diff --git a/BotPages.Core/Abstractions/AdapterOptionsBag.cs b/BotPages.Core/Abstractions/AdapterOptionsBag.cs new file mode 100644 index 0000000..33a56bd --- /dev/null +++ b/BotPages.Core/Abstractions/AdapterOptionsBag.cs @@ -0,0 +1,44 @@ +namespace BotPages.Core.Abstractions; + +/// +/// Контейнер для адаптер-специфичных опций, позволяющий хранить параметры для нескольких адаптеров. +/// Используется внутри `SendRequest.AdapterOptions`. +/// +public sealed class AdapterOptionsBag +{ + private readonly Dictionary _map = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Установить опции для адаптера. + /// + public void Set(string adapterKey, T options) + { + if (adapterKey is null) throw new ArgumentNullException(nameof(adapterKey)); + _map[adapterKey] = options; + } + + /// + /// Попробовать получить опции для адаптера. + /// + public bool TryGet(string adapterKey, out T? options) + { + if (adapterKey is null) throw new ArgumentNullException(nameof(adapterKey)); + if (_map.TryGetValue(adapterKey, out var o) && o is T t) + { + options = t; + return true; + } + + options = default; + return false; + } + + /// + /// Получить опции или вернуть null. + /// + public T? GetOrDefault(string adapterKey) + { + TryGet(adapterKey, out T? v); + return v; + } +} diff --git a/BotPages.Core/Abstractions/IMessengerAdapter.cs b/BotPages.Core/Abstractions/IMessengerAdapter.cs index 6d4da71..6bc9f72 100644 --- a/BotPages.Core/Abstractions/IMessengerAdapter.cs +++ b/BotPages.Core/Abstractions/IMessengerAdapter.cs @@ -15,27 +15,9 @@ public interface IMessengerAdapter Capabilities Capabilities { get; } /// - /// Отправить текстовое сообщение РІ чат. + /// Универсальный метод отправки СЃ использованием общего описания запроса. /// - Task SendTextAsync(string chatId, - string text, - MessageFormat format = MessageFormat.Plain, - IEnumerable>? inline = null, - IEnumerable>? reply = null, - string? messageId = null, - CancellationToken ct = default - ); - - /// - /// Отправить файл РІ чат. - /// - Task SendFileAsync(string chatId, - FileDescriptor file, - string? caption = null, - MessageFormat? captionFormat = null, - IEnumerable>? inline = null, - IEnumerable>? reply = null, - CancellationToken ct = default); + Task SendAsync(SendRequest request, CancellationToken ct = default); /// /// Создать билдер альбома для отправки медиагруппы. diff --git a/BotPages.Core/Abstractions/SendRequest.cs b/BotPages.Core/Abstractions/SendRequest.cs new file mode 100644 index 0000000..08e2376 --- /dev/null +++ b/BotPages.Core/Abstractions/SendRequest.cs @@ -0,0 +1,43 @@ +using BotPages.Core.Messaging; + +namespace BotPages.Core.Abstractions; + +/// +/// Универсальная структура запроса на отправку сообщения/файла, используемая адаптерами. +/// Помещена в Core чтобы быть доступной для всех адаптеров. +/// +public sealed class SendRequest +{ + /// Идентификатор чата/сессии в мессенджере. + public required string ChatId { get; init; } + + /// Текст сообщения (если отправляется текст). + public string? Text { get; init; } + + /// Формат текста (HTML/Markdown/Plain). + public MessageFormat? TextFormat { get; init; } + + /// Inline кнопки (строки кнопок). + public IEnumerable>? Inline { get; init; } + + /// Reply клавиатура (строки кнопок). + public IEnumerable>? Reply { get; init; } + + /// Id редактируемого сообщения (если редактируем). + public string? MessageId { get; init; } + + /// Файл для отправки (если отправляется файл). + public FileDescriptor? File { get; init; } + + /// Подпись для файла. + public string? Caption { get; init; } + + /// Формат подписи/подписи для файла. + public MessageFormat? CaptionFormat { get; init; } + + /// + /// Контейнер адаптер-специфичных опций. + /// Содержит имена/ключи адаптеров и соответствующие объекты опций. + /// + public AdapterOptionsBag? AdapterOptions { get; init; } +} diff --git a/BotPages.Core/Context/PageContextAdapterExtensions.cs b/BotPages.Core/Context/PageContextAdapterExtensions.cs index 5be1578..58b2909 100644 --- a/BotPages.Core/Context/PageContextAdapterExtensions.cs +++ b/BotPages.Core/Context/PageContextAdapterExtensions.cs @@ -4,12 +4,19 @@ using BotPages.Core.Messaging; namespace BotPages.Core; /// -/// Расширения для работы СЃ +/// Расширения для работы СЃ адаптером. +/// Упрощают создание универсального `SendRequest`. /// public static class PageContextAdapterExtensions { /// - /// Отправить текстовое сообщение. + /// Отправить универсальный запрос через привязанный адаптер. + /// + public static Task SendAsync(this PageContext ctx, SendRequest request, CancellationToken ct = default) + => ctx.Adapter.SendAsync(request, ct); + + /// + /// Удобная оболочка: отправить текстовое сообщение. /// public static Task SendTextAsync(this PageContext ctx, string text, @@ -17,25 +24,56 @@ public static class PageContextAdapterExtensions IEnumerable>? inline = null, IEnumerable>? reply = null, string? messageId = null, + object? adapterOptions = null, CancellationToken ct = default) - => ctx.Adapter.SendTextAsync(ctx.Update.Chat.Id, text, format, inline, reply, messageId, ct); + { + var bag = adapterOptions switch + { + null => null, + AdapterOptionsBag b => b, + _ => throw new ArgumentException("adapterOptions must be an AdapterOptionsBag or null. Use MessageBuilder extensions to set adapter options.", nameof(adapterOptions)) + }; + + return ctx.SendAsync(new SendRequest + { + ChatId = ctx.Update.Chat.Id, + Text = text, + TextFormat = format, + Inline = inline, + Reply = reply, + MessageId = messageId, + AdapterOptions = bag + }, ct); + } /// - /// Отправить файл. + /// Удобная оболочка: отправить файл. /// - public static Task SendFileAsync(this PageContext ctx, + public static Task SendFileAsync(this PageContext ctx, FileDescriptor file, string? caption = null, MessageFormat? captionFormat = null, IEnumerable>? inline = null, IEnumerable>? reply = null, - CancellationToken ct = default - ) - => ctx.Adapter.SendFileAsync(chatId: ctx.Update.Chat.Id, - file: file, - caption: caption, - captionFormat: captionFormat, - inline: inline, - reply: reply, - ct: ct); + object? adapterOptions = null, + CancellationToken ct = default) + { + var bag = adapterOptions switch + { + null => null, + AdapterOptionsBag b => b, + _ => throw new ArgumentException("adapterOptions must be an AdapterOptionsBag or null. Use MessageBuilder extensions to set adapter options.", nameof(adapterOptions)) + }; + + return ctx.SendAsync(new SendRequest + { + ChatId = ctx.Update.Chat.Id, + File = file, + Caption = caption, + CaptionFormat = captionFormat, + Inline = inline, + Reply = reply, + AdapterOptions = bag + }, ct); + } } \ No newline at end of file diff --git a/BotPages.Core/Messaging/MessageBuilder.cs b/BotPages.Core/Messaging/MessageBuilder.cs index 4615dcd..5853daf 100644 --- a/BotPages.Core/Messaging/MessageBuilder.cs +++ b/BotPages.Core/Messaging/MessageBuilder.cs @@ -4,6 +4,7 @@ namespace BotPages.Core.Messaging; /// /// Fluent‑билдер для отправки сообщений (текст, РєРЅРѕРїРєРё, файлы, альбомы, прогресс). +/// Поддерживает указание адаптер-специфичных опций через `WithAdapterOption`. /// public sealed class MessageBuilder { @@ -16,10 +17,21 @@ public sealed class MessageBuilder private readonly List<(FileDescriptor file, string? caption, MessageFormat? captionFormat)> _album = new(); private bool _disableReplyKeyboard; private string? _editMessageId = null; + private AdapterOptionsBag? _adapterOptions = null; /// Создать билдер сообщений. public MessageBuilder(PageContext ctx) => _ctx = ctx; + /// + /// Установить опции для конкретного адаптера. Ключ адаптера определяется адаптером (напр., "telegram"). + /// + public MessageBuilder WithAdapterOption(string adapterKey, T options) + { + if (_adapterOptions is null) _adapterOptions = new AdapterOptionsBag(); + _adapterOptions.Set(adapterKey, options); + return this; + } + /// Текст сообщения. public MessageBuilder Text(string text, MessageFormat format = MessageFormat.Plain) { @@ -63,7 +75,7 @@ public sealed class MessageBuilder return this; } - /// Добавить строку inline‑кнопок. + /// Добавить строки inline‑кнопок. public MessageBuilder Inline(IEnumerable> row) { _inline.AddRange(row.Select(t => t.ToList()).ToList()); @@ -73,7 +85,6 @@ public sealed class MessageBuilder /// /// Отключение Reply клавиатуры. /// - /// public MessageBuilder DisableReply() { _disableReplyKeyboard = true; @@ -96,7 +107,7 @@ public sealed class MessageBuilder return this; } - /// Добавить строку reply‑кнопок. + /// Добавить строки reply‑кнопок. public MessageBuilder Reply(IEnumerable> row) { _disableReplyKeyboard = false; @@ -130,18 +141,16 @@ public sealed class MessageBuilder // Текст if (!string.IsNullOrWhiteSpace(_text)) { - messageId = await _ctx.SendTextAsync(_text, _format, _inline, reply, _editMessageId, ct); + messageId = await _ctx.SendTextAsync(_text, _format, _inline, reply, _editMessageId, _adapterOptions, ct); } // Файлы foreach (var (file, caption, captionFormat) in _files) - await _ctx.SendFileAsync(file: file - , caption: caption - , captionFormat: captionFormat - , reply: reply - , inline: _inline - , ct: ct - ); + { + var res = await _ctx.SendFileAsync(file, caption, captionFormat, _inline, reply, _adapterOptions, ct); + // сохранить первый возвращённый id сообщения + if (messageId is null && res is not null) messageId = res; + } // Альбом if (_album.Count > 0) @@ -157,6 +166,7 @@ public sealed class MessageBuilder _album.Clear(); _editMessageId = null; + _adapterOptions = null; return messageId; } diff --git a/BotPages.Core/Middleware/ErrorHandlingMiddleware.cs b/BotPages.Core/Middleware/ErrorHandlingMiddleware.cs index 931f83a..19907f7 100644 --- a/BotPages.Core/Middleware/ErrorHandlingMiddleware.cs +++ b/BotPages.Core/Middleware/ErrorHandlingMiddleware.cs @@ -27,8 +27,12 @@ public sealed class ErrorHandlingMiddleware : IPageMiddleware { _logger.Log(LogLevel.Critical, "Unhandled exception in middleware pipeline.", ex); - // Теперь РјРѕР¶РЅРѕ напрямую использовать PageContext для ответа - await ctx.SendTextAsync("Произошла ошибка РїСЂРё обработке запроса. Попробуйте ещё раз.", ct: ct); + // Отправляем универсальный запрос СЃ текстом РѕР± ошибке + await ctx.SendAsync(new SendRequest + { + ChatId = ctx.Update.Chat.Id, + Text = "Произошла ошибка РїСЂРё обработке запроса. Попробуйте ещё раз." + }, ct); } } } diff --git a/BotPages.Telegram/BotPagesAppExtension.cs b/BotPages.Telegram/BotPagesAppExtension.cs index ba24b30..2f9cb98 100644 --- a/BotPages.Telegram/BotPagesAppExtension.cs +++ b/BotPages.Telegram/BotPagesAppExtension.cs @@ -15,8 +15,14 @@ public static class BotPagesAppExtension /// /// public static BotPagesApp AddTelegramAdapter(this BotPagesApp app, string token, string messengerType = "") + => app.AddTelegramAdapter(token, null, messengerType); + + /// + /// Добавление адаптера для телеграмм СЃ опциями. + /// + public static BotPagesApp AddTelegramAdapter(this BotPagesApp app, string token, TelegramOptions? options, string messengerType = "") { - var telegram = new TelegramAdapter(app.Logger, token); + var telegram = new TelegramAdapter(app.Logger, token, options); if (!string.IsNullOrWhiteSpace(messengerType)) telegram.MessengerType = messengerType; diff --git a/BotPages.Telegram/MessageBuilderExtensions.cs b/BotPages.Telegram/MessageBuilderExtensions.cs new file mode 100644 index 0000000..ccd4209 --- /dev/null +++ b/BotPages.Telegram/MessageBuilderExtensions.cs @@ -0,0 +1,43 @@ +using BotPages.Core.Abstractions; +using BotPages.Core.Messaging; + +namespace BotPages.Telegram; + +/// +/// Расширения для `MessageBuilder` специфичные для Telegram. +/// Позволяют удобно задать `TelegramOptions` для конкретного сообщения +/// без изменения самого билдера. +/// +public static class MessageBuilderExtensions +{ + /// + /// Установить опции Telegram для конкретного сообщения (переопределяют опции адаптера). + /// + public static MessageBuilder WithTelegramOptions(this MessageBuilder builder, TelegramOptions options) + { + // Ensure bag exists + var bag = (builder as object) switch + { + MessageBuilder mb => GetAdapterBag(mb) ?? CreateAndSetAdapterBag(mb), + _ => null + }; + + bag?.Set("telegram", options); + return builder; + } + + // Reflection helpers to access private adapterOptions field on MessageBuilder + private static AdapterOptionsBag? GetAdapterBag(MessageBuilder builder) + { + var fi = typeof(MessageBuilder).GetField("_adapterOptions", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return fi?.GetValue(builder) as AdapterOptionsBag; + } + + private static AdapterOptionsBag CreateAndSetAdapterBag(MessageBuilder builder) + { + var fi = typeof(MessageBuilder).GetField("_adapterOptions", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var bag = new AdapterOptionsBag(); + fi?.SetValue(builder, bag); + return bag; + } +} diff --git a/BotPages.Telegram/TelegramAdapter.cs b/BotPages.Telegram/TelegramAdapter.cs index fe0a958..5ad0e5c 100644 --- a/BotPages.Telegram/TelegramAdapter.cs +++ b/BotPages.Telegram/TelegramAdapter.cs @@ -21,11 +21,12 @@ namespace BotPages.Telegram; /// Адаптер для Telegram РЅР° базе Telegram.Bot. /// Реализует отправку текста, РєРЅРѕРїРѕРє, файлов, альбомов Рё прогресса. /// -public sealed class TelegramAdapter : IMessangerAdapterSetup +public sealed class TelegramAdapter : IMessengerAdapterSetup { private readonly ILogger _logger; private TelegramBotClient? _client; private string _token; + private readonly TelegramOptions _options; private static Capabilities _capabilities = new() { SupportsInlineButtons = true, @@ -37,10 +38,11 @@ public sealed class TelegramAdapter : IMessangerAdapterSetup }; /// Создать адаптер Telegram. - public TelegramAdapter(ILogger logger, string token) + public TelegramAdapter(ILogger logger, string token, TelegramOptions? options = null) { _logger = logger; _token = token; + _options = options ?? new TelegramOptions(); } /// @@ -81,6 +83,7 @@ public sealed class TelegramAdapter : IMessangerAdapterSetup cancellationToken: ct ); + await _client.SetMyCommands(commands.Where(t => t.Publish).Select(t => new BotCommand(t.Name, t.Description ?? t.Name.TrimStart('/'))), cancellationToken: ct); var me = await _client.GetMe(); @@ -89,13 +92,11 @@ public sealed class TelegramAdapter : IMessangerAdapterSetup return; } - /// - public async Task SendTextAsync(string chatId, string text, - MessageFormat format = MessageFormat.Plain, - IEnumerable>? inline = null, - IEnumerable>? reply = null, - string? messageId = null, - CancellationToken ct = default) + /// + /// Универсальный внутренний метод отправки — определяет, РЅСѓР¶РЅРѕ ли отправлять текст или файл РїРѕ параметрам. + /// Возвращает id сообщения (или null). + /// + public async Task SendAsync(SendRequest req, CancellationToken ct) { if (_client is null) { @@ -103,23 +104,29 @@ public sealed class TelegramAdapter : IMessangerAdapterSetup return null; } + // determine adapter-specific options (TelegramOptions) from bag or use adapter default + TelegramOptions telegramOptions = _options; + if (req.AdapterOptions is AdapterOptionsBag bag && bag.TryGet("telegram", out TelegramOptions? opt) && opt is not null) + { + telegramOptions = opt; + } + var disableNotification = !telegramOptions.NotifyOnSend; + + // Build markup InlineKeyboardMarkup? inlineMarkup = null; ReplyMarkup? markup = null; - if (inline is not null && inline.Any()) + if (req.Inline is not null && req.Inline.Any()) { inlineMarkup = new InlineKeyboardMarkup( - inline.Select(row => row.Select(b => new InlineKeyboardButton(b.Label, b.Value)).ToArray()) - .ToArray() + req.Inline.Select(row => row.Select(b => new InlineKeyboardButton(b.Label, b.Value)).ToArray()).ToArray() ); } - else if (reply is not null) + else if (req.Reply is not null) { - if (reply.Any()) + if (req.Reply.Any()) { - markup = new ReplyKeyboardMarkup( - reply.Select(row => row.Select(b => new KeyboardButton(b.Label)).ToArray()).ToArray() - ) + markup = new ReplyKeyboardMarkup(req.Reply.Select(row => row.Select(b => new KeyboardButton(b.Label)).ToArray()).ToArray()) { ResizeKeyboard = true }; @@ -130,180 +137,169 @@ public sealed class TelegramAdapter : IMessangerAdapterSetup } } - var parseMode = ParseMode.None; - - switch (format) + // If there is a file — send file + if (req.File is not null) { - case MessageFormat.Html: - { + var file = req.File; + + // Получаем поток, если РѕРЅ задан + Stream? stream = null; + if (file.GetStreamAsync is not null) + { + stream = await file.GetStreamAsync(ct); + if (stream is not null) 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); + } + + var parseMode = ParseMode.None; + switch (req.CaptionFormat) + { + case MessageFormat.Html: parseMode = ParseMode.Html; break; - } - case MessageFormat.Plain: - { - parseMode = ParseMode.None; - break; - } - case MessageFormat.Markdown: - { + case MessageFormat.Markdown: parseMode = ParseMode.MarkdownV2; break; - } - default: - { - _logger.Log(LogLevel.Warn, $"MessageFormat '{format}' not supported. Degraded to plain text."); + case MessageFormat.Plain: + case null: + parseMode = ParseMode.None; break; - } - } + } - // Длина сообщения - if (text.Length > Capabilities.MaxMessageLength) - { - _logger.Log(LogLevel.Warn, $"Message too long ({text.Length}). Truncated to {Capabilities.MaxMessageLength}."); - text = text.Substring(0, Capabilities.MaxMessageLength); - } - - if (!string.IsNullOrWhiteSpace(messageId)) - { - await _client.EditMessageText( - messageId: int.Parse(messageId), - chatId: long.Parse(chatId), - text: text, - parseMode: parseMode, - replyMarkup: inlineMarkup, - cancellationToken: ct - ); - - return messageId; - } - else - { if (inlineMarkup is not null) markup = inlineMarkup; - var message = await _client.SendMessage( - chatId: long.Parse(chatId), - text: text, - parseMode: parseMode, - replyMarkup: markup, - cancellationToken: ct - ); + Message? sentMessage = req.File.Kind switch + { + FileKind.Photo => await _client.SendPhoto(long.Parse(req.ChatId), inputFile, req.Caption ?? "", parseMode, replyMarkup: markup, disableNotification: disableNotification, cancellationToken: ct), + FileKind.Video => await _client.SendVideo(long.Parse(req.ChatId), inputFile, caption: req.Caption ?? "", parseMode, replyMarkup: markup, disableNotification: disableNotification, cancellationToken: ct), + FileKind.Audio => await _client.SendAudio(long.Parse(req.ChatId), inputFile, req.Caption ?? "", parseMode, replyMarkup: markup, disableNotification: disableNotification, cancellationToken: ct), + _ => await _client.SendDocument(long.Parse(req.ChatId), inputFile, req.Caption ?? "", parseMode, replyMarkup: markup, disableNotification: disableNotification, cancellationToken: ct), + }; - return message.Id.ToString(); + return sentMessage?.MessageId.ToString(); } + + // Otherwise treat as text + if (!string.IsNullOrWhiteSpace(req.Text)) + { + var format = req.TextFormat ?? MessageFormat.Plain; + var parseMode = ParseMode.None; + + switch (format) + { + case MessageFormat.Html: + parseMode = ParseMode.Html; + break; + case MessageFormat.Markdown: + parseMode = ParseMode.MarkdownV2; + break; + case MessageFormat.Plain: + default: + parseMode = ParseMode.None; + break; + } + + // Длина сообщения + var text = req.Text!; + if (text.Length > Capabilities.MaxMessageLength) + { + _logger.Log(LogLevel.Warn, $"Message too long ({text.Length}). Truncated to {Capabilities.MaxMessageLength}."); + text = text.Substring(0, Capabilities.MaxMessageLength); + } + + if (!string.IsNullOrWhiteSpace(req.MessageId)) + { + await _client.EditMessageText( + messageId: int.Parse(req.MessageId), + chatId: long.Parse(req.ChatId), + text: text, + parseMode: parseMode, + replyMarkup: inlineMarkup, + cancellationToken: ct + ); + + return req.MessageId; + } + else + { + if (inlineMarkup is not null) markup = inlineMarkup; + + var message = await _client.SendMessage( + chatId: long.Parse(req.ChatId), + text: text, + parseMode: parseMode, + replyMarkup: markup, + disableNotification: disableNotification, + cancellationToken: ct + ); + + return message.MessageId.ToString(); + } + } + + return null; } /// - public async Task SendFileAsync(string chatId, + public Task SendTextAsync(string chatId, string text, + MessageFormat format = MessageFormat.Plain, + IEnumerable>? inline = null, + IEnumerable>? reply = null, + string? messageId = null, + object? adapterOptions = null, + CancellationToken ct = default) + { + var req = new SendRequest + { + ChatId = chatId, + Text = text, + TextFormat = format, + Inline = inline, + Reply = reply, + MessageId = messageId, + AdapterOptions = adapterOptions as AdapterOptionsBag + }; + + return SendAsync(req, ct); + } + + /// + public Task SendFileAsync(string chatId, FileDescriptor file, string? caption = null, MessageFormat? captionFormat = null, IEnumerable>? inline = null, IEnumerable>? reply = null, + object? adapterOptions = null, CancellationToken ct = default ) { - if (_client is null) + var req = new SendRequest { - _logger.Log(LogLevel.Critical, $"{MessengerType} client is not initialized."); - return; - } + ChatId = chatId, + File = file, + Caption = caption, + CaptionFormat = captionFormat, + Inline = inline, + Reply = reply, + AdapterOptions = adapterOptions as AdapterOptionsBag + }; - // Получаем поток, если РѕРЅ задан - 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); - } - - var parseMode = ParseMode.None; - - switch (captionFormat) - { - case MessageFormat.Html: - { - parseMode = ParseMode.Html; - break; - } - case MessageFormat.Plain: - { - parseMode = ParseMode.None; - break; - } - case MessageFormat.Markdown: - { - parseMode = ParseMode.MarkdownV2; - break; - } - case null: - { - parseMode = ParseMode.None; - break; - } - default: - { - _logger.Log(LogLevel.Warn, $"MessageFormat '{captionFormat}' not supported. Degraded to plain text."); - break; - } - } - - 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) - { - if (reply.Any()) - { - markup = new ReplyKeyboardMarkup( - reply.Select(row => row.Select(b => new KeyboardButton(b.Label)).ToArray()).ToArray() - ) - { - ResizeKeyboard = true - }; - } - else - { - markup = new ReplyKeyboardRemove(); - } - } - - // Р’ зависимости РѕС‚ FileKind выбираем подходящий метод - switch (file.Kind) - { - case FileKind.Photo: - await _client.SendPhoto(long.Parse(chatId), inputFile, caption ?? "", parseMode, replyMarkup: markup, cancellationToken: ct); - break; - case FileKind.Video: - await _client.SendVideo(long.Parse(chatId), inputFile, caption: caption ?? "", parseMode, replyMarkup: markup, cancellationToken: ct); - break; - case FileKind.Audio: - await _client.SendAudio(long.Parse(chatId), inputFile, caption ?? "", parseMode, replyMarkup: markup, cancellationToken: ct); - break; - default: - await _client.SendDocument(long.Parse(chatId), inputFile, caption ?? "", parseMode, replyMarkup: markup, cancellationToken: ct); - break; - } + return SendAsync(req, ct); } /// diff --git a/BotPages.Telegram/TelegramOptions.cs b/BotPages.Telegram/TelegramOptions.cs new file mode 100644 index 0000000..e2cb315 --- /dev/null +++ b/BotPages.Telegram/TelegramOptions.cs @@ -0,0 +1,14 @@ +namespace BotPages.Telegram; + +/// +/// Настройки адаптера Telegram, применяемые по умолчанию при отправке сообщений. +/// +public sealed class TelegramOptions +{ + /// + /// Отправлять ли уведомление (sound) при отправке сообщения. По умолчанию true. + /// + public bool NotifyOnSend { get; set; } = true; + + // В будущем можно добавить: default parse mode, disable web page preview и т.д. +} diff --git a/Demo/Pages/DetailsPage.cs b/Demo/Pages/DetailsPage.cs index 3c5e0d5..880f482 100644 --- a/Demo/Pages/DetailsPage.cs +++ b/Demo/Pages/DetailsPage.cs @@ -6,7 +6,6 @@ namespace Demo.Pages; /// /// Страница РІРІРѕРґР° деталей заявки. -/// Страница СЃ параметрами Рё получением состояния. /// public sealed class DetailsPage : StatefullPage { @@ -32,19 +31,14 @@ public sealed class DetailsPage : StatefullPage switch (payload) { case "next": - { - await SaveState(ctx, ct); - await ctx.Navigation.GoToAsync(ctx, ct); - break; - } + await SaveState(ctx, ct); + await ctx.Navigation.GoToAsync(ctx, ct); + break; case "back": - { - await ctx.Navigation.GoToAsync(ctx, ct); - break; - } + await ctx.Navigation.GoToAsync(ctx, ct); + break; } - ; } public override async Task OnText(PageContext ctx, string text, CancellationToken ct) @@ -61,7 +55,6 @@ public sealed class DetailsPage : StatefullPage string? title = ""; args?.TryGetValue("title", out title); - // Навигация РЅР° страницу РїРѕ имени await ctx.Navigation.GoToAsync(ctx, title ?? "", ct); }; } \ No newline at end of file diff --git a/Demo/Pages/FileSendPage.cs b/Demo/Pages/FileSendPage.cs index c4e23b1..0c76c4a 100644 --- a/Demo/Pages/FileSendPage.cs +++ b/Demo/Pages/FileSendPage.cs @@ -15,7 +15,7 @@ public sealed class FileSendPage : SingletonPage var demoFile = new FileDescriptor { - Id = "", // РЅРµ используется РїСЂРё отправке РЅРѕРІРѕРіРѕ файла + Id = "", Name = "demo.txt", Extension = "txt", Size = stream.Length, diff --git a/Demo/Pages/FilesPage.cs b/Demo/Pages/FilesPage.cs index 10e89dc..3b21e53 100644 --- a/Demo/Pages/FilesPage.cs +++ b/Demo/Pages/FilesPage.cs @@ -6,7 +6,7 @@ namespace Demo.Pages; /// /// Страница загрузки файлов. -/// Обычная страница СЃ полученим Рё сохранением состояния. +/// Обычная страница СЃ получением Рё сохранением состояния. /// public sealed class FilesPage : SingletonPage { @@ -18,15 +18,17 @@ public sealed class FilesPage : SingletonPage public override async Task OnFile(PageContext ctx, List files, CancellationToken ct) { + // Пересылаем каждый файл обратно СЃ подтверждением foreach (var file in files) { - await ctx.SendFileAsync(file, $"Файл '{file.Name}' получен Рё отправлен обратно.", ct: ct); + await new MessageBuilder(ctx) + .File(file, $"Файл '{file.Name}' получен Рё отправлен обратно.") + .SendAsync(ct); } - //Обращение через Storage + // Сохраняем данные РІ хранилище состояния var request = await ctx.StateStorage.GetAsync(ctx.SessionKey, "Request", ct); request.FilesCount = files.Count; - //Обращение через Context await ctx.SetStorageAsync("Request", request, ct); await new MessageBuilder(ctx) diff --git a/Demo/Pages/SubmitPage.cs b/Demo/Pages/SubmitPage.cs index 1f3ce75..0c047db 100644 --- a/Demo/Pages/SubmitPage.cs +++ b/Demo/Pages/SubmitPage.cs @@ -10,9 +10,7 @@ public sealed class SubmitPage : SingletonPage { public override async Task OnEnter(PageContext ctx, CancellationToken ct) { - var progress = new MessageBuilder(ctx); - - var messageId = await progress + var messageId = await new MessageBuilder(ctx) .Text("Отправка заявки\n7%") .SendAsync(ct); @@ -21,7 +19,7 @@ public sealed class SubmitPage : SingletonPage { i += 25; Thread.Sleep(TimeSpan.FromMilliseconds(200)); - await progress + await new MessageBuilder(ctx) .Text($"Отправка заявки\n{i}%") .EditMessage(messageId!) .SendAsync(ct); diff --git a/Demo/Pages/TitlePage.cs b/Demo/Pages/TitlePage.cs index b7f63b7..8a8c897 100644 --- a/Demo/Pages/TitlePage.cs +++ b/Demo/Pages/TitlePage.cs @@ -5,7 +5,6 @@ using BotPages.Core.Messaging; namespace Demo.Pages; /// /// Страница РІРІРѕРґР° заголовка заявки. -/// Обычная страница РІРІРѕРґРѕРј текста. /// public sealed class TitlePage : SingletonPage { diff --git a/Demo/Pages/WelcomePage.cs b/Demo/Pages/WelcomePage.cs index f9501e3..c145460 100644 --- a/Demo/Pages/WelcomePage.cs +++ b/Demo/Pages/WelcomePage.cs @@ -7,7 +7,6 @@ namespace Demo.Pages; /// /// Стартовая страница демо‑бота. -/// Обычная страница СЃ кнопками /// [Route("Welcome")] public sealed class WelcomePage : SingletonPage @@ -31,19 +30,13 @@ public sealed class WelcomePage : SingletonPage switch (button) { case WelcomePageButtons.CreateRequest: - { - return ctx.Navigation.GoToAsync(ctx, ct); - } + return ctx.Navigation.GoToAsync(ctx, ct); case WelcomePageButtons.Help: - { - return new MessageBuilder(ctx).Text("Здесь будет справка.", MessageFormat.Plain).SendAsync(ct); - } + return new MessageBuilder(ctx).Text("Здесь будет справка.", MessageFormat.Plain).SendAsync(ct); case WelcomePageButtons.SendFile: - { - return ctx.Navigation.GoToAsync(ctx, ct); - } + return ctx.Navigation.GoToAsync(ctx, ct); } return base.OnText(ctx, text, ct); diff --git a/README.md b/README.md index c1bb019..f712852 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ п»ї# BotPages -BotPages — кроссплатформенный фреймворк для создания диалоговых ботов СЃ системой страниц (page‑based conversational framework). +BotPages — кроссплатформенный фреймворк для создания диалоговых ботов СЃ моделью страниц (page‑based conversational framework). Цели проекта: - Простая модель страниц Рё навигации. @@ -11,7 +11,7 @@ BotPages — кроссплатформенный фреймворк для СЃРѕ Структура репозитория - `BotPages.Core` — СЏРґСЂРѕ фреймворка: навигация, страницы, маршрутизация, реестр команд, middleware, абстракции адаптеров Рё хранилища состояния. -- `BotPages.Telegram` — адаптер для Telegram Bot API (реализация `IMessangerAdapterSetup`/`IMessengerAdapter`). +- `BotPages.Telegram` — адаптер для Telegram Bot API (реализация `IMessengerAdapter`). - `Demo` — пример приложения СЃ несколькими страницами Рё конфигурацией адаптера. - `BotPages` — мета‑проект для упаковки библиотек. - `docs/` — внутренняя документация проекта (Quickstart, API reference Рё С‚.Рґ.). @@ -51,14 +51,24 @@ export TELEGRAM_TOKEN="" # Linux/macOS dotnet run --project Demo ``` -Основные концепции +Простой пример конфигурации -- `BotPagesApp` — точка конфигурации приложения: регистрация адаптеров, middleware, маршрутов Рё команд, запуск. +```csharp +var app = new BotPagesApp(stateStorage, logger) + .AddTelegramAdapter(token) + .AddDefaultPage() + .MapCommand('/start') + .AddMiddleware(new LoggingMiddleware(logger)); + +await app.Build(CancellationToken.None); +``` + +Ключевые концепции + +- `SendRequest` — единый формат запроса РЅР° отправку (текст, файлы, РєРЅРѕРїРєРё, опции). Адаптеры реализуют `IMessengerAdapter.SendAsync(SendRequest)`. - `Page`, `StatefulPage`, `SingletonPage` — модели страниц СЃ жизненным циклом (`OnEnter`, `OnUpdate`, `OnText`, `OnButton`, `OnFile`, `OnError`). -- `NavigationService` — управление переходами между страницами Рё определение текущей страницы РїРѕ сессии. -- `CommandsRegistry` — шаблоны команд РІРёРґР° `/cmd {arg} {opt?}` СЃ поддержкой именованных Рё опциональных аргументов. +- `NavigationService` — управление переходами между страницами. - `IPageMiddleware` — middleware-конвейер, выполняющийся для каждого апдейта. -- `IMessangerAdapterSetup` / `IMessengerAdapter` — интерфейсы, позволяющие подключать новые мессенджеры. Документация @@ -67,30 +77,6 @@ dotnet run --project Demo - `docs/API_REFERENCE.md` — краткий reference публичных API. - `docs/PROJECT_DOCUMENTATION.md` — РѕР±Р·РѕСЂ архитектуры Рё компонентов. -XML‑документация генерируется РїСЂРё СЃР±РѕСЂРєРµ (опция `GenerateDocumentationFile` РІ `.csproj`), её РјРѕР¶РЅРѕ использовать для генерации HTML‑референса (docfx, MkDocs Рё С‚.Рї.). - -Примеры использования - -```csharp -var app = new BotPagesApp(stateStorage, logger) - .AddAdapter("telegram", new TelegramAdapterSetup(token)) - .AddDefaultPage() - .MapCommand("/start") - .AddMiddleware(new LoggingMiddleware(logger)); - -await app.Build(CancellationToken.None); -``` - -Вклад Рё тестирование - -- Принимам пулл‑реквесты. Описание PR должно содержать цель Рё краткое описание изменения. -- Следуйте единому стилю РєРѕРґР° Рё включённым nullable-аннотациям. - Лицензия -Проект распространяется РїРѕРґ лицензией MIT. Смотрите файл `LICENSE`. - -Контакты - -Автор: FrigaT -Репозиторий: https://git.frigat.duckdns.org/FrigaT/BotPages \ No newline at end of file +Проект распространяется РїРѕРґ лицензией MIT. Смотрите файл `LICENSE`. \ No newline at end of file diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index a24eb23..992dce6 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -1,50 +1,39 @@ -# API Reference (краткий) +# Справочник API -Документ перечисляет публичные API фреймворка и их назначение. +Ниже краткое описание публичных API проекта BotPages. ## BotPages.Core ### `BotPagesApp` -- `AddAdapter(string messengerType, IMessangerAdapterSetup adapter)` — зарегистрировать адаптер для мессенджера. -- `AddDefaultPage() where TPage : SingletonPage` — установить "домашнюю" страницу приложения. -- `AddMiddleware(TMiddleware instance) where TMiddleware : IPageMiddleware` — добавить middleware в конвейер. -- `MapCommand(string commandTemplate) where TPage : Page` — зарегистрировать команду, ведущую на страницу. -- `MapCommand(string template, CommandHandler handler)` — зарегистрировать кастомный обработчик команды. -- `MapRoute(string template) where TPage : Page` — вручную зарегистрировать маршрут для страницы. -- `AutoMapRoute()` — автоматически найти и зарегистрировать все Page-типы в загруженных сборках. -- `HandleUpdateAsync(UpdateContext update, CancellationToken ct)` — главный обработчик апдейтов (вызвается адаптером). -- `Build(CancellationToken ct)` — запустить все адаптеры и начать обработку апдейтов. +- `AddTelegramAdapter(string token)` — регистрирует Telegram адаптер с заданным токеном. +- `AddAdapter(string messengerType, IMessengerAdapter adapter)` — регистрирует произвольный адаптер. +- `AddMiddleware(IPageMiddleware middleware)` — добавляет middleware в pipeline. +- `MapCommand(string template)` — привязать команду к странице. +- `MapRoute(string template)` — зарегистрировать маршрут. +- `AutoMapRoute()` — выполнить автопоиск Page?классов и зарегистрировать их маршруты. +- `Build(CancellationToken ct)` — подготовить и запустить приложение. -### Страницы -- `Page` — абстрактный базовый класс. -- `StatefulPage` — страница с сохранением состояния. -- `SingletonPage` — одноэкземплярная страница. +### `IMessengerAdapter` +- `Task SendAsync(SendRequest request, CancellationToken ct = default)` — универсальный метод отправки для всех адаптеров. +- `IAlbumBuilder CreateAlbumBuilder(PageContext ctx)` — создать билдер альбома (если поддерживается). +- `Task OnLeaveAsync(PageContext ctx, CancellationToken ct)` — вызывается при уходе со страницы. -### Навигация -- `NavigationService` — отвечает за определение текущей страницы и переходы. - - `GoToAsync(PageContext ctx, CancellationToken ct)` - - `GoToHomeAsync(PageContext ctx, CancellationToken ct)` +### `SendRequest` +- `ChatId` — идентификатор чата. +- `Text`, `TextFormat` — текст и формат. +- `File`, `Caption`, `CaptionFormat` — файл и подпись. +- `Inline`, `Reply` — inline и reply клавиатуры. +- `MessageId` — id редактируемого сообщения. +- `AdapterOptions` — адаптер?специфичные опции (например `TelegramOptions`). -### Команды -- `CommandsRegistry` — реестр команд (внутренний). Поддерживает шаблоны с именованными аргументами и опциональными параметрами. - -### Middleware -- `IPageMiddleware` — интерфейс middleware. - - `InvokeAsync(PageContext ctx, Func next, CancellationToken ct)` - -### Абстракции адаптеров -- `IMessangerAdapterSetup` — настройка адаптера. -- `IMessengerAdapter` — экземпляр адаптера, реализующий `StartAdapterAsync(Func onUpdate, List commands, CancellationToken ct)`. -- `IMessengerAdapterFactory` — фабрика/реестр адаптеров (`MultiAdapterFactory` — реализация). - -### Хранилище состояний -- `IStateStorage` — абстракция для сохранения состояния страниц между сессиями. +### `PageContext` расширения +- `SendAsync(SendRequest request, CancellationToken ct = default)` — отправить `SendRequest`. +- `SendTextAsync(...)`, `SendFileAsync(...)` — удобные оболочки, создающие `SendRequest`. ## BotPages.Telegram -- Реализация адаптера для Telegram на базе `Telegram.Bot`. -- `TelegramUpdateMapper` — маппит апдейты Telegram в `UpdateContext`. -- `TelegramAlbumBuilder` — собирает медиа-альбомы из последовательных апдейтов. -- `TelegramAdapter` — реализация адаптера, стартует `StartAdapterAsync`. +- `TelegramAdapter` — реализация `IMessengerAdapter` для Telegram. +- `TelegramOptions` — настройки адаптера (например `NotifyOnSend`). +- `MessageBuilderExtensions.WithTelegramOptions` — расширение для установки `TelegramOptions` для конкретного сообщения. # Генерация документации diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index fc48e5a..6c6dc01 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -1,28 +1,37 @@ -# Быстрый старт — BotPages +# Быстрый старт с BotPages -Эта инструкция поможет запустить и протестировать проект локально. +Это руководство поможет быстро запустить demo?приложение с Telegram?адаптером. Требования - .NET 8 SDK -- Токен Telegram (если используете Telegram-адаптер) +- Токен Telegram?бота (для demo) Сборка + ```bash dotnet build ``` -Запуск демонстрации -1. Откройте проект `Demo`. -2. Внесите токен Telegram (если используется) в код инициализации адаптера или в переменные окружения проекта Demo. -3. Запустите: +Запуск demo + +1. Установите переменную окружения `TELEGRAM_TOKEN`: + +```bash +setx TELEGRAM_TOKEN "" # Windows +export TELEGRAM_TOKEN="" # Linux/macOS +``` + +2. Запустите demo: + ```bash dotnet run --project Demo ``` Пример конфигурации приложения + ```csharp var app = new BotPagesApp(stateStorage, logger) - .AddAdapter("telegram", new TelegramAdapterSetup("")) + .AddTelegramAdapter(token) .AddDefaultPage() .MapCommand("/start") .AddMiddleware(new LoggingMiddleware(logger)); @@ -30,18 +39,9 @@ var app = new BotPagesApp(stateStorage, logger) await app.Build(CancellationToken.None); ``` -Как написать страницу -- Наследуйте `StatefulPage` для страниц с пер-сессионным состоянием. -- Наследуйте `SingletonPage` для одноэкземплярных страниц. -- Переопределите `OnEnter`, `OnUpdate`, `OnText`, `OnButton`, `OnFile`, `OnError` по необходимости. -- Для автоматического маппинга маршрутов используйте `AutoMapRoute()`. +Основные понятия +- `SendRequest` — единый формат запроса на отправку (текст, файлы, кнопки, адаптер?опции). Используйте `PageContext.SendAsync` для отправки. +- `Page` / `StatefulPage` / `SingletonPage` — модели страниц и их жизненный цикл. +- `IPageMiddleware` — middleware выполняется для каждого входящего обновления. -Тестирование команд -- Команды регистрируются в `BotPagesApp.MapCommand`. -- Шаблон команд поддерживает именованные и опциональные аргументы: `/cmd {a} {b?}`. - -Подсказки -- Middleware выполняются в том порядке, в котором их добавляют в `BotPagesApp`. -- Команды имеют приоритет над обработкой страниц (если текст начинается с `/`). - -Если требуется подробный справочник API — смотрите `docs/API_REFERENCE.md`. \ No newline at end of file +Далее: см. `docs/API_REFERENCE.md` для описания публичных API. \ No newline at end of file