Доработан обработчик команд. Добавлена публикация команд
All checks were successful
CI / build-test (push) Successful in 31s
Release / pack-and-publish (release) Successful in 35s

This commit is contained in:
2025-12-07 10:09:59 +03:00
parent 8af03fa52b
commit edc718b1f9
9 changed files with 127 additions and 17 deletions

View File

@@ -59,5 +59,5 @@ public interface IMessangerAdapterSetup : IMessengerAdapter
/// <param name="onUpdate"></param> /// <param name="onUpdate"></param>
/// <param name="ct"></param> /// <param name="ct"></param>
/// <returns></returns> /// <returns></returns>
Task StartAdapterAsync(Func<UpdateContext, Task> onUpdate, CancellationToken ct); Task StartAdapterAsync(Func<UpdateContext, Task> onUpdate, List<Routing.Command> commands, CancellationToken ct);
} }

View File

@@ -69,16 +69,34 @@ public sealed class BotPagesApp
/// </summary> /// </summary>
public BotPagesApp MapCommand<TPage>(string commandTemplate) where TPage : Page public BotPagesApp MapCommand<TPage>(string commandTemplate) where TPage : Page
{ {
_commands.Map<TPage>(commandTemplate); _commands.Map<TPage>(commandTemplate, false, null);
return this;
}
/// <summary>
/// Зарегистрировать команду, ведущую на страницу.
/// </summary>
public BotPagesApp MapCommand<TPage>(string commandTemplate, bool publish, string description) where TPage : Page
{
_commands.Map<TPage>(commandTemplate, publish, description);
return this; return this;
} }
/// <summary> /// <summary>
/// Зарегистрировать команду с кастомным обработчиком. /// Зарегистрировать команду с кастомным обработчиком.
/// </summary> /// </summary>
public BotPagesApp MapCommand(string template, Func<PageContext, CancellationToken, Task> handler) public BotPagesApp MapCommand(string template, CommandHandler handler)
{ {
_commands.Map(template, handler); _commands.Map(template, handler, false, null);
return this;
}
/// <summary>
/// Зарегистрировать команду с кастомным обработчиком.
/// </summary>
public BotPagesApp MapCommand(string template, CommandHandler handler, bool publish, string description)
{
_commands.Map(template, handler, publish, description);
return this; return this;
} }
@@ -142,7 +160,6 @@ public sealed class BotPagesApp
{ {
if (_commands.TryDispatch(ctx, update.Text, ct, out var dispatched) && dispatched is not null) if (_commands.TryDispatch(ctx, update.Text, ct, out var dispatched) && dispatched is not null)
{ {
_logger.Log(LogLevel.Info, $"Command '{update.Text}' dispatched.");
await dispatched; await dispatched;
return; return;
} }
@@ -237,7 +254,7 @@ public sealed class BotPagesApp
{ {
foreach (var adapter in _adapterFactory.Adapters) 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);
} }
} }
} }

View File

@@ -0,0 +1,34 @@
namespace BotPages.Core.Routing;
using System.Text.RegularExpressions;
/// <summary>
/// Команда действий. Например "/start"
/// </summary>
public class Command
{
/// <summary>
/// Название команды.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Шаблон команды.
/// </summary>
public required Regex Pattern { get; init; }
/// <summary>
/// Обработчик команды.
/// </summary>
public required CommandHandler Handler { get; init; }
/// <summary>
/// Описание команды.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Публичный? нужно ли регистрировать в боте.
/// </summary>
public required bool Publish { get; init; }
}

View File

@@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace BotPages.Core.Routing;
/// <summary>
/// Обработчик команды: получает контекст страницы, аргументы команды и токен отмены.
/// </summary>
public delegate Task CommandHandler(PageContext context, IReadOnlyDictionary<string, string>? args, CancellationToken cancellationToken);

View File

@@ -2,30 +2,40 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
/// <summary> /// <summary>
/// Реестр команд, доступных из любого места. /// Реестр команд, доступных из любого места.
/// </summary> /// </summary>
internal sealed class CommandsRegistry internal sealed class CommandsRegistry
{ {
private readonly List<(Regex pattern, Func<PageContext, CancellationToken, Task> handler)> _commands = new(); private readonly List<Command> _commands = new();
public List<Command> Commands => _commands;
/// <summary> /// <summary>
/// Зарегистрировать команду, ведущую на страницу. /// Зарегистрировать команду, ведущую на страницу.
/// </summary> /// </summary>
public CommandsRegistry Map<TPage>(string commandTemplate) where TPage : Page public CommandsRegistry Map<TPage>(string commandTemplate, bool publish = false, string? description = null) where TPage : Page
{ {
var pattern = ToRegex(commandTemplate); var pattern = ToRegex(commandTemplate);
_commands.Add((pattern, (ctx, ct) => ctx.Navigation.GoToAsync<TPage>(ctx, ct)));
return this; return Map(commandTemplate, (ctx, args, ct) => ctx.Navigation.GoToAsync<TPage>(ctx, ct), publish, description);
} }
/// <summary> /// <summary>
/// Зарегистрировать команду с кастомным обработчиком. /// Зарегистрировать команду с кастомным обработчиком.
/// </summary> /// </summary>
public CommandsRegistry Map(string commandTemplate, Func<PageContext, CancellationToken, Task> handler) public CommandsRegistry Map(string commandTemplate, CommandHandler handler, bool publish = false, string? description = null)
{ {
var pattern = ToRegex(commandTemplate); 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; return this;
} }
@@ -34,11 +44,19 @@ internal sealed class CommandsRegistry
/// </summary> /// </summary>
public bool TryDispatch(PageContext ctx, string command, CancellationToken ct, out Task? task) 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; return true;
} }
} }
@@ -56,4 +74,10 @@ internal sealed class CommandsRegistry
.Replace("{id?}", "(?<id>\\S+)?") + "$"; .Replace("{id?}", "(?<id>\\S+)?") + "$";
return new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); 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();
}
} }

View File

@@ -56,7 +56,7 @@ public sealed class TelegramAdapter : IMessangerAdapterSetup
/// <summary> /// <summary>
/// Запустить polling для приема обновлений от Telegram. /// Запустить polling для приема обновлений от Telegram.
/// </summary> /// </summary>
public async Task StartAdapterAsync(Func<UpdateContext, Task> onUpdate, CancellationToken ct) public async Task StartAdapterAsync(Func<UpdateContext, Task> onUpdate, List<BotPages.Core.Routing.Command> commands, CancellationToken ct)
{ {
_client = new TelegramBotClient(_token); _client = new TelegramBotClient(_token);
@@ -77,6 +77,7 @@ public sealed class TelegramAdapter : IMessangerAdapterSetup
cancellationToken: ct 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(); var me = await _client.GetMe();
_logger.Log(LogLevel.Info, $"{MessengerType} started: @{me.Username}"); _logger.Log(LogLevel.Info, $"{MessengerType} started: @{me.Username}");

View File

@@ -1,9 +1,11 @@
using BotPages.Core; using BotPages.Core;
using BotPages.Core.Abstractions; using BotPages.Core.Abstractions;
using BotPages.Core.Messaging; using BotPages.Core.Messaging;
using BotPages.Core.Routing;
namespace Demo.Pages; namespace Demo.Pages;
[Route("FileSend")]
public sealed class FileSendPage : SingletonPage public sealed class FileSendPage : SingletonPage
{ {
public override Task OnEnter(PageContext ctx, CancellationToken ct) public override Task OnEnter(PageContext ctx, CancellationToken ct)

View File

@@ -1,6 +1,7 @@
using BotPages.Core; using BotPages.Core;
using BotPages.Core.Abstractions; using BotPages.Core.Abstractions;
using BotPages.Core.Messaging; using BotPages.Core.Messaging;
using BotPages.Core.Routing;
namespace Demo.Pages; namespace Demo.Pages;
@@ -8,6 +9,7 @@ namespace Demo.Pages;
/// Стартовая страница демо‑бота. /// Стартовая страница демо‑бота.
/// Обычная страница с кнопками /// Обычная страница с кнопками
/// </summary> /// </summary>
[Route("Welcome")]
public sealed class WelcomePage : SingletonPage public sealed class WelcomePage : SingletonPage
{ {
public override async Task OnEnter(PageContext ctx, CancellationToken ct) public override async Task OnEnter(PageContext ctx, CancellationToken ct)

View File

@@ -1,6 +1,7 @@
using BotPages.Core; using BotPages.Core;
using BotPages.Core.Logging; using BotPages.Core.Logging;
using BotPages.Core.Middleware; using BotPages.Core.Middleware;
using BotPages.Core.Routing;
using BotPages.Core.Storage; using BotPages.Core.Storage;
using BotPages.Telegram; using BotPages.Telegram;
using Demo.Pages; using Demo.Pages;
@@ -18,9 +19,26 @@ namespace Demo
var state = new InMemoryStateStorage(); var state = new InMemoryStateStorage();
using var cts = new CancellationTokenSource(); 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) var app = new BotPagesApp(state, logger)
.AddDefaultPage<WelcomePage>() .AddDefaultPage<WelcomePage>()
.MapCommand<WelcomePage>("/start") .MapCommand<WelcomePage>("/start", true, "Главная")
.MapCommand("/open {page}", openHandler, true, "открыть станицу /open {page}")
.AutoMapRoute()
.AddMiddleware(new ErrorHandlingMiddleware(logger)) .AddMiddleware(new ErrorHandlingMiddleware(logger))
.AddMiddleware(new LoggingMiddleware(logger)) .AddMiddleware(new LoggingMiddleware(logger))
.AddTelegramAdapter(token, "Telegram") .AddTelegramAdapter(token, "Telegram")