first commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
**/bin
|
||||
**/obj
|
||||
**/.git
|
||||
**/.github
|
||||
**/.vscode
|
||||
**/.idea
|
||||
**/*.user
|
||||
**/*.suo
|
||||
**/*.log
|
||||
data/
|
||||
|
||||
@@ -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
|
||||
|
||||
+194
@@ -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
|
||||
+15
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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<ForwardWorker> _logger;
|
||||
|
||||
public ForwardWorker(
|
||||
IOptions<TelegramOptions> telegram,
|
||||
IOptions<VkOptions> vk,
|
||||
IOptions<DbOptions> db,
|
||||
VkApiClient vkApi,
|
||||
MappingRepository repo,
|
||||
ILogger<ForwardWorker> 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<string?> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Telegram2VkBot;
|
||||
|
||||
public sealed class MappingRepository
|
||||
{
|
||||
private readonly DbOptions _dbOptions;
|
||||
private readonly ILogger<MappingRepository> _logger;
|
||||
|
||||
public MappingRepository(DbOptions dbOptions, ILogger<MappingRepository> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TelegramOptions>(builder.Configuration.GetSection("TELEGRAM"));
|
||||
builder.Services.Configure<VkOptions>(builder.Configuration.GetSection("VK"));
|
||||
builder.Services.Configure<DbOptions>(builder.Configuration.GetSection("DB"));
|
||||
|
||||
builder.Services.AddHttpClient<VkApiClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
});
|
||||
builder.Services.AddSingleton<MappingRepository>();
|
||||
|
||||
builder.Services.AddHostedService<ForwardWorker>();
|
||||
|
||||
builder.Logging.AddSimpleConsole(options =>
|
||||
{
|
||||
options.SingleLine = true;
|
||||
options.TimestampFormat = "yyyy-MM-dd HH:mm:ss ";
|
||||
});
|
||||
|
||||
var host = builder.Build();
|
||||
await host.RunAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" 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="Telegram.Bot" Version="22.9.5.3" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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<VkApiClient> _logger;
|
||||
|
||||
public VkApiClient(IOptions<VkOptions> options, HttpClient http, ILogger<VkApiClient> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
_json = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
}
|
||||
|
||||
public async Task<int> WallPostAsync(
|
||||
long ownerId,
|
||||
string message,
|
||||
string? attachments,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["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<string, string>
|
||||
{
|
||||
["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<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 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<string, string>
|
||||
{
|
||||
["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<JsonElement> CallMethodAsync(
|
||||
string method,
|
||||
IReadOnlyDictionary<string, string> parameters,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var query = new Dictionary<string, string>(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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user