commit f0f41aebb8ce50537c9c40ee287f3f8a62edbe7f Author: kislovdm Date: Thu Apr 2 20:22:29 2026 +0300 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..91c4f65 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/bin +**/obj +**/.git +**/.github +**/.vscode +**/.idea +**/*.user +**/*.suo +**/*.log +data/ + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5df229c --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +TELEGRAM_BOT_TOKEN=PASTE_TELEGRAM_BOT_TOKEN +# Для каналов часто это ID вида -1001234567890 +TELEGRAM_CHANNEL_ID=-1001234567890 + +VK_ACCESS_TOKEN=PASTE_VK_ACCESS_TOKEN +# ID сообщества без минуса +VK_GROUP_ID=123456 + +# Необязательно (по умолчанию 5.199) +VK_API_VERSION=5.199 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53a3648 --- /dev/null +++ b/.gitignore @@ -0,0 +1,194 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Xamarin Studio / monodevelop user-specific +*.userprefs +*.dll.mdb +*.exe.mdb + +# Build results + +[Dd]ebug/ +[Rr]elease/ +x64/ +build/ +[Bb]in/ +[Oo]bj/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +*_i.c +*_p.c +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.log +*.scc + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# Other Visual Studio data +.vs/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +*.ncrunch* +.*crunch*.local.xml + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.Publish.xml + +# Enable nuget.exe in the .nuget folder (though normally executables are not tracked) +!.nuget/NuGet.exe + +# Windows Azure Build Output +csx +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# VSCode +.vscode/ + +# Others +sql/ +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.[Pp]ublish.xml +*.pfx +*.publishsettings + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file to a newer +# Visual Studio version. Backup files are not needed, because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +App_Data/*.mdf +App_Data/*.ldf + + +#LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml + +# ========================= +# Windows detritus +# ========================= + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Mac desktop service store files +.DS_Store + +# =================================================== +# Exclude F# project specific directories and files +# =================================================== + +# NuGet Packages Directory +packages/ + +# Test results produced by build +TestResults.xml + +# Nuget outputs +nuget/*.nupkg +release.cmd +release.sh +localpackages/ +paket-files +*.orig +docsrc/content/license.md +docsrc/content/release-notes.md +.fake +.idea +src/.idea +docsrc/tools/FSharp.Formatting.svclog +artifacts +/artifacts +/artifacts + +.ionide +AssemblyInfo.cs +launchSettings.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6593d17 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY ./src ./src + +RUN dotnet restore "src/Telegram2VkBot/Telegram2VkBot.csproj" +RUN dotnet publish "src/Telegram2VkBot/Telegram2VkBot.csproj" -c Release -o /app/publish --no-restore + +FROM mcr.microsoft.com/dotnet/runtime:8.0 AS runtime +WORKDIR /app + +COPY --from=build /app/publish . + +ENTRYPOINT ["dotnet", "Telegram2VkBot.dll"] + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4ab76f7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + telegram2vk: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + environment: + TELEGRAM__BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} + TELEGRAM__CHANNEL_ID: ${TELEGRAM_CHANNEL_ID} + VK__ACCESS_TOKEN: ${VK_ACCESS_TOKEN} + VK__GROUP_ID: ${VK_GROUP_ID} + VK__API_VERSION: ${VK_API_VERSION:-5.199} + DB__PATH: /data/mapping.sqlite + volumes: + - ./data:/data + diff --git a/src/Telegram2VkBot/AppOptions.cs b/src/Telegram2VkBot/AppOptions.cs new file mode 100644 index 0000000..f6c3e82 --- /dev/null +++ b/src/Telegram2VkBot/AppOptions.cs @@ -0,0 +1,20 @@ +namespace Telegram2VkBot; + +public sealed class TelegramOptions +{ + public required string BotToken { get; init; } + public required long ChannelId { get; init; } +} + +public sealed class VkOptions +{ + public required string AccessToken { get; init; } + public required long GroupId { get; init; } + public string ApiVersion { get; init; } = "5.199"; +} + +public sealed class DbOptions +{ + public required string Path { get; init; } +} + diff --git a/src/Telegram2VkBot/ForwardWorker.cs b/src/Telegram2VkBot/ForwardWorker.cs new file mode 100644 index 0000000..e3d747d --- /dev/null +++ b/src/Telegram2VkBot/ForwardWorker.cs @@ -0,0 +1,199 @@ +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using Microsoft.Extensions.Options; +using Telegram.Bot.Polling; + +namespace Telegram2VkBot; + +public sealed class ForwardWorker : BackgroundService +{ + private readonly TelegramOptions _telegram; + private readonly VkOptions _vk; + private readonly VkApiClient _vkApi; + private readonly MappingRepository _repo; + private readonly ILogger _logger; + + public ForwardWorker( + IOptions telegram, + IOptions vk, + IOptions db, + VkApiClient vkApi, + MappingRepository repo, + ILogger logger) + { + _telegram = telegram.Value; + _vk = vk.Value; + _vkApi = vkApi; + _repo = repo; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (string.IsNullOrWhiteSpace(_telegram.BotToken)) + throw new ArgumentException("TELEGRAM__BOT_TOKEN is empty"); + if (_telegram.ChannelId == 0) + throw new ArgumentException("TELEGRAM__CHANNEL_ID must be non-zero (example: -1001234567890)"); + if (string.IsNullOrWhiteSpace(_vk.AccessToken)) + throw new ArgumentException("VK__ACCESS_TOKEN is empty"); + if (_vk.GroupId == 0) + throw new ArgumentException("VK__GROUP_ID must be non-zero (community id without minus)"); + + _logger.LogInformation("Init database"); + await _repo.InitializeAsync(stoppingToken); + + var bot = new TelegramBotClient(_telegram.BotToken); + var receiverOptions = new ReceiverOptions + { + AllowedUpdates = new[] + { + UpdateType.ChannelPost, + UpdateType.EditedChannelPost + } + }; + + _logger.LogInformation("Start Telegram polling for channel {ChannelId}", _telegram.ChannelId); + + using var semaphore = new SemaphoreSlim(1, 1); + + bot.StartReceiving( + async (client, update, ct) => + { + await semaphore.WaitAsync(ct); + try + { + await HandleUpdateAsync(update, bot, ct); + } + finally + { + semaphore.Release(); + } + }, + HandleErrorAsync, + receiverOptions, + stoppingToken); + + // StartReceiving не блокирует, поэтому держим сервис живым. + await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken); + } + + private async Task HandleUpdateAsync(Update update, TelegramBotClient bot, CancellationToken ct) + { + if (update.EditedChannelPost is { } edited && edited.Chat.Id == _telegram.ChannelId) + { + await HandleChannelPostEditAsync(bot, edited, ct); + return; + } + + if (update.ChannelPost is { } msg && msg.Chat.Id == _telegram.ChannelId) + { + await HandleChannelPostAsync(bot, msg, ct); + return; + } + } + + private async Task HandleChannelPostAsync(TelegramBotClient bot, Message message, CancellationToken ct) + { + var ownerId = -Math.Abs(_vk.GroupId); + var telegramMessageId = message.MessageId; + + var text = ExtractTelegramText(message); + var attachments = await ExtractPhotoAttachmentIfAnyAsync(bot, message, ct); + + // Если это не текст и не фото, просто пропускаем (сложно маппить видео/документы в VK без доп. логики). + if (string.IsNullOrWhiteSpace(text) && string.IsNullOrWhiteSpace(attachments)) + { + _logger.LogInformation("Skip message {MessageId}: no text/photo", telegramMessageId); + return; + } + + var postId = await _vkApi.WallPostAsync(ownerId, text ?? string.Empty, attachments, ct); + await _repo.UpsertAsync( + telegramChatId: _telegram.ChannelId, + telegramMessageId: telegramMessageId, + vkOwnerId: ownerId, + vkPostId: postId, + vkMessage: text, + vkAttachments: attachments, + ct); + + _logger.LogInformation("Posted new VK post {PostId} for Telegram {MessageId}", postId, telegramMessageId); + } + + private async Task HandleChannelPostEditAsync(TelegramBotClient bot, Message editedMessage, CancellationToken ct) + { + var telegramMessageId = editedMessage.MessageId; + var ownerId = -Math.Abs(_vk.GroupId); + + var existing = await _repo.GetAsync(_telegram.ChannelId, telegramMessageId, ct); + + var extractedText = ExtractTelegramText(editedMessage); + + // Если фото в апдейте отсутствует, то оставляем старые вложения, чтобы не "стереть" медиа на стороне VK. + string? extractedAttachments = await ExtractPhotoAttachmentIfAnyAsync(bot, editedMessage, ct); + var attachmentsToSend = !string.IsNullOrWhiteSpace(extractedAttachments) + ? extractedAttachments + : existing?.VkAttachments; + + var messageToSend = !string.IsNullOrWhiteSpace(extractedText) + ? extractedText + : existing?.VkMessage; + + if (existing == null) + { + _logger.LogWarning("Mapping not found for edited Telegram {MessageId}; posting new VK post", telegramMessageId); + if (string.IsNullOrWhiteSpace(messageToSend) && string.IsNullOrWhiteSpace(attachmentsToSend)) + return; + + var newPostId = await _vkApi.WallPostAsync(ownerId, messageToSend ?? string.Empty, attachmentsToSend, ct); + await _repo.UpsertAsync(_telegram.ChannelId, telegramMessageId, ownerId, newPostId, messageToSend, attachmentsToSend, ct); + return; + } + + if (messageToSend == null) messageToSend = existing.Value.VkMessage; + if (attachmentsToSend == null) attachmentsToSend = existing.Value.VkAttachments; + + var messageToSendFinal = messageToSend ?? string.Empty; + + await _vkApi.WallEditAsync(existing.Value.VkOwnerId, existing.Value.VkPostId, messageToSendFinal, attachmentsToSend, ct); + await _repo.UpsertAsync(_telegram.ChannelId, telegramMessageId, existing.Value.VkOwnerId, existing.Value.VkPostId, messageToSendFinal, attachmentsToSend, ct); + + _logger.LogInformation("Edited VK post {PostId} for Telegram {MessageId}", existing.Value.VkPostId, telegramMessageId); + } + + private static string? ExtractTelegramText(Message message) + { + if (!string.IsNullOrWhiteSpace(message.Text)) + return message.Text; + if (!string.IsNullOrWhiteSpace(message.Caption)) + return message.Caption; + return null; + } + + private async Task ExtractPhotoAttachmentIfAnyAsync(TelegramBotClient bot, Message message, CancellationToken ct) + { + if (message.Photo == null || message.Photo.Length == 0) + return null; + + // Telegram предоставляет несколько размеров одного фото. Берем самый большой. + var best = message.Photo[^1]; + + await using var ms = new MemoryStream(); + // В Telegram.Bot v22 методы называются без Async-суффикса. + await bot.GetInfoAndDownloadFile(best.FileId, ms, ct); + + return await _vkApi.UploadPhotoAndGetAttachmentAsync(ms.ToArray(), ct); + } + + private static Task HandleErrorAsync( + ITelegramBotClient client, + Exception exception, + HandleErrorSource errorSource, + CancellationToken cancellationToken) + { + // Логирование делаем в Worker через исключения, но чтобы не терять ошибки polling'а — просто возвращаем CompletedTask. + return Task.CompletedTask; + } +} + diff --git a/src/Telegram2VkBot/MappingRepository.cs b/src/Telegram2VkBot/MappingRepository.cs new file mode 100644 index 0000000..b5655e4 --- /dev/null +++ b/src/Telegram2VkBot/MappingRepository.cs @@ -0,0 +1,124 @@ +using Microsoft.Data.Sqlite; + +namespace Telegram2VkBot; + +public sealed class MappingRepository +{ + private readonly DbOptions _dbOptions; + private readonly ILogger _logger; + + public MappingRepository(DbOptions dbOptions, ILogger logger) + { + _dbOptions = dbOptions; + _logger = logger; + } + + private string ConnectionString => $"Data Source={_dbOptions.Path};Cache=Shared"; + + public async Task InitializeAsync(CancellationToken ct) + { + var dir = Path.GetDirectoryName(_dbOptions.Path); + if (!string.IsNullOrWhiteSpace(dir)) + { + Directory.CreateDirectory(dir); + } + + await using var con = new SqliteConnection(ConnectionString); + await con.OpenAsync(ct); + + var sql = @" +CREATE TABLE IF NOT EXISTS vk_mappings ( + telegram_chat_id INTEGER NOT NULL, + telegram_message_id INTEGER NOT NULL, + vk_owner_id INTEGER NOT NULL, + vk_post_id INTEGER NOT NULL, + vk_message TEXT NULL, + vk_attachments TEXT NULL, + PRIMARY KEY (telegram_chat_id, telegram_message_id) +);"; + + await using var cmd = con.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(ct); + } + + public async Task<(long VkOwnerId, int VkPostId, string? VkMessage, string? VkAttachments)?> GetAsync( + long telegramChatId, + int telegramMessageId, + CancellationToken ct) + { + await using var con = new SqliteConnection(ConnectionString); + await con.OpenAsync(ct); + + var sql = @" +SELECT vk_owner_id, vk_post_id, vk_message, vk_attachments +FROM vk_mappings +WHERE telegram_chat_id = $telegram_chat_id AND telegram_message_id = $telegram_message_id +LIMIT 1;"; + + await using var cmd = con.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("$telegram_chat_id", telegramChatId); + cmd.Parameters.AddWithValue("$telegram_message_id", telegramMessageId); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + if (await reader.ReadAsync(ct)) + { + var vkOwnerId = reader.GetInt64(0); + var vkPostId = reader.GetInt32(1); + var vkMessage = reader.IsDBNull(2) ? null : reader.GetString(2); + var vkAttachments = reader.IsDBNull(3) ? null : reader.GetString(3); + return (vkOwnerId, vkPostId, vkMessage, vkAttachments); + } + + return null; + } + + public async Task UpsertAsync( + long telegramChatId, + int telegramMessageId, + long vkOwnerId, + int vkPostId, + string? vkMessage, + string? vkAttachments, + CancellationToken ct) + { + await using var con = new SqliteConnection(ConnectionString); + await con.OpenAsync(ct); + + var sql = @" +INSERT INTO vk_mappings ( + telegram_chat_id, + telegram_message_id, + vk_owner_id, + vk_post_id, + vk_message, + vk_attachments +) +VALUES ( + $telegram_chat_id, + $telegram_message_id, + $vk_owner_id, + $vk_post_id, + $vk_message, + $vk_attachments +) +ON CONFLICT (telegram_chat_id, telegram_message_id) DO UPDATE SET + vk_owner_id = excluded.vk_owner_id, + vk_post_id = excluded.vk_post_id, + vk_message = excluded.vk_message, + vk_attachments = excluded.vk_attachments;"; + + await using var cmd = con.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("$telegram_chat_id", telegramChatId); + cmd.Parameters.AddWithValue("$telegram_message_id", telegramMessageId); + cmd.Parameters.AddWithValue("$vk_owner_id", vkOwnerId); + cmd.Parameters.AddWithValue("$vk_post_id", vkPostId); + cmd.Parameters.AddWithValue("$vk_message", (object?)vkMessage ?? DBNull.Value); + cmd.Parameters.AddWithValue("$vk_attachments", (object?)vkAttachments ?? DBNull.Value); + + await cmd.ExecuteNonQueryAsync(ct); + } +} + diff --git a/src/Telegram2VkBot/Program.cs b/src/Telegram2VkBot/Program.cs new file mode 100644 index 0000000..8035eb2 --- /dev/null +++ b/src/Telegram2VkBot/Program.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace Telegram2VkBot; + +public static class Program +{ + public static async Task Main(string[] args) + { + var builder = Host.CreateApplicationBuilder(args); + + builder.Services.Configure(builder.Configuration.GetSection("TELEGRAM")); + builder.Services.Configure(builder.Configuration.GetSection("VK")); + builder.Services.Configure(builder.Configuration.GetSection("DB")); + + builder.Services.AddHttpClient(client => + { + client.Timeout = TimeSpan.FromSeconds(60); + }); + builder.Services.AddSingleton(); + + builder.Services.AddHostedService(); + + builder.Logging.AddSimpleConsole(options => + { + options.SingleLine = true; + options.TimestampFormat = "yyyy-MM-dd HH:mm:ss "; + }); + + var host = builder.Build(); + await host.RunAsync(); + } +} + diff --git a/src/Telegram2VkBot/Telegram2VkBot.csproj b/src/Telegram2VkBot/Telegram2VkBot.csproj new file mode 100644 index 0000000..4d988c7 --- /dev/null +++ b/src/Telegram2VkBot/Telegram2VkBot.csproj @@ -0,0 +1,16 @@ + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/src/Telegram2VkBot/VkApiClient.cs b/src/Telegram2VkBot/VkApiClient.cs new file mode 100644 index 0000000..3a1aa50 --- /dev/null +++ b/src/Telegram2VkBot/VkApiClient.cs @@ -0,0 +1,173 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace Telegram2VkBot; + +public sealed class VkApiClient +{ + private const string VkApiBaseUrl = "https://api.vk.com/method/"; + + private readonly VkOptions _options; + private readonly HttpClient _http; + private readonly JsonSerializerOptions _json; + private readonly ILogger _logger; + + public VkApiClient(IOptions options, HttpClient http, ILogger logger) + { + _options = options.Value; + _http = http; + _logger = logger; + _json = new JsonSerializerOptions(JsonSerializerDefaults.Web); + } + + public async Task WallPostAsync( + long ownerId, + string message, + string? attachments, + CancellationToken ct) + { + var parameters = new Dictionary + { + ["owner_id"] = ownerId.ToString(), + ["message"] = message ?? string.Empty, + }; + + if (!string.IsNullOrWhiteSpace(attachments)) + { + parameters["attachments"] = attachments; + } + + var response = await CallMethodAsync("wall.post", parameters, ct); + // response: { "post_id": 123 } + return response.GetProperty("post_id").GetInt32(); + } + + public async Task WallEditAsync( + long ownerId, + int postId, + string message, + string? attachments, + CancellationToken ct) + { + var parameters = new Dictionary + { + ["owner_id"] = ownerId.ToString(), + ["post_id"] = postId.ToString(), + ["message"] = message ?? string.Empty, + }; + + if (!string.IsNullOrWhiteSpace(attachments)) + { + parameters["attachments"] = attachments; + } + + // response: 1 + await CallMethodAsync("wall.edit", parameters, ct); + } + + 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 uploadUrl = uploadServerResponse.GetProperty("upload_url").GetString(); + if (string.IsNullOrWhiteSpace(uploadUrl)) + { + _logger.LogWarning("VK upload_url is empty"); + 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"); + + using var uploadResp = await _http.PostAsync(uploadUrl, form, ct); + var uploadRespText = await uploadResp.Content.ReadAsStringAsync(ct); + if (!uploadResp.IsSuccessStatusCode) + { + _logger.LogWarning("VK upload failed: {StatusCode} {Body}", uploadResp.StatusCode, uploadRespText); + 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() ?? ""; + + // 3) photos.saveWallPhoto + var saved = await CallMethodAsync( + "photos.saveWallPhoto", + new Dictionary + { + ["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}"; + } + + _logger.LogWarning("VK photos.saveWallPhoto response has no photos array"); + return null; + } + + private async Task CallMethodAsync( + string method, + IReadOnlyDictionary parameters, + CancellationToken ct) + { + var query = new Dictionary(parameters) + { + ["access_token"] = _options.AccessToken, + ["v"] = _options.ApiVersion, + }; + + using var content = new FormUrlEncodedContent(query); + using var resp = await _http.PostAsync(VkApiBaseUrl + method, content, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + + if (!resp.IsSuccessStatusCode) + { + throw new InvalidOperationException($"VK API HTTP error: {(int)resp.StatusCode} {body}"); + } + + 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; + 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}"); + } + + // Return detached element by cloning to avoid disposing doc + return JsonDocument.Parse(response.GetRawText()).RootElement.Clone(); + } +} +