From ed8537775c84a1f5b062118ed7ffd61556dbb5ec Mon Sep 17 00:00:00 2001 From: kislovdm Date: Thu, 2 Apr 2026 21:25:55 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BC=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Telegram2VkBot/Program.cs | 5 +- src/Telegram2VkBot/Telegram2VkBot.csproj | 1 + src/Telegram2VkBot/VkApiClient.cs | 313 +++++++++++------------ 3 files changed, 152 insertions(+), 167 deletions(-) diff --git a/src/Telegram2VkBot/Program.cs b/src/Telegram2VkBot/Program.cs index 8035eb2..f1646af 100644 --- a/src/Telegram2VkBot/Program.cs +++ b/src/Telegram2VkBot/Program.cs @@ -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(builder.Configuration.GetSection("VK")); builder.Services.Configure(builder.Configuration.GetSection("DB")); - builder.Services.AddHttpClient(client => + builder.Services.AddHttpClient(VkApiClient.UploadHttpClientName, client => { client.Timeout = TimeSpan.FromSeconds(60); }); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/src/Telegram2VkBot/Telegram2VkBot.csproj b/src/Telegram2VkBot/Telegram2VkBot.csproj index 6176ea8..9ad51d4 100644 --- a/src/Telegram2VkBot/Telegram2VkBot.csproj +++ b/src/Telegram2VkBot/Telegram2VkBot.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Telegram2VkBot/VkApiClient.cs b/src/Telegram2VkBot/VkApiClient.cs index 59e4e25..82aff43 100644 --- a/src/Telegram2VkBot/VkApiClient.cs +++ b/src/Telegram2VkBot/VkApiClient.cs @@ -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 _logger; + private readonly VkApi _api; - public VkApiClient(IOptions options, HttpClient http, ILogger logger) + public VkApiClient( + IOptions options, + IHttpClientFactory httpFactory, + ILogger 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 WallPostAsync( long ownerId, string message, string? attachments, CancellationToken ct) { - var parameters = new Dictionary + 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 + 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 UploadPhotoAndGetAttachmentAsync( byte[] photoBytes, CancellationToken ct) { - // 1) photos.getUploadServer - var uploadServerResponse = await CallMethodAsync( - "photos.getUploadServer", - new Dictionary - { - ["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 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 + 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); + + /// + /// VkNet ожидает коллекцию ; строка из БД вида photo-123_456 парсится в . + /// + private static IEnumerable? ParsePhotoAttachments(string? attachments) + { + if (string.IsNullOrWhiteSpace(attachments)) + return null; + + var parts = attachments.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var list = new List(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 CallMethodAsync( - string method, - IReadOnlyDictionary parameters, - CancellationToken ct) + private void ApplyApiVersion(string version) { - var query = new Dictionary(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 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(), ")"); } } -