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.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Telegram2VkBot; namespace Telegram2VkBot;
@@ -13,10 +13,11 @@ public static class Program
builder.Services.Configure<VkOptions>(builder.Configuration.GetSection("VK")); builder.Services.Configure<VkOptions>(builder.Configuration.GetSection("VK"));
builder.Services.Configure<DbOptions>(builder.Configuration.GetSection("DB")); builder.Services.Configure<DbOptions>(builder.Configuration.GetSection("DB"));
builder.Services.AddHttpClient<VkApiClient>(client => builder.Services.AddHttpClient(VkApiClient.UploadHttpClientName, client =>
{ {
client.Timeout = TimeSpan.FromSeconds(60); client.Timeout = TimeSpan.FromSeconds(60);
}); });
builder.Services.AddSingleton<VkApiClient>();
builder.Services.AddSingleton<MappingRepository>(); builder.Services.AddSingleton<MappingRepository>();
builder.Services.AddHostedService<ForwardWorker>(); 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.Logging.Console" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Telegram.Bot" Version="22.9.5.3" /> <PackageReference Include="Telegram.Bot" Version="22.9.5.3" />
<PackageReference Include="VkNet" Version="1.78.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+139 -156
View File
@@ -1,49 +1,78 @@
using System.Globalization;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text; using System.Text.RegularExpressions;
using System.Text.Json;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using VkNet;
using VkNet.Exception;
using VkNet.Model;
namespace Telegram2VkBot; 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 LogParamMaxLength = 240;
private const int LogBodyMaxLength = 8192; private const int LogBodyMaxLength = 8192;
private const string MultipartBoundary = "-------0988";
private readonly VkOptions _options; private readonly VkOptions _options;
private readonly HttpClient _http; private readonly IHttpClientFactory _httpFactory;
private readonly JsonSerializerOptions _json;
private readonly ILogger<VkApiClient> _logger; 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; _options = options.Value;
_http = http; _httpFactory = httpFactory;
_logger = logger; _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( public async Task<int> WallPostAsync(
long ownerId, long ownerId,
string message, string message,
string? attachments, string? attachments,
CancellationToken ct) CancellationToken ct)
{ {
var parameters = new Dictionary<string, string> var wallParams = new WallPostParams
{ {
["owner_id"] = ownerId.ToString(), OwnerId = ownerId,
["message"] = message ?? string.Empty, FromGroup = true,
Message = message ?? string.Empty,
}; };
if (!string.IsNullOrWhiteSpace(attachments)) var media = ParsePhotoAttachments(attachments);
{ if (media != null)
parameters["attachments"] = attachments; wallParams.Attachments = media;
}
var response = await CallMethodAsync("wall.post", parameters, ct); _logger.LogInformation(
// response: { "post_id": 123 } "VkNet Wall.Post request: OwnerId={OwnerId}, FromGroup=true, MessageLen={MessageLen}, MessagePreview={MessagePreview}, Attachments={Attachments}",
return response.GetProperty("post_id").GetInt32(); 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( public async Task WallEditAsync(
@@ -53,189 +82,144 @@ public sealed class VkApiClient
string? attachments, string? attachments,
CancellationToken ct) CancellationToken ct)
{ {
var parameters = new Dictionary<string, string> var editParams = new WallEditParams
{ {
["owner_id"] = ownerId.ToString(), OwnerId = ownerId,
["post_id"] = postId.ToString(), PostId = postId,
["message"] = message ?? string.Empty, Message = message ?? string.Empty,
}; };
if (!string.IsNullOrWhiteSpace(attachments)) var mediaEdit = ParsePhotoAttachments(attachments);
{ if (mediaEdit != null)
parameters["attachments"] = attachments; editParams.Attachments = mediaEdit;
}
// response: 1 _logger.LogInformation(
await CallMethodAsync("wall.edit", parameters, ct); "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( public async Task<string?> UploadPhotoAndGetAttachmentAsync(
byte[] photoBytes, byte[] photoBytes,
CancellationToken ct) CancellationToken ct)
{ {
// 1) photos.getUploadServer var groupId = _options.GroupId;
var uploadServerResponse = await CallMethodAsync( var groupIdUlong = groupId > 0 ? (ulong)groupId : throw new ArgumentOutOfRangeException(nameof(groupId));
"photos.getUploadServer",
new Dictionary<string, string>
{
["group_id"] = _options.GroupId.ToString(),
},
ct);
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)) if (string.IsNullOrWhiteSpace(uploadUrl))
{ {
_logger.LogWarning("VK upload_url is empty"); _logger.LogWarning("VK GetWallUploadServer: UploadUrl пуст");
return null; 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( _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, photoBytes.Length,
TruncateForLog(uploadUrl, 512)); TruncateForLog(uploadUrl, 512));
using var uploadResp = await _http.PostAsync(uploadUrl, form, ct); using var form = new MultipartFormDataContent(MultipartBoundary);
var uploadRespText = await uploadResp.Content.ReadAsStringAsync(ct); 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( _logger.LogInformation(
"VK upload ← HTTP {StatusCode}, body: {Body}", "VK upload ← HTTP {StatusCode}, body: {Body}",
(int)uploadResp.StatusCode, (int)uploadResp.StatusCode,
TruncateForLog(uploadRespText, LogBodyMaxLength)); TruncateForLog(result, LogBodyMaxLength));
if (!uploadResp.IsSuccessStatusCode) 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; return null;
} }
using var uploadDoc = JsonDocument.Parse(uploadRespText); IReadOnlyCollection<Photo> saved;
// upload response: { server, photo, hash, ... } try
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>
{ {
["group_id"] = _options.GroupId.ToString(), saved = await _api.Photo.SaveWallPhotoAsync(
["server"] = server, response: result,
["photo"] = photo, userId: 0,
["hash"] = hash, groupId: groupIdUlong,
}, caption: null,
ct); token: ct)
.ConfigureAwait(false);
// saved response usually: { "photos": [ { "id": ..., "owner_id": ... } ] } }
if (saved.TryGetProperty("photos", out var photos) && photos.GetArrayLength() > 0) catch (VkApiException ex)
{ {
var first = photos[0]; _logger.LogError(ex, "VkNet Photo.SaveWallPhotoAsync failed (VK code {ErrorCode}): {Message}", ex.ErrorCode, ex.Message);
var ownerId = first.GetProperty("owner_id").GetInt32(); throw;
var id = first.GetProperty("id").GetInt32();
return $"photo{ownerId}_{id}";
} }
_logger.LogWarning("VK photos.saveWallPhoto response has no photos array"); var photo = saved.FirstOrDefault();
if (photo == null)
{
_logger.LogWarning("VkNet SaveWallPhoto: пустой результат");
return null; return null;
} }
private async Task<JsonElement> CallMethodAsync( var attachment = $"photo{photo.OwnerId}_{photo.Id}";
string method, _logger.LogInformation("VkNet SaveWallPhoto response: {Attachment}", attachment);
IReadOnlyDictionary<string, string> parameters, return attachment;
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}");
} }
using var doc = JsonDocument.Parse(body); private static readonly Regex PhotoAttachmentRegex = new(@"^photo(-?\d+)_(\d+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
var root = doc.RootElement;
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; if (string.IsNullOrWhiteSpace(attachments))
var code = error.TryGetProperty("error_code", out var ec) ? ec.ToString() : null; return null;
_logger.LogError(
"VK API error body for {Method}: {Body}", var parts = attachments.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
method, var list = new List<MediaAttachment>(parts.Length);
bodyForLog); foreach (var part in parts)
throw new InvalidOperationException($"VK API error: code={code} msg={message}"); {
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)) return list;
{
throw new InvalidOperationException($"VK API unexpected response: {body}");
} }
var responsePayload = response.GetRawText(); private void ApplyApiVersion(string version)
_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 parts = version.Trim().Split('.', 2, StringSplitOptions.RemoveEmptyEntries);
var first = true; if (parts.Length == 2
foreach (var (key, value) in form.OrderBy(static kv => kv.Key, StringComparer.Ordinal)) && 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) _api.VkApiVersion.SetVersion(major, minor);
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) 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(), ")"); return string.Concat(text.AsSpan(0, maxLen), "…(truncated, totalLen=", text.Length.ToString(), ")");
} }
} }