мл
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Telegram2VkBot;
|
||||
|
||||
@@ -13,10 +13,11 @@ public static class Program
|
||||
builder.Services.Configure<VkOptions>(builder.Configuration.GetSection("VK"));
|
||||
builder.Services.Configure<DbOptions>(builder.Configuration.GetSection("DB"));
|
||||
|
||||
builder.Services.AddHttpClient<VkApiClient>(client =>
|
||||
builder.Services.AddHttpClient(VkApiClient.UploadHttpClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
});
|
||||
builder.Services.AddSingleton<VkApiClient>();
|
||||
builder.Services.AddSingleton<MappingRepository>();
|
||||
|
||||
builder.Services.AddHostedService<ForwardWorker>();
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="Telegram.Bot" Version="22.9.5.3" />
|
||||
<PackageReference Include="VkNet" Version="1.78.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
+148
-165
@@ -1,49 +1,78 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using VkNet;
|
||||
using VkNet.Exception;
|
||||
using VkNet.Model;
|
||||
|
||||
namespace Telegram2VkBot;
|
||||
|
||||
public sealed class VkApiClient
|
||||
public sealed class VkApiClient : IDisposable
|
||||
{
|
||||
private const string VkApiBaseUrl = "https://api.vk.com/method/";
|
||||
public const string UploadHttpClientName = "VkUpload";
|
||||
private const int LogParamMaxLength = 240;
|
||||
private const int LogBodyMaxLength = 8192;
|
||||
private const string MultipartBoundary = "-------0988";
|
||||
|
||||
private readonly VkOptions _options;
|
||||
private readonly HttpClient _http;
|
||||
private readonly JsonSerializerOptions _json;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly ILogger<VkApiClient> _logger;
|
||||
private readonly VkApi _api;
|
||||
|
||||
public VkApiClient(IOptions<VkOptions> options, HttpClient http, ILogger<VkApiClient> logger)
|
||||
public VkApiClient(
|
||||
IOptions<VkOptions> options,
|
||||
IHttpClientFactory httpFactory,
|
||||
ILogger<VkApiClient> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_http = http;
|
||||
_httpFactory = httpFactory;
|
||||
_logger = logger;
|
||||
_json = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
_api = new VkApi();
|
||||
ApplyApiVersion(_options.ApiVersion);
|
||||
_api.Authorize(new ApiAuthParams
|
||||
{
|
||||
AccessToken = _options.AccessToken,
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose() => _api.Dispose();
|
||||
|
||||
public async Task<int> WallPostAsync(
|
||||
long ownerId,
|
||||
string message,
|
||||
string? attachments,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var parameters = new Dictionary<string, string>
|
||||
var wallParams = new WallPostParams
|
||||
{
|
||||
["owner_id"] = ownerId.ToString(),
|
||||
["message"] = message ?? string.Empty,
|
||||
OwnerId = ownerId,
|
||||
FromGroup = true,
|
||||
Message = message ?? string.Empty,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(attachments))
|
||||
{
|
||||
parameters["attachments"] = attachments;
|
||||
}
|
||||
var media = ParsePhotoAttachments(attachments);
|
||||
if (media != null)
|
||||
wallParams.Attachments = media;
|
||||
|
||||
var response = await CallMethodAsync("wall.post", parameters, ct);
|
||||
// response: { "post_id": 123 }
|
||||
return response.GetProperty("post_id").GetInt32();
|
||||
_logger.LogInformation(
|
||||
"VkNet Wall.Post request: OwnerId={OwnerId}, FromGroup=true, MessageLen={MessageLen}, MessagePreview={MessagePreview}, Attachments={Attachments}",
|
||||
ownerId,
|
||||
wallParams.Message.Length,
|
||||
TruncateForLog(wallParams.Message, 200),
|
||||
attachments ?? "(none)");
|
||||
|
||||
try
|
||||
{
|
||||
var postId = await _api.Wall.PostAsync(wallParams, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("VkNet Wall.Post response: post_id={PostId}", postId);
|
||||
return postId > int.MaxValue ? throw new InvalidOperationException($"post_id слишком большой для хранения: {postId}") : (int)postId;
|
||||
}
|
||||
catch (VkApiException ex)
|
||||
{
|
||||
_logger.LogError(ex, "VkNet Wall.Post failed (VK code {ErrorCode}): {Message}", ex.ErrorCode, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task WallEditAsync(
|
||||
@@ -53,189 +82,144 @@ public sealed class VkApiClient
|
||||
string? attachments,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var parameters = new Dictionary<string, string>
|
||||
var editParams = new WallEditParams
|
||||
{
|
||||
["owner_id"] = ownerId.ToString(),
|
||||
["post_id"] = postId.ToString(),
|
||||
["message"] = message ?? string.Empty,
|
||||
OwnerId = ownerId,
|
||||
PostId = postId,
|
||||
Message = message ?? string.Empty,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(attachments))
|
||||
{
|
||||
parameters["attachments"] = attachments;
|
||||
}
|
||||
var mediaEdit = ParsePhotoAttachments(attachments);
|
||||
if (mediaEdit != null)
|
||||
editParams.Attachments = mediaEdit;
|
||||
|
||||
// response: 1
|
||||
await CallMethodAsync("wall.edit", parameters, ct);
|
||||
_logger.LogInformation(
|
||||
"VkNet Wall.Edit request: OwnerId={OwnerId}, PostId={PostId}, MessageLen={MessageLen}, MessagePreview={MessagePreview}, Attachments={Attachments}",
|
||||
ownerId,
|
||||
postId,
|
||||
editParams.Message.Length,
|
||||
TruncateForLog(editParams.Message, 200),
|
||||
attachments ?? "(none)");
|
||||
|
||||
try
|
||||
{
|
||||
await _api.Wall.EditAsync(editParams, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("VkNet Wall.Edit completed for post_id={PostId}", postId);
|
||||
}
|
||||
catch (VkApiException ex)
|
||||
{
|
||||
_logger.LogError(ex, "VkNet Wall.Edit failed (VK code {ErrorCode}): {Message}", ex.ErrorCode, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> UploadPhotoAndGetAttachmentAsync(
|
||||
byte[] photoBytes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// 1) photos.getUploadServer
|
||||
var uploadServerResponse = await CallMethodAsync(
|
||||
"photos.getUploadServer",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["group_id"] = _options.GroupId.ToString(),
|
||||
},
|
||||
ct);
|
||||
var groupId = _options.GroupId;
|
||||
var groupIdUlong = groupId > 0 ? (ulong)groupId : throw new ArgumentOutOfRangeException(nameof(groupId));
|
||||
|
||||
var uploadUrl = uploadServerResponse.GetProperty("upload_url").GetString();
|
||||
var uploadServer = await _api.Photo.GetWallUploadServerAsync(groupId, ct).ConfigureAwait(false);
|
||||
var uploadUrl = uploadServer.UploadUrl;
|
||||
if (string.IsNullOrWhiteSpace(uploadUrl))
|
||||
{
|
||||
_logger.LogWarning("VK upload_url is empty");
|
||||
_logger.LogWarning("VK GetWallUploadServer: UploadUrl пуст");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2) Upload bytes to upload_url (VK expects multipart form with field name 'photo')
|
||||
using var form = new MultipartFormDataContent();
|
||||
var byteContent = new ByteArrayContent(photoBytes);
|
||||
byteContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
form.Add(byteContent, "photo", "photo.jpg");
|
||||
|
||||
_logger.LogInformation(
|
||||
"VK upload → POST multipart (photo.jpg, {Size} bytes) to {UploadUrl}",
|
||||
"VK upload → POST multipart ({FileName}, {Size} bytes) to {UploadUrl}",
|
||||
"photo.jpg",
|
||||
photoBytes.Length,
|
||||
TruncateForLog(uploadUrl, 512));
|
||||
|
||||
using var uploadResp = await _http.PostAsync(uploadUrl, form, ct);
|
||||
var uploadRespText = await uploadResp.Content.ReadAsStringAsync(ct);
|
||||
using var form = new MultipartFormDataContent(MultipartBoundary);
|
||||
var bytes = new ByteArrayContent(photoBytes);
|
||||
bytes.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
|
||||
form.Add(bytes, "photo", $"{Guid.NewGuid():N}.jpg");
|
||||
|
||||
var http = _httpFactory.CreateClient(UploadHttpClientName);
|
||||
using var uploadResp = await http.PostAsync(uploadUrl, form, ct).ConfigureAwait(false);
|
||||
var result = await uploadResp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"VK upload ← HTTP {StatusCode}, body: {Body}",
|
||||
(int)uploadResp.StatusCode,
|
||||
TruncateForLog(uploadRespText, LogBodyMaxLength));
|
||||
TruncateForLog(result, LogBodyMaxLength));
|
||||
|
||||
if (!uploadResp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("VK upload failed: {StatusCode} {Body}", uploadResp.StatusCode, uploadRespText);
|
||||
_logger.LogWarning("VK upload failed: {StatusCode} {Body}", uploadResp.StatusCode, TruncateForLog(result, LogBodyMaxLength));
|
||||
return null;
|
||||
}
|
||||
|
||||
using var uploadDoc = JsonDocument.Parse(uploadRespText);
|
||||
// upload response: { server, photo, hash, ... }
|
||||
var server = uploadDoc.RootElement.GetProperty("server").GetString() ?? "";
|
||||
var photo = uploadDoc.RootElement.GetProperty("photo").GetString() ?? "";
|
||||
var hash = uploadDoc.RootElement.GetProperty("hash").GetString() ?? "";
|
||||
IReadOnlyCollection<Photo> saved;
|
||||
try
|
||||
{
|
||||
saved = await _api.Photo.SaveWallPhotoAsync(
|
||||
response: result,
|
||||
userId: 0,
|
||||
groupId: groupIdUlong,
|
||||
caption: null,
|
||||
token: ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (VkApiException ex)
|
||||
{
|
||||
_logger.LogError(ex, "VkNet Photo.SaveWallPhotoAsync failed (VK code {ErrorCode}): {Message}", ex.ErrorCode, ex.Message);
|
||||
throw;
|
||||
}
|
||||
|
||||
// 3) photos.saveWallPhoto
|
||||
var saved = await CallMethodAsync(
|
||||
"photos.saveWallPhoto",
|
||||
new Dictionary<string, string>
|
||||
var photo = saved.FirstOrDefault();
|
||||
if (photo == null)
|
||||
{
|
||||
_logger.LogWarning("VkNet SaveWallPhoto: пустой результат");
|
||||
return null;
|
||||
}
|
||||
|
||||
var attachment = $"photo{photo.OwnerId}_{photo.Id}";
|
||||
_logger.LogInformation("VkNet SaveWallPhoto response: {Attachment}", attachment);
|
||||
return attachment;
|
||||
}
|
||||
|
||||
private static readonly Regex PhotoAttachmentRegex = new(@"^photo(-?\d+)_(\d+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// VkNet ожидает коллекцию <see cref="MediaAttachment"/>; строка из БД вида photo-123_456 парсится в <see cref="Photo"/>.
|
||||
/// </summary>
|
||||
private static IEnumerable<MediaAttachment>? ParsePhotoAttachments(string? attachments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachments))
|
||||
return null;
|
||||
|
||||
var parts = attachments.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var list = new List<MediaAttachment>(parts.Length);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var m = PhotoAttachmentRegex.Match(part);
|
||||
if (!m.Success)
|
||||
throw new InvalidOperationException($"Неподдерживаемый формат вложения для VkNet (ожидается photo{{owner}}_{{id}}): {part}");
|
||||
|
||||
list.Add(new Photo
|
||||
{
|
||||
["group_id"] = _options.GroupId.ToString(),
|
||||
["server"] = server,
|
||||
["photo"] = photo,
|
||||
["hash"] = hash,
|
||||
},
|
||||
ct);
|
||||
|
||||
// saved response usually: { "photos": [ { "id": ..., "owner_id": ... } ] }
|
||||
if (saved.TryGetProperty("photos", out var photos) && photos.GetArrayLength() > 0)
|
||||
{
|
||||
var first = photos[0];
|
||||
var ownerId = first.GetProperty("owner_id").GetInt32();
|
||||
var id = first.GetProperty("id").GetInt32();
|
||||
return $"photo{ownerId}_{id}";
|
||||
OwnerId = long.Parse(m.Groups[1].ValueSpan, CultureInfo.InvariantCulture),
|
||||
Id = long.Parse(m.Groups[2].ValueSpan, CultureInfo.InvariantCulture),
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogWarning("VK photos.saveWallPhoto response has no photos array");
|
||||
return null;
|
||||
return list;
|
||||
}
|
||||
|
||||
private async Task<JsonElement> CallMethodAsync(
|
||||
string method,
|
||||
IReadOnlyDictionary<string, string> parameters,
|
||||
CancellationToken ct)
|
||||
private void ApplyApiVersion(string version)
|
||||
{
|
||||
var query = new Dictionary<string, string>(parameters)
|
||||
var parts = version.Trim().Split('.', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length == 2
|
||||
&& int.TryParse(parts[0], System.Globalization.NumberStyles.Integer, null, out var major)
|
||||
&& int.TryParse(parts[1], System.Globalization.NumberStyles.Integer, null, out var minor))
|
||||
{
|
||||
["access_token"] = _options.AccessToken,
|
||||
["v"] = _options.ApiVersion,
|
||||
};
|
||||
|
||||
var requestUrl = VkApiBaseUrl + method;
|
||||
_logger.LogInformation(
|
||||
"VK API → POST {Url}, form: {Form}",
|
||||
requestUrl,
|
||||
FormatFormForLog(query));
|
||||
|
||||
using var content = new FormUrlEncodedContent(query);
|
||||
using var resp = await _http.PostAsync(requestUrl, content, ct);
|
||||
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||
var bodyForLog = TruncateForLog(body, LogBodyMaxLength);
|
||||
|
||||
_logger.LogInformation(
|
||||
"VK API ← {Method}: HTTP {StatusCode}, body: {Body}",
|
||||
method,
|
||||
(int)resp.StatusCode,
|
||||
bodyForLog);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError(
|
||||
"VK API HTTP error for {Method}: {StatusCode}, body: {Body}",
|
||||
method,
|
||||
(int)resp.StatusCode,
|
||||
bodyForLog);
|
||||
throw new InvalidOperationException($"VK API HTTP error: {(int)resp.StatusCode} {body}");
|
||||
_api.VkApiVersion.SetVersion(major, minor);
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("error", out var error))
|
||||
{
|
||||
var message = error.TryGetProperty("error_msg", out var em) ? em.GetString() : null;
|
||||
var code = error.TryGetProperty("error_code", out var ec) ? ec.ToString() : null;
|
||||
_logger.LogError(
|
||||
"VK API error body for {Method}: {Body}",
|
||||
method,
|
||||
bodyForLog);
|
||||
throw new InvalidOperationException($"VK API error: code={code} msg={message}");
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("response", out var response))
|
||||
{
|
||||
throw new InvalidOperationException($"VK API unexpected response: {body}");
|
||||
}
|
||||
|
||||
var responsePayload = response.GetRawText();
|
||||
_logger.LogDebug("VK API response.extracted for {Method}: {Payload}", method, TruncateForLog(responsePayload, LogBodyMaxLength));
|
||||
|
||||
// Return detached element by cloning to avoid disposing doc
|
||||
return JsonDocument.Parse(responsePayload).RootElement.Clone();
|
||||
}
|
||||
|
||||
private string FormatFormForLog(IReadOnlyDictionary<string, string> form)
|
||||
{
|
||||
var sb = new StringBuilder(capacity: Math.Min(512, form.Count * 32));
|
||||
var first = true;
|
||||
foreach (var (key, value) in form.OrderBy(static kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (!first)
|
||||
sb.Append("; ");
|
||||
first = false;
|
||||
sb.Append(key);
|
||||
sb.Append('=');
|
||||
sb.Append(key.Equals("access_token", StringComparison.OrdinalIgnoreCase)
|
||||
? "***"
|
||||
: SanitizeParamForLog(key, value));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string SanitizeParamForLog(string key, string value)
|
||||
{
|
||||
if (value.Length <= LogParamMaxLength)
|
||||
return value;
|
||||
|
||||
// VK передаёт поле photo как длинную строка; message тоже может быть огромным.
|
||||
var previewLen = Math.Min(120, LogParamMaxLength);
|
||||
var head = value[..previewLen];
|
||||
return $"{head}…(truncated, len={value.Length}, key={key})";
|
||||
}
|
||||
|
||||
private static string TruncateForLog(string? text, int maxLen)
|
||||
@@ -247,4 +231,3 @@ public sealed class VkApiClient
|
||||
return string.Concat(text.AsSpan(0, maxLen), "…(truncated, totalLen=", text.Length.ToString(), ")");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user