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