first commit

This commit is contained in:
kislovdm
2026-04-02 20:22:29 +03:00
commit f0f41aebb8
11 changed files with 813 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
**/bin
**/obj
**/.git
**/.github
**/.vscode
**/.idea
**/*.user
**/*.suo
**/*.log
data/
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+16
View File
@@ -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
+20
View File
@@ -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; }
}
+199
View File
@@ -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;
}
}
+124
View File
@@ -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);
}
}
+34
View File
@@ -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();
}
}
+16
View File
@@ -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>
+173
View File
@@ -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();
}
}