Переработанная версия ядра
All checks were successful
CI / build-test (push) Successful in 42s

This commit is contained in:
2025-12-05 12:57:05 +03:00
parent ee175a35a0
commit d817417a69
81 changed files with 2335 additions and 1453 deletions

View File

@@ -1,10 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageId>BotPages.Telegram</PackageId>
<Product>BotPages</Product>
<Version>0.1.0</Version>
<Authors>FrigaT</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<Description>Адаптер для Telegram Bot API. Работает поверх BotPages.Core.</Description>
<Copyright>Copyright © 2025 FrigaT</Copyright>
<RepositoryUrl>https://git.frigat.duckdns.org/FrigaT/BotPages</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Telegram.Bot" Version="22.7.5" />

View File

@@ -0,0 +1,248 @@
using BotPages.Core;
using BotPages.Core.Abstractions;
using BotPages.Core.Context;
using BotPages.Core.Logging;
using BotPages.Core.Messaging;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace BotPages.Telegram;
/// <summary>
/// Адаптер для Telegram на базе Telegram.Bot.
/// Реализует отправку текста, кнопок, файлов, альбомов и прогресса.
/// </summary>
public sealed class TelegramAdapter : IMessengerAdapter
{
private readonly ILogger _logger;
private TelegramBotClient? _client;
/// <summary>Создать адаптер Telegram.</summary>
public TelegramAdapter(ILogger logger) => _logger = logger;
/// <summary>
/// Запустить polling для приема обновлений от Telegram.
/// </summary>
public async Task StartPollingAsync(string token, Func<UpdateContext, Task> onUpdate, CancellationToken ct)
{
_client = new TelegramBotClient(token);
_client.StartReceiving(
updateHandler: async (_, update, ct2) =>
{
var mapped = TelegramUpdateMapper.Map(update, _client);
if (mapped is not null)
await onUpdate(mapped);
},
errorHandler: async (_, ex, ct2) =>
{
_logger.Log(LogLevel.Warn, "Telegram error.", ex);
await Task.CompletedTask;
},
cancellationToken: ct
);
var me = await _client.GetMe();
_logger.Log(LogLevel.Info, $"Telegram started: @{me.Username}");
return;
}
/// <inheritdoc />
public async Task SendTextAsync(PageContext ctx, string text, MessageFormat format,
IEnumerable<IEnumerable<InlineButton>>? inline,
IEnumerable<IEnumerable<ReplyButton>>? reply, CancellationToken ct)
{
if (_client is null)
{
_logger.Log(LogLevel.Critical, "Telegram client is not initialized.");
return;
}
ReplyMarkup? markup = null;
if (inline is not null && inline.Any())
{
markup = new InlineKeyboardMarkup(
inline.Select(row => row.Select(b => new InlineKeyboardButton(b.Label, b.Value)).ToArray())
.ToArray()
);
}
else if (reply is not null && reply.Any())
{
markup = new ReplyKeyboardMarkup(
reply.Select(row => row.Select(b => new KeyboardButton(b.Label)).ToArray()).ToArray()
)
{
ResizeKeyboard = true
};
}
var parseMode = ParseMode.None;
switch (format)
{
case MessageFormat.Html:
{
parseMode = ParseMode.Html;
break;
}
case MessageFormat.Plain:
{
parseMode = ParseMode.None;
break;
}
case MessageFormat.Markdown:
{
parseMode = ParseMode.MarkdownV2;
break;
}
default:
{
_logger.Log(LogLevel.Warn, $"MessageFormat '{format}' not supported. Degraded to plain text.");
break;
}
}
// Длина сообщения
if (text.Length > ctx.Update.Chat.Capabilities.MaxMessageLength)
{
_logger.Log(LogLevel.Warn, $"Message too long ({text.Length}). Truncated to {ctx.Update.Chat.Capabilities.MaxMessageLength}.");
text = text.Substring(0, ctx.Update.Chat.Capabilities.MaxMessageLength);
}
await _client.SendMessage(
chatId: long.Parse(ctx.Update.Chat.Id),
text: text,
parseMode: parseMode,
replyMarkup: markup,
cancellationToken: ct
);
}
/// <inheritdoc />
public async Task SendFileAsync(PageContext ctx, FileDescriptor file, string? caption, CancellationToken ct)
{
if (_client is null)
{
_logger.Log(LogLevel.Critical, "Telegram client is not initialized.");
return;
}
var chatId = long.Parse(ctx.Update.Chat.Id);
// Получаем поток, если он задан
Stream? stream = null;
if (file.GetStreamAsync is not null)
{
stream = await file.GetStreamAsync(ct);
stream.Position = 0;
}
InputFile inputFile;
if (stream is not null && stream != Stream.Null)
{
inputFile = new InputFileStream(stream, file.Name);
}
else if (file.Id.StartsWith("http://") || file.Id.StartsWith("https://"))
{
inputFile = new InputFileUrl(file.Id);
}
else
{
inputFile = new InputFileId(file.Id);
}
// В зависимости от FileKind выбираем подходящий метод
switch (file.Kind)
{
case FileKind.Photo:
await _client.SendPhoto(chatId, inputFile, caption ?? "", cancellationToken: ct);
break;
case FileKind.Video:
await _client.SendVideo(chatId, inputFile, caption: caption ?? "", cancellationToken: ct);
break;
case FileKind.Audio:
await _client.SendAudio(chatId, inputFile, caption ?? "", cancellationToken: ct);
break;
default:
await _client.SendDocument(chatId, inputFile, caption ?? "", cancellationToken: ct);
break;
}
}
/// <inheritdoc />
public IAlbumBuilder CreateAlbumBuilder(PageContext ctx) => new TelegramAlbumBuilder(this, ctx, _logger, _client);
/// <inheritdoc />
public async Task<string?> StartProgressAsync(PageContext ctx, string title, CancellationToken ct)
{
if (_client is null)
{
_logger.Log(LogLevel.Critical, "Telegram client is not initialized.");
return null;
}
string text = "0%";
if (!string.IsNullOrEmpty(title))
{
text = title + Environment.NewLine + text;
}
var message = await _client.SendMessage(
chatId: long.Parse(ctx.Update.Chat.Id),
text: text,
cancellationToken: ct
);
return message.Id.ToString();
}
/// <inheritdoc />
public async Task UpdateProgressAsync(PageContext ctx, string messageId, string title, int percent, CancellationToken ct)
{
if (_client is null)
{
_logger.Log(LogLevel.Critical, "Telegram client is not initialized.");
return;
}
percent = Math.Clamp(percent, 0, 100);
string text = $"{percent}%";
if (!string.IsNullOrEmpty(title))
{
text = title + Environment.NewLine + text;
}
try
{
await _client.EditMessageText(
messageId: int.Parse(messageId),
chatId: long.Parse(ctx.Update.Chat.Id),
text: text,
cancellationToken: ct
);
}
catch (Exception ex)
{
_logger.Log(LogLevel.Critical, ex.Message, ex);
}
}
/// <inheritdoc />
public Task OnLeaveAsync(PageContext ctx, CancellationToken ct) => Task.CompletedTask;
}

View File

@@ -0,0 +1,105 @@
using BotPages.Core;
using BotPages.Core.Abstractions;
using BotPages.Core.Logging;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Telegram.Bot;
using Telegram.Bot.Types;
namespace BotPages.Telegram;
/// <summary>
/// Внутренний билдер альбомов (медиагрупп) для Telegram.
/// </summary>
public sealed class TelegramAlbumBuilder : IAlbumBuilder
{
private readonly TelegramAdapter _adapter;
private readonly PageContext _ctx;
private readonly ILogger _logger;
private readonly TelegramBotClient? _client;
private readonly List<(FileDescriptor file, string? caption)> _items = new();
/// <summary>Создать билдер альбома.</summary>
public TelegramAlbumBuilder(TelegramAdapter adapter, PageContext ctx, ILogger logger, TelegramBotClient? client)
{
_adapter = adapter;
_ctx = ctx;
_logger = logger;
_client = client;
}
/// <inheritdoc />
public IAlbumBuilder Add(FileDescriptor file, string? caption = null)
{
_items.Add((file, caption));
return this;
}
/// <inheritdoc />
public async Task SendAsync(CancellationToken ct = default)
{
if (_client is null)
{
_logger.Log(LogLevel.Critical, "Telegram client is not initialized.");
return;
}
var chatId = long.Parse(_ctx.Update.Chat.Id);
if (!_ctx.Update.Chat.Capabilities.SupportsAlbums)
{
_logger.Log(LogLevel.Warn, "Albums not supported. Degraded to sequential sends.");
foreach (var (file, caption) in _items)
await _adapter.SendFileAsync(_ctx, file, caption, ct);
return;
}
var media = new List<IAlbumInputMedia>();
foreach (var (file, caption) in _items)
{
Stream? stream = null;
if (file.GetStreamAsync is not null)
{
stream = await file.GetStreamAsync(ct);
}
InputFile inputFile;
if (stream is not null && stream != Stream.Null)
{
inputFile = new InputFileStream(stream, file.Name);
}
else if (file.Id.StartsWith("http://") || file.Id.StartsWith("https://"))
{
inputFile = new InputFileUrl(file.Id);
}
else
{
inputFile = new InputFileId(file.Id);
}
IAlbumInputMedia? m = file.Kind switch
{
FileKind.Audio => new InputMediaAudio(inputFile) { Caption = caption ?? "" },
FileKind.Document => new InputMediaDocument(inputFile) { Caption = caption ?? "" },
FileKind.Photo => new InputMediaPhoto(inputFile) { Caption = caption ?? "" },
FileKind.Video => new InputMediaVideo(inputFile) { Caption = caption ?? "" },
_ => null
};
if (m is not null) media.Add(m);
else
{
// Telegram не поддерживает document в альбомах — деградация
_logger.Log(LogLevel.Warn, $"Document '{file.Kind}' in album not supported. Sending document separately.");
await _adapter.SendFileAsync(_ctx, file, caption, ct);
}
}
if (media.Count > 0)
await _client.SendMediaGroup(chatId, media, cancellationToken: ct);
}
}

View File

@@ -1,91 +0,0 @@
using BotPages.Core;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
using static System.Net.Mime.MediaTypeNames;
namespace BotPages.Telegram
{
/// <summary>
/// Адаптер клиента для Telegram Bot API.
/// </summary>
public sealed class TelegramClientAdapter : IChatClient
{
private readonly ITelegramBotClient _bot;
public string Id { get; init; } = nameof(TelegramClientAdapter);
/// <summary>
/// Создаёт адаптер на основе ITelegramBotClient.
/// </summary>
public TelegramClientAdapter(ITelegramBotClient bot) => _bot = bot;
/// <summary>
/// Отправляет текстовое сообщение с опциональной клавиатурой.
/// </summary>
public Task SendTextAsync(long chatId, PageMessage message, IEnumerable<PageAction>? actions, CancellationToken ct)
{
ReplyMarkup? replyMarkup = null;
if (actions is { })
{
var inlineGroups = actions
.Where(a => a.Placement == ActionPlacement.Inline)
.GroupBy(a => a.Row)
.OrderBy(g => g.Key)
.Select(g => g.Select(a => InlineKeyboardButton.WithCallbackData(a.Label, a.Value)).ToArray())
.ToArray();
var replyGroups = actions
.Where(a => a.Placement == ActionPlacement.Reply)
.GroupBy(a => a.Row)
.OrderBy(g => g.Key)
.Select(g => g.Select(a => new KeyboardButton(a.Label)).ToArray())
.ToArray();
if (inlineGroups.Any())
replyMarkup = new InlineKeyboardMarkup(inlineGroups);
else if (replyGroups.Any())
replyMarkup = new ReplyKeyboardMarkup(replyGroups) { ResizeKeyboard = true };
}
var parseMode = message.Format switch
{
MessageFormat.Markdown => ParseMode.MarkdownV2,
MessageFormat.Html => ParseMode.Html,
_ => ParseMode.None,
};
return _bot.SendMessage(new ChatId(chatId),
message.Text,
parseMode: parseMode,
replyMarkup: replyMarkup,
disableNotification: message.IsSilent,
cancellationToken: ct
);
}
/// <summary>
/// Отправляет файлы как документы (по одному или пачкой).
/// </summary>
public async Task SendFilesAsync(long chatId, IEnumerable<FileDescriptor> files, CancellationToken ct)
{
foreach (var f in files)
{
if (f.Content is not null)
{
var input = new InputFileStream(f.Content, f.Name);
await _bot.SendDocument(chatId, input, cancellationToken: ct);
}
else
{
// Если контент не загружен, и есть FileId — отправляем по Id
await _bot.SendDocument(chatId, new InputFileId(f.Id), cancellationToken: ct);
}
}
}
}
}

View File

@@ -1,59 +0,0 @@
using BotPages.Core;
using Telegram.Bot;
namespace BotPages.Telegram
{
/// <summary>
/// FileService для Telegram: загрузка по FileId.
/// </summary>
public sealed class TelegramFileService : IFileService
{
private readonly ITelegramBotClient _bot;
/// <summary>
/// Создаёт файловый сервис для Telegram.
/// </summary>
public TelegramFileService(ITelegramBotClient bot) => _bot = bot;
/// <summary>
/// Загружает файл по идентификатору Telegram FileId.
/// </summary>
public async Task<FileDescriptor> DownloadAsync(string fileId, CancellationToken ct)
{
var file = await _bot.GetFile(fileId, ct);
var ms = new MemoryStream();
await _bot.DownloadFile(file.FilePath!, ms, ct);
ms.Position = 0;
var name = System.IO.Path.GetFileName(file.FilePath!);
return new FileDescriptor(file.FileId, name, "application/octet-stream", ms);
}
/// <summary>
/// Загружает несколько файлов по их идентификаторам.
/// </summary>
public async Task<IReadOnlyList<FileDescriptor>> DownloadManyAsync(IEnumerable<string> fileIds, CancellationToken ct)
{
var res = new List<FileDescriptor>();
foreach (var id in fileIds)
res.Add(await DownloadAsync(id, ct));
return res;
}
/// <summary>
/// Отправляет один файл в чат.
/// </summary>
public async Task SendAsync(IChatClient client, long chatId, FileDescriptor file, CancellationToken ct)
{
await client.SendFilesAsync(chatId, new[] { file }, ct);
}
/// <summary>
/// Отправляет несколько файлов в чат.
/// </summary>
public Task SendManyAsync(IChatClient client, long chatId, IEnumerable<FileDescriptor> files, CancellationToken ct)
{
return client.SendFilesAsync(chatId, files, ct);
}
}
}

View File

@@ -1,46 +1,157 @@
using BotPages.Core;
using BotPages.Core.Abstractions;
using BotPages.Core.Context;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Telegram.Bot;
using Telegram.Bot.Types;
namespace BotPages.Telegram
namespace BotPages.Telegram;
/// <summary>
/// Утилита для маппинга Telegram Update → UpdateContext.
/// </summary>
public static class TelegramUpdateMapper
{
/// <summary>
/// Утилиты для извлечения контекста из Telegram Update.
/// Маппинг Telegram Update в UpdateContext BotPages.
/// </summary>
public static class TelegramUpdateMapper
public static UpdateContext Map(Update update, TelegramBotClient client)
{
/// <summary>
/// Преобразует Telegram Update в универсальный UpdateContext.
/// </summary>
public static UpdateContext Map(ITelegramBotClient bot, INavigationService nav, IStateStore store, Update update)
var chat = update.Message?.Chat ?? update.CallbackQuery?.Message?.Chat;
var user = update.Message?.From ?? update.CallbackQuery?.From;
var userContext = new UserContext
{
var chat = update.Message?.Chat ?? update.CallbackQuery?.Message?.Chat;
var user = update.Message?.From ?? update.CallbackQuery?.From;
Id = user?.Id.ToString() ?? "unknown",
DisplayName = user?.Username,
};
var text = update.Message?.Text ?? update.CallbackQuery?.Data;
var files = new List<FileDescriptor>();
if (update.Message?.Document is { } doc)
var chatContext = new ChatContext
{
Id = chat?.Id.ToString() ?? "unknown",
Title = chat?.Title,
Capabilities = new Capabilities
{
files.Add(new FileDescriptor(doc.FileId, doc.FileName ?? "file", doc.MimeType ?? "application/octet-stream"));
SupportsInlineButtons = true,
SupportsReplyButtons = true,
SupportsAlbums = true,
SupportsFormattingMarkdown = true,
SupportsFormattingHtml = true,
MaxMessageLength = 4096,
}
if (update.Message?.Photo is { } photos && photos.Count() > 0)
};
string? text = null;
UpdateKind kind = UpdateKind.None;
var files = new List<FileDescriptor>();
if (update.Message is { } msg)
{
if (msg.Text is not null)
{
var largest = photos.OrderBy(p => p.FileSize).Last();
files.Add(new FileDescriptor(largest.FileId, "photo.jpg", "image/jpeg"));
text = msg.Text;
kind |= UpdateKind.Text;
}
return new UpdateContext
if (msg.Photo is { Length: > 0 })
{
Client = new TelegramClientAdapter(bot),
Chat = new ChatContext { Id = chat!.Id, Title = chat.Title },
User = new UserContext { Id = user!.Id, DisplayName = $"{user.FirstName} {user.LastName}" },
Text = text,
IncomingFiles = files,
RawUpdate = update,
Nav = nav,
State = store
};
foreach (var p in msg.Photo)
{
files.Add(new FileDescriptor
{
Id = p.FileId,
Name = "photo",
Extension = "jpg",
Size = p.FileSize ?? 0,
Kind = FileKind.Photo,
GetStreamAsync = GetStreamAsync(client, p.FileId),
});
}
}
if (msg.Document is not null)
{
files.Add(new FileDescriptor
{
Id = msg.Document.FileId,
Name = msg.Document.FileName ?? "document",
Extension = System.IO.Path.GetExtension(msg.Document.FileName) ?? "bin",
Size = msg.Document.FileSize ?? 0,
Kind = FileKind.Document,
Mime = msg.Document.MimeType,
GetStreamAsync = GetStreamAsync(client, msg.Document.FileId),
});
}
if (msg.Audio is not null)
{
files.Add(new FileDescriptor
{
Id = msg.Audio.FileId,
Name = msg.Audio.FileName ?? "audio",
Extension = System.IO.Path.GetExtension(msg.Audio.FileName) ?? "mp3",
Size = msg.Audio.FileSize ?? 0,
Kind = FileKind.Audio,
Mime = msg.Audio.MimeType,
GetStreamAsync = GetStreamAsync(client, msg.Audio.FileId),
});
}
if (msg.Video is not null)
{
files.Add(new FileDescriptor
{
Id = msg.Video.FileId,
Name = "video",
Extension = "mp4",
Size = msg.Video.FileSize ?? 0,
Kind = FileKind.Video,
Mime = msg.Video.MimeType,
GetStreamAsync = GetStreamAsync(client, msg.Video.FileId),
});
}
if (files.Count > 0)
{
kind |= UpdateKind.File;
}
}
if (update.CallbackQuery is { } cb)
{
kind |= UpdateKind.Button;
text = cb.Data;
}
return new UpdateContext
{
MessengerType = "Telegram",
User = userContext,
Chat = chatContext,
Text = text,
Kind = kind,
Files = files
};
}
private static Func<CancellationToken, Task<Stream>> GetStreamAsync(TelegramBotClient client, string fileId)
{
Func<CancellationToken, Task<Stream>> getStreamAsync = async _ =>
{
var file = await client.GetFile(fileId);
var stream = new MemoryStream();
await client.DownloadFile(file, stream);
stream.Position = 0;
return stream;
};
return getStreamAsync;
}
}