This commit is contained in:
kislovdm
2026-04-02 21:25:55 +03:00
parent 10d54e870f
commit ed8537775c
3 changed files with 152 additions and 167 deletions
+3 -2
View File
@@ -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>();
+1
View File
@@ -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>
+139 -156
View File
@@ -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() ?? "";
// 3) photos.saveWallPhoto
var saved = await CallMethodAsync(
"photos.saveWallPhoto",
new Dictionary<string, string>
IReadOnlyCollection<Photo> saved;
try
{
["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)
saved = await _api.Photo.SaveWallPhotoAsync(
response: result,
userId: 0,
groupId: groupIdUlong,
caption: null,
token: ct)
.ConfigureAwait(false);
}
catch (VkApiException ex)
{
var first = photos[0];
var ownerId = first.GetProperty("owner_id").GetInt32();
var id = first.GetProperty("id").GetInt32();
return $"photo{ownerId}_{id}";
_logger.LogError(ex, "VkNet Photo.SaveWallPhotoAsync failed (VK code {ErrorCode}): {Message}", ex.ErrorCode, ex.Message);
throw;
}
_logger.LogWarning("VK photos.saveWallPhoto response has no photos array");
var photo = saved.FirstOrDefault();
if (photo == null)
{
_logger.LogWarning("VkNet SaveWallPhoto: пустой результат");
return null;
}
private async Task<JsonElement> CallMethodAsync(
string method,
IReadOnlyDictionary<string, string> parameters,
CancellationToken ct)
{
var query = new Dictionary<string, string>(parameters)
{
["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}");
var attachment = $"photo{photo.OwnerId}_{photo.Id}";
_logger.LogInformation("VkNet SaveWallPhoto response: {Attachment}", attachment);
return attachment;
}
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
private static readonly Regex PhotoAttachmentRegex = new(@"^photo(-?\d+)_(\d+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
if (root.TryGetProperty("error", out var error))
/// <summary>
/// VkNet ожидает коллекцию <see cref="MediaAttachment"/>; строка из БД вида photo-123_456 парсится в <see cref="Photo"/>.
/// </summary>
private static IEnumerable<MediaAttachment>? ParsePhotoAttachments(string? attachments)
{
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 (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
{
OwnerId = long.Parse(m.Groups[1].ValueSpan, CultureInfo.InvariantCulture),
Id = long.Parse(m.Groups[2].ValueSpan, CultureInfo.InvariantCulture),
});
}
if (!root.TryGetProperty("response", out var response))
{
throw new InvalidOperationException($"VK API unexpected response: {body}");
return list;
}
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)
private void ApplyApiVersion(string version)
{
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))
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))
{
if (!first)
sb.Append("; ");
first = false;
sb.Append(key);
sb.Append('=');
sb.Append(key.Equals("access_token", StringComparison.OrdinalIgnoreCase)
? "***"
: SanitizeParamForLog(key, value));
_api.VkApiVersion.SetVersion(major, minor);
}
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(), ")");
}
}