diff --git a/BotPages.Core/Abstractions/IMessengerAdapter.cs b/BotPages.Core/Abstractions/IMessengerAdapter.cs index 284bd95..db37aad 100644 --- a/BotPages.Core/Abstractions/IMessengerAdapter.cs +++ b/BotPages.Core/Abstractions/IMessengerAdapter.cs @@ -59,5 +59,5 @@ public interface IMessangerAdapterSetup : IMessengerAdapter /// /// /// - Task StartAdapterAsync(Func onUpdate, CancellationToken ct); + Task StartAdapterAsync(Func onUpdate, List commands, CancellationToken ct); } \ No newline at end of file diff --git a/BotPages.Core/BotPagesApp.cs b/BotPages.Core/BotPagesApp.cs index 67be146..c060ae4 100644 --- a/BotPages.Core/BotPagesApp.cs +++ b/BotPages.Core/BotPagesApp.cs @@ -69,16 +69,34 @@ public sealed class BotPagesApp /// public BotPagesApp MapCommand(string commandTemplate) where TPage : Page { - _commands.Map(commandTemplate); + _commands.Map(commandTemplate, false, null); + return this; + } + + /// + /// Зарегистрировать команду, ведущую на страницу. + /// + public BotPagesApp MapCommand(string commandTemplate, bool publish, string description) where TPage : Page + { + _commands.Map(commandTemplate, publish, description); return this; } /// /// Зарегистрировать команду с кастомным обработчиком. /// - public BotPagesApp MapCommand(string template, Func handler) + public BotPagesApp MapCommand(string template, CommandHandler handler) { - _commands.Map(template, handler); + _commands.Map(template, handler, false, null); + return this; + } + + /// + /// Зарегистрировать команду с кастомным обработчиком. + /// + public BotPagesApp MapCommand(string template, CommandHandler handler, bool publish, string description) + { + _commands.Map(template, handler, publish, description); return this; } @@ -142,7 +160,6 @@ public sealed class BotPagesApp { if (_commands.TryDispatch(ctx, update.Text, ct, out var dispatched) && dispatched is not null) { - _logger.Log(LogLevel.Info, $"Command '{update.Text}' dispatched."); await dispatched; return; } @@ -237,7 +254,7 @@ public sealed class BotPagesApp { foreach (var adapter in _adapterFactory.Adapters) { - await adapter.Value.StartAdapterAsync(update => HandleUpdateAsync(update, cancellationToken), cancellationToken); + await adapter.Value.StartAdapterAsync(update => HandleUpdateAsync(update, cancellationToken), _commands.Commands, cancellationToken); } } } \ No newline at end of file diff --git a/BotPages.Core/Routing/Command.cs b/BotPages.Core/Routing/Command.cs new file mode 100644 index 0000000..04ec926 --- /dev/null +++ b/BotPages.Core/Routing/Command.cs @@ -0,0 +1,34 @@ +namespace BotPages.Core.Routing; + +using System.Text.RegularExpressions; + +/// +/// Команда действий. Например "/start" +/// +public class Command +{ + /// + /// Название команды. + /// + public required string Name { get; init; } + + /// + /// Шаблон команды. + /// + public required Regex Pattern { get; init; } + + /// + /// Обработчик команды. + /// + public required CommandHandler Handler { get; init; } + + /// + /// Описание команды. + /// + public string? Description { get; init; } + + /// + /// Публичный? нужно ли регистрировать в боте. + /// + public required bool Publish { get; init; } +} diff --git a/BotPages.Core/Routing/CommandHandler.cs b/BotPages.Core/Routing/CommandHandler.cs new file mode 100644 index 0000000..ea69ba7 --- /dev/null +++ b/BotPages.Core/Routing/CommandHandler.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BotPages.Core.Routing; + +/// +/// Обработчик команды: получает контекст страницы, аргументы команды и токен отмены. +/// +public delegate Task CommandHandler(PageContext context, IReadOnlyDictionary? args, CancellationToken cancellationToken); diff --git a/BotPages.Core/Routing/CommandsRegistry.cs b/BotPages.Core/Routing/CommandsRegistry.cs index 627f15f..a63d43e 100644 --- a/BotPages.Core/Routing/CommandsRegistry.cs +++ b/BotPages.Core/Routing/CommandsRegistry.cs @@ -2,30 +2,40 @@ using System.Text.RegularExpressions; + /// /// Реестр команд, доступных из любого места. /// internal sealed class CommandsRegistry { - private readonly List<(Regex pattern, Func handler)> _commands = new(); + private readonly List _commands = new(); + + public List Commands => _commands; /// /// Зарегистрировать команду, ведущую на страницу. /// - public CommandsRegistry Map(string commandTemplate) where TPage : Page + public CommandsRegistry Map(string commandTemplate, bool publish = false, string? description = null) where TPage : Page { var pattern = ToRegex(commandTemplate); - _commands.Add((pattern, (ctx, ct) => ctx.Navigation.GoToAsync(ctx, ct))); - return this; + + return Map(commandTemplate, (ctx, args, ct) => ctx.Navigation.GoToAsync(ctx, ct), publish, description); } /// /// Зарегистрировать команду с кастомным обработчиком. /// - public CommandsRegistry Map(string commandTemplate, Func handler) + public CommandsRegistry Map(string commandTemplate, CommandHandler handler, bool publish = false, string? description = null) { var pattern = ToRegex(commandTemplate); - _commands.Add((pattern, handler)); + _commands.Add(new Command() + { + Name = ToCommandName(commandTemplate), + Pattern = pattern, + Handler = handler, + Publish = publish, + Description = string.IsNullOrWhiteSpace(description) ? null : description + }); return this; } @@ -34,11 +44,19 @@ internal sealed class CommandsRegistry /// public bool TryDispatch(PageContext ctx, string command, CancellationToken ct, out Task? task) { - foreach (var (pattern, handler) in _commands) + foreach (var cmd in _commands) { - if (pattern.IsMatch(command)) + var match = cmd.Pattern.Match(command); + if (match.Success) { - task = handler(ctx, ct); + // Собираем аргументы + var args = cmd.Pattern.GetGroupNames() + .Where(n => n != "0") + .Select(n => new { n, v = match.Groups[n].Value }) + .Where(x => !string.IsNullOrEmpty(x.v)) + .ToDictionary(x => x.n, x => x.v); + + task = cmd.Handler(ctx, args, ct); return true; } } @@ -56,4 +74,10 @@ internal sealed class CommandsRegistry .Replace("{id?}", "(?\\S+)?") + "$"; return new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); } + + private static string ToCommandName(string template) + { + // Простейшее преобразование шаблона: "/open {page} {id?}" -> "/open" + return template.Split(" ", StringSplitOptions.RemoveEmptyEntries).First().ToLowerInvariant(); + } } diff --git a/BotPages.Telegram/TelegramAdapter.cs b/BotPages.Telegram/TelegramAdapter.cs index 5851db6..bff4419 100644 --- a/BotPages.Telegram/TelegramAdapter.cs +++ b/BotPages.Telegram/TelegramAdapter.cs @@ -56,7 +56,7 @@ public sealed class TelegramAdapter : IMessangerAdapterSetup /// /// Запустить polling для приема обновлений от Telegram. /// - public async Task StartAdapterAsync(Func onUpdate, CancellationToken ct) + public async Task StartAdapterAsync(Func onUpdate, List commands, CancellationToken ct) { _client = new TelegramBotClient(_token); @@ -77,6 +77,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(); _logger.Log(LogLevel.Info, $"{MessengerType} started: @{me.Username}"); diff --git a/Demo/Pages/FileSendPage.cs b/Demo/Pages/FileSendPage.cs index 06861f0..c4e23b1 100644 --- a/Demo/Pages/FileSendPage.cs +++ b/Demo/Pages/FileSendPage.cs @@ -1,9 +1,11 @@ using BotPages.Core; using BotPages.Core.Abstractions; using BotPages.Core.Messaging; +using BotPages.Core.Routing; namespace Demo.Pages; +[Route("FileSend")] public sealed class FileSendPage : SingletonPage { public override Task OnEnter(PageContext ctx, CancellationToken ct) diff --git a/Demo/Pages/WelcomePage.cs b/Demo/Pages/WelcomePage.cs index 72c86fd..f9501e3 100644 --- a/Demo/Pages/WelcomePage.cs +++ b/Demo/Pages/WelcomePage.cs @@ -1,6 +1,7 @@ using BotPages.Core; using BotPages.Core.Abstractions; using BotPages.Core.Messaging; +using BotPages.Core.Routing; namespace Demo.Pages; @@ -8,6 +9,7 @@ namespace Demo.Pages; /// Стартовая страница демо‑бота. /// Обычная страница с кнопками /// +[Route("Welcome")] public sealed class WelcomePage : SingletonPage { public override async Task OnEnter(PageContext ctx, CancellationToken ct) diff --git a/Demo/Program.cs b/Demo/Program.cs index f6b3e70..5cd2209 100644 --- a/Demo/Program.cs +++ b/Demo/Program.cs @@ -1,6 +1,7 @@ using BotPages.Core; using BotPages.Core.Logging; using BotPages.Core.Middleware; +using BotPages.Core.Routing; using BotPages.Core.Storage; using BotPages.Telegram; using Demo.Pages; @@ -18,9 +19,26 @@ namespace Demo var state = new InMemoryStateStorage(); using var cts = new CancellationTokenSource(); + // Можно использовать команды для открытия страниц с роутингом + // /open Welcome + // /open FileSend + CommandHandler openHandler = async (ctx, args, ct) => + { + if (args is null || !args.TryGetValue("page", out var pageName)) + { + await ctx.SendTextAsync("Не указана страница для открытия.", ct: ct); + return; + } + + // Навигация на страницу по имени + await ctx.Navigation.GoToAsync(pageName, ctx, ct); + }; + var app = new BotPagesApp(state, logger) .AddDefaultPage() - .MapCommand("/start") + .MapCommand("/start", true, "Главная") + .MapCommand("/open {page}", openHandler, true, "открыть станицу /open {page}") + .AutoMapRoute() .AddMiddleware(new ErrorHandlingMiddleware(logger)) .AddMiddleware(new LoggingMiddleware(logger)) .AddTelegramAdapter(token, "Telegram")