мл
This commit is contained in:
@@ -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>();
|
||||||
|
|||||||
@@ -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
@@ -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(), ")");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user