Переработанная версия ядра
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

25
Demo/Pages/ConfirmPage.cs Normal file
View File

@@ -0,0 +1,25 @@
using BotPages.Core;
using BotPages.Core.Messaging;
namespace Demo.Pages;
/// <summary>
/// Страница подтверждения заявки.
/// </summary>
public sealed class ConfirmPage : SingletonPage
{
public override Task OnEnter(PageContext ctx, CancellationToken ct)
=> new MessageBuilder(ctx)
.Text("Подтвердите заявку ✅")
.Inline("Отправить", "submit")
.Inline("Отмена", "cancel")
.SendAsync(ct);
public override Task OnButton(PageContext ctx, string payload, CancellationToken ct)
=> payload switch
{
"submit" => ctx.Navigation.GoToAsync<SubmitPage>(ctx, ct),
"cancel" => ctx.Navigation.GoToAsync<WelcomePage>(ctx, ct),
_ => Task.CompletedTask
};
}

33
Demo/Pages/DetailsPage.cs Normal file
View File

@@ -0,0 +1,33 @@
using BotPages.Core;
using BotPages.Core.Messaging;
namespace Demo.Pages;
/// <summary>
/// Страница ввода деталей заявки.
/// </summary>
public sealed class DetailsPage : StatefullPage<DetailsArgs>
{
public override Task OnEnter(PageContext ctx, DetailsArgs args, CancellationToken ct)
=> new MessageBuilder(ctx)
.Text($"Заголовок: {args.Title}\nДобавьте детали или нажмите Далее.")
.Inline(new InlineButton("Далее", "next"), new InlineButton("Назад", "back"))
.Reply("Отмена")
.SendAsync(ct);
public override Task OnButton(PageContext ctx, string payload, CancellationToken ct)
=> payload switch
{
"next" => ctx.Navigation.GoToAsync<FilesPage>(ctx, ct),
"back" => ctx.Navigation.GoToAsync<TitlePage>(ctx, ct),
_ => Task.CompletedTask
};
}
/// <summary>
/// Аргументы для страницы DetailsPage.
/// </summary>
public sealed class DetailsArgs
{
public string Title { get; set; } = "";
}

View File

@@ -0,0 +1,29 @@
using BotPages.Core;
using BotPages.Core.Abstractions;
using BotPages.Core.Messaging;
namespace Demo.Pages;
public sealed class FileSendPage : SingletonPage
{
public override Task OnEnter(PageContext ctx, CancellationToken ct)
{
var content = "Hello from BotPages! This file is generated on the fly.";
var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content));
var demoFile = new FileDescriptor
{
Id = "", // не используется при отправке нового файла
Name = "demo.txt",
Extension = "txt",
Size = stream.Length,
Kind = FileKind.Document,
GetStreamAsync = _ => Task.FromResult<Stream>(stream)
};
return new MessageBuilder(ctx)
.Text("Вот пример отправки нового файла 📎", MessageFormat.Markdown)
.File(demoFile, "Демонстрационный файл")
.SendAsync(ct);
}
}

View File

@@ -1,39 +1,36 @@
using BotPages.Core;
using BotPages.Core.Abstractions;
using BotPages.Core.Messaging;
namespace Demo.Pages
namespace Demo.Pages;
/// <summary>
/// Страница загрузки файлов.
/// </summary>
public sealed class FilesPage : Page
{
public sealed class FilesPage : Page
public override Task OnEnter(PageContext ctx, CancellationToken ct)
=> new MessageBuilder(ctx)
.Text("Пришлите файлы для заявки 📎", MessageFormat.Markdown)
.Reply("Пропустить")
.SendAsync(ct);
public override async Task OnFile(PageContext ctx, List<FileDescriptor> files, CancellationToken ct)
{
public static string Id => nameof(FilesPage);
public override Task<PageResult> EnterAsync(UpdateContext ctx, CancellationToken ct)
foreach (var file in files)
{
var actions = new[]
{
new PageAction { Label = "⬅️ Назад", Value = "back", Placement = ActionPlacement.Reply, Row = 0 }
};
return Task.FromResult(
PageResultBuilder.Empty()
.WithText("📂 Здесь можно загрузить или отправить файл.")
.WithKeyboard(actions)
.Build()
);
await ctx.SendFileAsync(file, $"Файл '{file.Name}' получен и отправлен обратно.", ct);
}
public override async Task<PageResult> HandleAsync(UpdateContext ctx, CancellationToken ct)
{
if (ctx.Text == "⬅️ Назад")
return PageResultBuilder.Empty().WithNavigate(nameof(MainPage)).Build();
if (ctx.IncomingFiles?.Count > 0)
{
await ctx.Client.SendFilesAsync(ctx.Chat.Id, ctx.IncomingFiles, ct);
return PageResultBuilder.Empty().WithText("Файл получен и отправлен обратно.").Build();
}
return PageResultBuilder.Empty().WithText("Пришлите файл или нажмите 'Назад'.").Build();
}
await new MessageBuilder(ctx)
.Text($"Получено файлов: {files.Count}", MessageFormat.Plain)
.Inline("Далее", "next")
.SendAsync(ct);
}
public override Task OnButton(PageContext ctx, string payload, CancellationToken ct)
=> ctx.Navigation.GoToAsync<ConfirmPage>(ctx, ct);
public override Task OnText(PageContext ctx, string text, CancellationToken ct)
=> ctx.Navigation.GoToAsync<ConfirmPage>(ctx, ct);
}

View File

@@ -1,32 +0,0 @@
using BotPages.Core;
namespace Demo.Pages
{
public sealed class InlinePage : Page
{
public override string Id => nameof(InlinePage);
public override Task<PageResult> EnterAsync(UpdateContext ctx, CancellationToken ct)
{
var actions = new[]
{
new PageAction { Label = "⬅️ Назад", Value = "back", Placement = ActionPlacement.Inline, Row = 0 }
};
return Task.FromResult(
PageResultBuilder.Empty()
.WithText("Это страница с Inlineкнопками.")
.WithKeyboard(actions)
.Build()
);
}
public override Task<PageResult> HandleAsync(UpdateContext ctx, CancellationToken ct)
{
if (ctx.Text == "back")
return Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(MainPage)).Build());
return Task.FromResult(PageResultBuilder.Empty().WithText("Нажмите кнопку 'Назад'.").Build());
}
}
}

View File

@@ -1,51 +0,0 @@
using BotPages.Core;
namespace Demo.Pages
{
public sealed class MainPage : Page
{
public override string Id => nameof(MainPage);
public override Task<PageResult> EnterAsync(UpdateContext ctx, CancellationToken ct)
{
var actions = new[]
{
new PageAction(MainPageButtons.Inline) { Placement = ActionPlacement.Reply, Row = 0 },
new PageAction(MainPageButtons.Reply) { Placement = ActionPlacement.Reply, Row = 1 },
new PageAction(MainPageButtons.Files) { Placement = ActionPlacement.Reply, Row = 2 },
};
return Task.FromResult(
PageResultBuilder.Empty()
.WithText("🏠 Главная страница.\nВыберите куда перейти:")
.WithKeyboard(actions)
.Build()
);
}
public override Task<PageResult> HandleAsync(UpdateContext ctx, CancellationToken ct)
{
var button = ActionExtensions.FromActionLabel<MainPageButtons>(ctx.Text);
return button switch
{
MainPageButtons.Inline => Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(InlinePage)).Build()),
MainPageButtons.Reply => Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(ReplyPage)).Build()),
MainPageButtons.Files => Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(FilesPage)).Build()),
_ => Task.FromResult(PageResultBuilder.Empty().WithText("Выберите действие с кнопок.").Build())
};
}
}
public enum MainPageButtons
{
[Action("📌 Inline")]
Inline,
[Action("⌨️ Reply")]
Reply,
[Action("📂 Файлы")]
Files,
}
}

View File

@@ -1,33 +0,0 @@
using BotPages.Core;
namespace Demo.Pages
{
public sealed class ReplyPage : Page
{
public override string Id => nameof(ReplyPage);
public override Task<PageResult> EnterAsync(UpdateContext ctx, CancellationToken ct)
{
var actions = new[]
{
new PageAction { Label = "⬅️ Назад", Value = "back", Placement = ActionPlacement.Reply, Row = 0 }
};
return Task.FromResult(
PageResultBuilder.Empty()
.WithText("Это страница с Replyклавиатурой.")
.WithKeyboard(actions)
.Build()
);
}
public override Task<PageResult> HandleAsync(UpdateContext ctx, CancellationToken ct)
{
if (ctx.Text == "⬅️ Назад")
return Task.FromResult(PageResultBuilder.Empty().WithNavigate(nameof(MainPage)).Build());
return Task.FromResult(PageResultBuilder.Empty().WithText("Нажмите кнопку 'Назад'.").Build());
}
}
}

37
Demo/Pages/SubmitPage.cs Normal file
View File

@@ -0,0 +1,37 @@
using BotPages.Core;
using BotPages.Core.Messaging;
namespace Demo.Pages;
/// <summary>
/// Финальная страница отправки заявки.
/// </summary>
public sealed class SubmitPage : SingletonPage
{
public override async Task OnEnter(PageContext ctx, CancellationToken ct)
{
var progress = new MessageBuilder(ctx);
await progress
.Progress("Отправка заявки", 7)
.SendAsync(ct);
int i = 7;
do
{
i += 25;
Thread.Sleep(TimeSpan.FromMilliseconds(200));
await progress
.Progress("Отправка заявки", i)
.SendAsync(ct);
}
while (i < 100);
await ctx.Navigation.GoToHome(ctx, ct);
}
public override Task OnLeave(PageContext ctx, CancellationToken ct)
{
return new MessageBuilder(ctx).Text("Заявка отправлена").SendAsync(ct);
}
}

18
Demo/Pages/TitlePage.cs Normal file
View File

@@ -0,0 +1,18 @@
using BotPages.Core;
using BotPages.Core.Abstractions;
using BotPages.Core.Messaging;
namespace Demo.Pages;
/// <summary>
/// Страница ввода заголовка заявки.
/// </summary>
public sealed class TitlePage : SingletonPage
{
public override Task OnEnter(PageContext ctx, CancellationToken ct)
=> new MessageBuilder(ctx)
.Text("Введите заголовок заявки:", MessageFormat.Markdown)
.SendAsync(ct);
public override Task OnText(PageContext ctx, string text, CancellationToken ct)
=> ctx.Navigation.GoToAsync<DetailsPage, DetailsArgs>(ctx, new DetailsArgs { Title = text }, ct);
}

56
Demo/Pages/WelcomePage.cs Normal file
View File

@@ -0,0 +1,56 @@
using BotPages.Core;
using BotPages.Core.Abstractions;
using BotPages.Core.Messaging;
namespace Demo.Pages;
/// <summary>
/// Стартовая страница демо‑бота.
/// </summary>
public sealed class WelcomePage : SingletonPage
{
public override Task OnEnter(PageContext ctx, CancellationToken ct)
=> new MessageBuilder(ctx)
.Text("Добро пожаловать! 🚀")
.Reply(WelcomePageButtons.CreateRequest)
.Reply(WelcomePageButtons.Help)
.Reply(WelcomePageButtons.SendFile)
.SendAsync(ct);
public override Task OnText(PageContext ctx, string text, CancellationToken ct)
{
var button = ButtonExtensions.FromButtonLabel<WelcomePageButtons>(text);
switch (button)
{
case WelcomePageButtons.CreateRequest:
{
return ctx.Navigation.GoToAsync<TitlePage>(ctx, ct);
}
case WelcomePageButtons.Help:
{
return new MessageBuilder(ctx).Text("Здесь будет справка.", MessageFormat.Plain).SendAsync(ct);
}
case WelcomePageButtons.SendFile:
{
return ctx.Navigation.GoToAsync<FileSendPage>(ctx, ct);
}
}
return base.OnText(ctx, text, ct);
}
}
public enum WelcomePageButtons
{
[Button("Создать заявку")]
CreateRequest,
[Button("Помощь")]
Help,
[Button("Отправка файла")]
SendFile,
}

View File

@@ -1,7 +1,10 @@
using BotPages.Core;
using BotPages.Core.Abstractions;
using BotPages.Core.Logging;
using BotPages.Core.Middleware;
using BotPages.Core.Storage;
using BotPages.Telegram;
using Demo.Pages;
using Telegram.Bot;
namespace Demo
{
@@ -9,57 +12,30 @@ namespace Demo
{
public static async Task Main(string[] args)
{
// Токен Telegram бота
var token = Environment.GetEnvironmentVariable("TELEGRAM_TOKEN") ?? throw new InvalidOperationException("TELEGRAM_TOKEN not set");
var token = Environment.GetEnvironmentVariable("TELEGRAM_TOKEN")
?? throw new InvalidOperationException("TELEGRAM_TOKEN not set");
// Инициализация Telegram клиента
var botClient = new TelegramBotClient(token);
var chatClient = new TelegramClientAdapter(botClient);
var logger = new ConsoleLogger();
var state = new InMemoryStateStorage();
var telegram = new TelegramAdapter(logger);
var factory = new MultiAdapterFactory()
.Register("Telegram", telegram);
// Регистрируем страницы
var pages = new IPage[]
{
new MainPage(),
new InlinePage(),
new ReplyPage(),
new FilesPage()
};
var registry = new PageRegistry(pages, pages[0]);
var app = new BotPagesApp(factory, state, logger)
.AddDefaultPage<WelcomePage>()
.MapCommand<WelcomePage>("/start")
.AddMiddleware(new ErrorHandlingMiddleware(logger))
.AddMiddleware(new LoggingMiddleware(logger));
// Навигация и состояние
IStateStore store = new InMemoryStateStore();
INavigationService nav = new NavigationService(registry, store);
var router = new Router(registry);
using var cts = new CancellationTokenSource();
var middleware = new IUpdateMiddleware[]
{
new LoggingMiddleware(), //логирование вызова в консоль
new ErrorHandlingMiddleware(), //обработчик ошибок
//new ThrottleMiddleware(TimeSpan.FromMilliseconds(150)), //задержка в 150мс перед ответом
};
await telegram.StartPollingAsync(token,
update => app.HandleUpdateAsync(update, CancellationToken.None),
cts.Token);
var pipeline = new Pipeline(middleware, router);
botClient.StartReceiving(
async (bot, update, ct) =>
{
var ctx = TelegramUpdateMapper.Map(bot, nav, store, update);
await pipeline.ExecuteAsync(ctx, ct);
},
(bot, error, ct) =>
{
Console.WriteLine($"⚠️ Ошибка Telegram: {error}");
return Task.CompletedTask;
}
);
var me = await botClient.GetMe();
Console.WriteLine($"BotPages Demo (@{me.Username}) запущен. Нажмите Enter для выхода.");
Console.ReadLine();
Console.ReadKey();
cts.Cancel();
}
}
}