diff --git a/BotPages.Core/Abstractions/AdapterOptionsBag.cs b/BotPages.Core/Abstractions/AdapterOptionsBag.cs index 33a56bd..c1b7fa8 100644 --- a/BotPages.Core/Abstractions/AdapterOptionsBag.cs +++ b/BotPages.Core/Abstractions/AdapterOptionsBag.cs @@ -2,7 +2,7 @@ namespace BotPages.Core.Abstractions; /// /// - , . -/// `SendRequest.AdapterOptions`. +/// . /// public sealed class AdapterOptionsBag { diff --git a/BotPages.Core/Abstractions/CompositeSessionKey.cs b/BotPages.Core/Abstractions/CompositeSessionKey.cs index 0190701..9809b31 100644 --- a/BotPages.Core/Abstractions/CompositeSessionKey.cs +++ b/BotPages.Core/Abstractions/CompositeSessionKey.cs @@ -1,6 +1,28 @@ -namespace BotPages.Core.Abstractions; +using BotPages.Core.Context; + +namespace BotPages.Core.Abstractions; /// /// Ключ для идентификации пользовательской сессии. /// -public readonly record struct CompositeSessionKey(string MessengerType, string ChatId, string? UserId); +public readonly record struct CompositeSessionKey(string AdapterId, string ChatId, string? UserId) +{ + /// + /// Создает ключ сессии из UpdateContext. + /// + public static CompositeSessionKey FromUpdate(UpdateContext update) + { + return new CompositeSessionKey( + update.AdapterId, + update.Chat.Id, + update.User.Id); + } + + /// + /// Получить ключ для определенного адаптера. + /// + public CompositeSessionKey ForAdapter(string adapterId) + { + return new CompositeSessionKey(adapterId, ChatId, UserId); + } +} \ No newline at end of file diff --git a/BotPages.Core/Abstractions/IMessengerAdapter.cs b/BotPages.Core/Abstractions/IMessengerAdapter.cs index 1fb9b27..e92f31b 100644 --- a/BotPages.Core/Abstractions/IMessengerAdapter.cs +++ b/BotPages.Core/Abstractions/IMessengerAdapter.cs @@ -1,4 +1,5 @@ using BotPages.Core.Context; +using BotPages.Core.Messaging; namespace BotPages.Core.Abstractions; @@ -23,6 +24,53 @@ public interface IMessengerAdapter /// Task DeleteAsync(string chatId, string messageId, CancellationToken ct = default); + /// + /// Удалить несколько сообщений за раз. + /// + Task DeleteMultipleAsync(string chatId, IEnumerable messageIds, CancellationToken ct = default); + + /// + /// Редактировать только текст сообщения. + /// + Task EditTextAsync(string chatId, string messageId, string text, + MessageFormat? format = null, CancellationToken ct = default); + + /// + /// Редактировать только клавиатуру сообщения. + /// + Task EditButtonsAsync(string chatId, string messageId, + IEnumerable>? inlineButtons = null, + CancellationToken ct = default); + + /// + /// Закрепить сообщение в чате. + /// + Task PinMessageAsync(string chatId, string messageId, bool disableNotification = false, + CancellationToken ct = default); + + /// + /// Открепить сообщение в чате. + /// + Task UnpinMessageAsync(string chatId, string messageId, CancellationToken ct = default); + + /// + /// Получить информацию о сообщении. + /// + Task GetMessageInfoAsync(string chatId, string messageId, CancellationToken ct = default); + + /// + /// Переслать сообщение. + /// + Task ForwardMessageAsync(string fromChatId, string messageId, string toChatId, + bool disableNotification = false, CancellationToken ct = default); + + /// + /// Копировать сообщение с возможностью редактирования. + /// + Task CopyMessageAsync(string fromChatId, string messageId, string toChatId, + string? caption = null, MessageFormat? captionFormat = null, + bool disableNotification = false, CancellationToken ct = default); + /// /// Создать билдер альбома для отправки медиагруппы. /// diff --git a/BotPages.Core/Abstractions/IMessengerAdapterFactory.cs b/BotPages.Core/Abstractions/IMessengerAdapterFactory.cs index 2235438..2ecfe6b 100644 --- a/BotPages.Core/Abstractions/IMessengerAdapterFactory.cs +++ b/BotPages.Core/Abstractions/IMessengerAdapterFactory.cs @@ -1,44 +1,53 @@ -using BotPages.Core.Context; - -namespace BotPages.Core.Abstractions; +namespace BotPages.Core.Abstractions; /// /// Фабрика адаптеров мессенджеров. -/// Используется для разрешения конкретного по типу мессенджера. +/// Используется для разрешения конкретного по ID адаптера. /// public interface IMessengerAdapterFactory { /// - /// Список зарегистрированных адаптеров. + /// Список всех зарегистрированных адаптеров. /// - Dictionary Adapters { get; } + IReadOnlyList AllAdapters { get; } /// - /// Зарегистрировать адаптер для указанного типа мессенджера. + /// Зарегистрировать адаптер с уникальным идентификатором. /// - /// - /// Тип мессенджера (например, "Telegram", "Slack", "VK"). - /// - /// - /// Экземпляр адаптера, реализующий . - /// - /// - /// Текущий экземпляр для цепочки вызовов. - /// - IMessengerAdapterFactory Register(string messengerType, IMessengerAdapterSetup adapter); + /// Уникальный идентификатор адаптера. + /// Экземпляр адаптера. + /// Текущий экземпляр фабрики для цепочки вызовов. + /// Если адаптер с таким ID уже зарегистрирован. + IMessengerAdapterFactory Register(string adapterId, IMessengerAdapterSetup adapter); /// - /// Получить адаптер для указанного мессенджера. + /// Зарегистрировать адаптер с автоматически сгенерированным ID. /// - /// - /// Тип мессенджера (например, "Telegram", "Slack", "VK"). - /// Значение должно совпадать с . - /// - /// - /// Экземпляр , зарегистрированный для данного типа мессенджера. - /// - /// - /// Выбрасывается, если адаптер для указанного типа не зарегистрирован. - /// - IMessengerAdapter Resolve(string messengerType); -} + IMessengerAdapterFactory Register(IMessengerAdapterSetup adapter); + + /// + /// Получить адаптер по ID. + /// + /// Если адаптер не найден. + IMessengerAdapter Resolve(string adapterId); + + /// + /// Попытаться получить адаптер по ID. + /// + bool TryResolve(string adapterId, out IMessengerAdapter? adapter); + + /// + /// Получить все адаптеры определенного типа. + /// + IReadOnlyList GetAdaptersByType(string adapterType); + + /// + /// Проверить, зарегистрирован ли адаптер с указанным ID. + /// + bool Contains(string adapterId); + + /// + /// Удалить адаптер по ID. + /// + bool Remove(string adapterId); +} \ No newline at end of file diff --git a/BotPages.Core/Abstractions/MessageInfo.cs b/BotPages.Core/Abstractions/MessageInfo.cs new file mode 100644 index 0000000..b629ab7 --- /dev/null +++ b/BotPages.Core/Abstractions/MessageInfo.cs @@ -0,0 +1,34 @@ +namespace BotPages.Core.Abstractions; + +/// +/// Информация о сообщении. +/// +public class MessageInfo +{ + /// ID сообщения. + public required string MessageId { get; init; } + + /// ID чата. + public required string ChatId { get; init; } + + /// Текст сообщения. + public string? Text { get; init; } + + /// Формат текста. + public MessageFormat? Format { get; init; } + + /// Дата отправки. + public DateTime Date { get; init; } + + /// ID отправителя. + public string? FromUserId { get; init; } + + /// Закреплено ли сообщение. + public bool IsPinned { get; init; } + + /// Является ли ответом на другое сообщение. + public bool IsReply { get; init; } + + /// ID сообщения, на которое отвечает. + public string? ReplyToMessageId { get; init; } +} diff --git a/BotPages.Core/Abstractions/MessengerAdapterBase.cs b/BotPages.Core/Abstractions/MessengerAdapterBase.cs new file mode 100644 index 0000000..2240527 --- /dev/null +++ b/BotPages.Core/Abstractions/MessengerAdapterBase.cs @@ -0,0 +1,102 @@ +using BotPages.Core.Context; +using BotPages.Core.Messaging; + +namespace BotPages.Core.Abstractions; + +/// +/// Базовый класс для адаптеров мессенджеров. +/// +public abstract class MessengerAdapterBase : IMessengerAdapterSetup +{ + /// + /// Уникальный идентификатор адаптера. + /// + public string AdapterId { get; internal set; } = string.Empty; + + /// + /// Тип адаптера (Telegram, VK, WhatsApp и т.д.). + /// + public abstract string AdapterType { get; } + + /// + /// Название адаптера для отображения. + /// + public string DisplayName { get; set; } = string.Empty; + + /// + /// Доступные возможности мессенджера. + /// + public abstract Capabilities Capabilities { get; } + + /// + /// Универсальный метод отправки с использованием общего описания запроса. + /// + public abstract Task SendAsync(SendRequest request, CancellationToken ct = default); + + /// + /// Универсальный метод удаления сообщения. + /// + public abstract Task DeleteAsync(string chatId, string messageId, CancellationToken ct = default); + + /// + /// Удалить несколько сообщений за раз. + /// + public abstract Task DeleteMultipleAsync(string chatId, IEnumerable messageIds, CancellationToken ct = default); + + /// + /// Редактировать только текст сообщения. + /// + public abstract Task EditTextAsync(string chatId, string messageId, string text, + MessageFormat? format = null, CancellationToken ct = default); + + /// + /// Редактировать только клавиатуру сообщения. + /// + public abstract Task EditButtonsAsync(string chatId, string messageId, + IEnumerable>? inlineButtons = null, + CancellationToken ct = default); + + /// + /// Закрепить сообщение в чате. + /// + public abstract Task PinMessageAsync(string chatId, string messageId, bool disableNotification = false, + CancellationToken ct = default); + + /// + /// Открепить сообщение в чате. + /// + public abstract Task UnpinMessageAsync(string chatId, string messageId, CancellationToken ct = default); + + /// + /// Получить информацию о сообщении. + /// + public abstract Task GetMessageInfoAsync(string chatId, string messageId, CancellationToken ct = default); + + /// + /// Переслать сообщение. + /// + public abstract Task ForwardMessageAsync(string fromChatId, string messageId, string toChatId, + bool disableNotification = false, CancellationToken ct = default); + + /// + /// Копировать сообщение с возможностью редактирования. + /// + public abstract Task CopyMessageAsync(string fromChatId, string messageId, string toChatId, + string? caption = null, MessageFormat? captionFormat = null, + bool disableNotification = false, CancellationToken ct = default); + + /// + /// Создать билдер альбома для отправки медиагруппы. + /// + public abstract IAlbumBuilder CreateAlbumBuilder(PageContext ctx); + + /// + /// Вызывается при выходе со страницы. + /// + public abstract Task OnLeaveAsync(PageContext ctx, CancellationToken ct); + + /// + /// Запуск работы адаптера. + /// + public abstract Task StartAdapterAsync(Func onUpdate, List commands, CancellationToken ct); +} \ No newline at end of file diff --git a/BotPages.Core/Abstractions/MultiAdapterFactory.cs b/BotPages.Core/Abstractions/MultiAdapterFactory.cs index ddf47fd..ec3ac10 100644 --- a/BotPages.Core/Abstractions/MultiAdapterFactory.cs +++ b/BotPages.Core/Abstractions/MultiAdapterFactory.cs @@ -1,50 +1,142 @@ namespace BotPages.Core.Abstractions; - /// -/// Реализация , позволяющая регистрировать и разрешать несколько адаптеров мессенджеров. +/// Реализация , позволяющая регистрировать и разрешать несколько адаптеров. /// public sealed class MultiAdapterFactory : IMessengerAdapterFactory { - private readonly Dictionary _adapters = new(StringComparer.OrdinalIgnoreCase); + private readonly List _allAdapters = new(); + private readonly Dictionary _adaptersById = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _adaptersByType = new(StringComparer.OrdinalIgnoreCase); /// - /// Список зарегистрированных адаптеров. + /// Список всех зарегистрированных адаптеров. /// - public Dictionary Adapters => _adapters; + public IReadOnlyList AllAdapters => _allAdapters; /// - /// Зарегистрировать адаптер для указанного типа мессенджера. + /// Зарегистрировать адаптер с уникальным идентификатором. /// - /// - /// Тип мессенджера (например, "Telegram", "Slack", "VK"). - /// - /// - /// Экземпляр адаптера, реализующий . - /// - /// - /// Текущий экземпляр для цепочки вызовов. - /// - public IMessengerAdapterFactory Register(string messengerType, IMessengerAdapterSetup adapter) + public IMessengerAdapterFactory Register(string adapterId, IMessengerAdapterSetup adapter) { - _adapters[messengerType] = adapter; + if (string.IsNullOrWhiteSpace(adapterId)) + throw new ArgumentException("Adapter ID cannot be null or empty", nameof(adapterId)); + + if (_adaptersById.ContainsKey(adapterId)) + throw new ArgumentException($"Adapter with ID '{adapterId}' is already registered", nameof(adapterId)); + + // Устанавливаем идентификатор в адаптер + if (adapter is MessengerAdapterBase adapterBase) + { + adapterBase.AdapterId = adapterId; + } + + _allAdapters.Add(adapter); + _adaptersById[adapterId] = adapter; + + // Группируем по типу + var adapterType = GetAdapterType(adapter); + if (!_adaptersByType.TryGetValue(adapterType, out var typeList)) + { + typeList = new List(); + _adaptersByType[adapterType] = typeList; + } + typeList.Add(adapter); + return this; } /// - /// Получить адаптер для указанного мессенджера. + /// Зарегистрировать адаптер с автоматически сгенерированным ID. /// - /// - /// Тип мессенджера (например, "Telegram", "Slack", "VK"). - /// - /// - /// Экземпляр , зарегистрированный для данного типа мессенджера. - /// - /// - /// Выбрасывается, если адаптер для указанного типа не зарегистрирован. - /// - public IMessengerAdapter Resolve(string messengerType) - => _adapters.TryGetValue(messengerType, out var adapter) - ? adapter - : throw new InvalidOperationException($"No adapter registered for {messengerType}"); -} + public IMessengerAdapterFactory Register(IMessengerAdapterSetup adapter) + { + var adapterId = GenerateAdapterId(adapter); + return Register(adapterId, adapter); + } + + /// + /// Получить адаптер по ID. + /// + public IMessengerAdapter Resolve(string adapterId) + { + if (_adaptersById.TryGetValue(adapterId, out var adapter)) + { + return adapter; + } + + throw new InvalidOperationException($"No adapter registered with ID '{adapterId}'"); + } + + /// + /// Попытаться получить адаптер по ID. + /// + public bool TryResolve(string adapterId, out IMessengerAdapter? adapter) + { + var result = _adaptersById.TryGetValue(adapterId, out var adapterSetup); + adapter = adapterSetup; + return result; + } + + /// + /// Получить все адаптеры определенного типа. + /// + public IReadOnlyList GetAdaptersByType(string adapterType) + { + if (_adaptersByType.TryGetValue(adapterType, out var adapters)) + { + return adapters.AsReadOnly(); + } + + return Array.Empty(); + } + + /// + /// Проверить, зарегистрирован ли адаптер с указанным ID. + /// + public bool Contains(string adapterId) => _adaptersById.ContainsKey(adapterId); + + /// + /// Удалить адаптер по ID. + /// + public bool Remove(string adapterId) + { + if (_adaptersById.TryGetValue(adapterId, out var adapter)) + { + _allAdapters.Remove(adapter); + _adaptersById.Remove(adapterId); + + var adapterType = GetAdapterType(adapter); + if (_adaptersByType.TryGetValue(adapterType, out var typeList)) + { + typeList.Remove(adapter); + if (typeList.Count == 0) + { + _adaptersByType.Remove(adapterType); + } + } + + return true; + } + + return false; + } + + private static string GetAdapterType(IMessengerAdapter adapter) + { + if (adapter is MessengerAdapterBase adapterBase) + { + return adapterBase.AdapterType; + } + + // Для обратной совместимости + return adapter.GetType().Name.Replace("Adapter", ""); + } + + private static string GenerateAdapterId(IMessengerAdapter adapter) + { + var adapterType = GetAdapterType(adapter); + var guid = Guid.NewGuid().ToString("N").Substring(0, 8); + return $"{adapterType}_{guid}".ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/BotPages.Core/Abstractions/SendRequest.cs b/BotPages.Core/Abstractions/SendRequest.cs index d009c97..a63b377 100644 --- a/BotPages.Core/Abstractions/SendRequest.cs +++ b/BotPages.Core/Abstractions/SendRequest.cs @@ -39,4 +39,66 @@ public sealed class SendRequest /// / . /// public AdapterOptionsBag? AdapterOptions { get; init; } + + /// + /// ID , . + /// + public string? ReplyToMessageId { get; init; } + + /// + /// . + /// + public bool QuoteReply { get; init; } = true; + + /// + /// ( ). + /// + public string? QuoteTitle { get; init; } + + /// + /// . + /// + public bool DisableWebPagePreview { get; init; } = false; + + /// + /// . + /// + public bool DisableNotification { get; init; } = false; + + /// + /// . + /// + public bool ProtectContent { get; init; } = false; + + /// + /// ( ). + /// + public MessageStyle? Style { get; init; } + + /// + /// ( ). + /// + public DateTime? ScheduleDate { get; init; } + + /// + /// ( ). + /// + public string? Topic { get; init; } } + +/// +/// . +/// +public enum MessageStyle +{ + /// . + Default, + /// . + Heading, + /// . + Warning, + /// . + Success, + /// . + Error +} \ No newline at end of file diff --git a/BotPages.Core/BotPagesApp.cs b/BotPages.Core/BotPagesApp.cs index 79174e7..069bf3b 100644 --- a/BotPages.Core/BotPagesApp.cs +++ b/BotPages.Core/BotPagesApp.cs @@ -38,14 +38,40 @@ public sealed class BotPagesApp } /// - /// Добавить адаптер. + /// Добавить адаптер с указанием ID. /// - public BotPagesApp AddAdapter(string messengerType, IMessengerAdapterSetup adapter) + /// Если адаптер с таким ID уже существует. + public BotPagesApp AddAdapter(string adapterId, IMessengerAdapterSetup adapter) { - _adapterFactory.Register(messengerType, adapter); + _adapterFactory.Register(adapterId, adapter); return this; } + /// + /// Добавить адаптер с автоматическим ID. + /// + public BotPagesApp AddAdapter(IMessengerAdapterSetup adapter) + { + _adapterFactory.Register(adapter); + return this; + } + + /// + /// Проверить, существует ли адаптер с указанным ID. + /// + public bool HasAdapter(string adapterId) => _adapterFactory.Contains(adapterId); + + /// + /// Получить адаптер по ID. + /// + public IMessengerAdapter GetAdapter(string adapterId) => _adapterFactory.Resolve(adapterId); + + /// + /// Получить все адаптеры определенного типа. + /// + public IReadOnlyList GetAdaptersByType(string adapterType) + => _adapterFactory.GetAdaptersByType(adapterType); + /// /// Установить страницу по умолчанию. /// @@ -174,6 +200,28 @@ public sealed class BotPagesApp await pipeline(); } + /// + /// Создать контекст страницы для текущего обновления. + /// + private async Task CreatePageContextAsync(UpdateContext update, CancellationToken ct) + { + _currentCt = ct; + + var sessionKey = CompositeSessionKey.FromUpdate(update); + + var ctx = new PageContext + { + Update = update, + SessionKey = sessionKey, + StateStorage = _state, + Navigation = _navigation, + Adapter = _adapterFactory.Resolve(update.AdapterId), + AdapterFactory = _adapterFactory, + }; + + return await Task.FromResult(ctx); + } + private Func BuildPipeline(PageContext ctx, Func terminal) { Func next = terminal; @@ -188,27 +236,6 @@ public sealed class BotPagesApp // Технические поля для конвейера private CancellationToken _currentCt; - /// - /// Создать контекст страницы для текущего обновления. - /// - private async Task CreatePageContextAsync(UpdateContext update, CancellationToken ct) - { - _currentCt = ct; - - var sessionKey = new CompositeSessionKey(update.MessengerType, update.Chat.Id, update.User.Id); - - var ctx = new PageContext - { - Update = update, - SessionKey = sessionKey, - StateStorage = _state, - Navigation = _navigation, - Adapter = _adapterFactory.Resolve(update.MessengerType), - }; - - return await Task.FromResult(ctx); - } - /// /// Отправить обновление на текущую страницу. /// @@ -230,6 +257,8 @@ public sealed class BotPagesApp if (update.Kind.HasFlag(UpdateKind.Text) && update.Text is not null) await page.OnText(ctx, update.Text, ct); if (update.Kind.HasFlag(UpdateKind.Button) && update.Text is not null) await page.OnButton(ctx, update.Text, ct); if (update.Kind.HasFlag(UpdateKind.File) && update.Files.Count > 0) await page.OnFile(ctx, update.Files, ct); + if (update.Kind.HasFlag(UpdateKind.Pin) && update.PinInfo is not null) await page.OnPin(ctx, update.PinInfo, ct); + if (update.Kind.HasFlag(UpdateKind.Delete) && update.DeleteInfo is not null) await page.OnDelete(ctx, update.DeleteInfo, ct); } catch (Exception ex) @@ -252,9 +281,9 @@ public sealed class BotPagesApp /// public async Task Build(CancellationToken cancellationToken) { - foreach (var adapter in _adapterFactory.Adapters) + foreach (var adapter in _adapterFactory.AllAdapters) { - await adapter.Value.StartAdapterAsync(update => HandleUpdateAsync(update, cancellationToken), _commands.Commands, cancellationToken); + await adapter.StartAdapterAsync(update => HandleUpdateAsync(update, cancellationToken), _commands.Commands, cancellationToken); } } } \ No newline at end of file diff --git a/BotPages.Core/Context/DeleteInfo.cs b/BotPages.Core/Context/DeleteInfo.cs new file mode 100644 index 0000000..be3d400 --- /dev/null +++ b/BotPages.Core/Context/DeleteInfo.cs @@ -0,0 +1,16 @@ +namespace BotPages.Core.Context; + +/// +/// Информация об удалении сообщения. +/// +public class DeleteInfo +{ + /// ID удаленного сообщения. + public required string MessageId { get; init; } + + /// Дата удаления. + public DateTime DeleteDate { get; init; } + + /// Удалено ли массово. + public bool IsBulkDelete { get; init; } +} diff --git a/BotPages.Core/Context/EditInfo.cs b/BotPages.Core/Context/EditInfo.cs new file mode 100644 index 0000000..43c53f8 --- /dev/null +++ b/BotPages.Core/Context/EditInfo.cs @@ -0,0 +1,16 @@ +namespace BotPages.Core.Context; + +/// +/// Информация о редактировании сообщения. +/// +public class EditInfo +{ + /// Новый текст сообщения. + public string? NewText { get; init; } + + /// Дата редактирования. + public DateTime EditDate { get; init; } + + /// Были ли изменены кнопки. + public bool ButtonsChanged { get; init; } +} diff --git a/BotPages.Core/Context/PageContext.cs b/BotPages.Core/Context/PageContext.cs index 6ae3c77..f00ad13 100644 --- a/BotPages.Core/Context/PageContext.cs +++ b/BotPages.Core/Context/PageContext.cs @@ -16,9 +16,14 @@ public sealed class PageContext /// Хранилище состояния. public required IStateStorage StateStorage { get; init; } + /// Сервис навигации. public required NavigationService Navigation { get; init; } - /// Адаптер мессенджера. + + /// Фабрика адаптеров (для получения других адаптеров). + public required IMessengerAdapterFactory AdapterFactory { get; init; } + + /// Текущий адаптер мессенджера. public required IMessengerAdapter Adapter { get; init; } /// @@ -26,4 +31,36 @@ public sealed class PageContext /// public IAlbumBuilder Albums => Adapter.CreateAlbumBuilder(this); + /// + /// Получить адаптер по ID. + /// + public IMessengerAdapter GetAdapter(string adapterId) + => AdapterFactory.Resolve(adapterId); + + /// + /// Попытаться получить адаптер по ID. + /// + public bool TryGetAdapter(string adapterId, out IMessengerAdapter? adapter) + => AdapterFactory.TryResolve(adapterId, out adapter); + + /// + /// Получить все адаптеры определенного типа. + /// + public IReadOnlyList GetAdaptersByType(string adapterType) + => AdapterFactory.GetAdaptersByType(adapterType); + + /// + /// Получить текущий тип адаптера. + /// + public string CurrentAdapterType => Update.AdapterType; + + /// + /// Получить текущий ID адаптера. + /// + public string CurrentAdapterId => Update.AdapterId; + + /// + /// Получить все адаптеры. + /// + public IReadOnlyList AllAdapters => AdapterFactory.AllAdapters; } \ No newline at end of file diff --git a/BotPages.Core/Context/PageContextAdapterExtensions.cs b/BotPages.Core/Context/PageContextAdapterExtensions.cs index 5510f69..2970828 100644 --- a/BotPages.Core/Context/PageContextAdapterExtensions.cs +++ b/BotPages.Core/Context/PageContextAdapterExtensions.cs @@ -1,4 +1,5 @@ using BotPages.Core.Abstractions; +using BotPages.Core.Messaging; namespace BotPages.Core; @@ -19,4 +20,108 @@ public static class PageContextAdapterExtensions /// public static Task DeleteAsync(this PageContext ctx, string chatId, string messageId, CancellationToken ct = default) => ctx.Adapter.DeleteAsync(chatId, messageId, ct); + + /// + /// Удалить сообщение (используя ChatId из контекста). + /// + public static Task DeleteAsync(this PageContext ctx, string messageId, CancellationToken ct = default) + => ctx.Adapter.DeleteAsync(ctx.Update.Chat.Id, messageId, ct); + + /// + /// Удалить несколько сообщений. + /// + public static Task DeleteMultipleAsync(this PageContext ctx, string chatId, IEnumerable messageIds, CancellationToken ct = default) + => ctx.Adapter.DeleteMultipleAsync(chatId, messageIds, ct); + + /// + /// Удалить несколько сообщений (используя ChatId из контекста). + /// + public static Task DeleteMultipleAsync(this PageContext ctx, IEnumerable messageIds, CancellationToken ct = default) + => ctx.Adapter.DeleteMultipleAsync(ctx.Update.Chat.Id, messageIds, ct); + + /// + /// Редактировать текст сообщения. + /// + public static Task EditTextAsync(this PageContext ctx, string chatId, string messageId, string text, + MessageFormat? format = null, CancellationToken ct = default) + => ctx.Adapter.EditTextAsync(chatId, messageId, text, format, ct); + + /// + /// Редактировать текст сообщения (используя ChatId из контекста). + /// + public static Task EditTextAsync(this PageContext ctx, string messageId, string text, + MessageFormat? format = null, CancellationToken ct = default) + => ctx.Adapter.EditTextAsync(ctx.Update.Chat.Id, messageId, text, format, ct); + + /// + /// Редактировать кнопки сообщения. + /// + public static Task EditButtonsAsync(this PageContext ctx, string chatId, string messageId, + IEnumerable>? inlineButtons = null, CancellationToken ct = default) + => ctx.Adapter.EditButtonsAsync(chatId, messageId, inlineButtons, ct); + + /// + /// Редактировать кнопки сообщения (используя ChatId из контекста). + /// + public static Task EditButtonsAsync(this PageContext ctx, string messageId, + IEnumerable>? inlineButtons = null, CancellationToken ct = default) + => ctx.Adapter.EditButtonsAsync(ctx.Update.Chat.Id, messageId, inlineButtons, ct); + + /// + /// Закрепить сообщение. + /// + public static Task PinMessageAsync(this PageContext ctx, string chatId, string messageId, + bool disableNotification = false, CancellationToken ct = default) + => ctx.Adapter.PinMessageAsync(chatId, messageId, disableNotification, ct); + + /// + /// Закрепить сообщение (используя ChatId из контекста). + /// + public static Task PinMessageAsync(this PageContext ctx, string messageId, + bool disableNotification = false, CancellationToken ct = default) + => ctx.Adapter.PinMessageAsync(ctx.Update.Chat.Id, messageId, disableNotification, ct); + + /// + /// Открепить сообщение. + /// + public static Task UnpinMessageAsync(this PageContext ctx, string chatId, string messageId, + CancellationToken ct = default) + => ctx.Adapter.UnpinMessageAsync(chatId, messageId, ct); + + /// + /// Открепить сообщение (используя ChatId из контекста). + /// + public static Task UnpinMessageAsync(this PageContext ctx, string messageId, + CancellationToken ct = default) + => ctx.Adapter.UnpinMessageAsync(ctx.Update.Chat.Id, messageId, ct); + + /// + /// Получить информацию о сообщении. + /// + public static Task GetMessageInfoAsync(this PageContext ctx, string chatId, string messageId, + CancellationToken ct = default) + => ctx.Adapter.GetMessageInfoAsync(chatId, messageId, ct); + + /// + /// Получить информацию о сообщении (используя ChatId из контекста). + /// + public static Task GetMessageInfoAsync(this PageContext ctx, string messageId, + CancellationToken ct = default) + => ctx.Adapter.GetMessageInfoAsync(ctx.Update.Chat.Id, messageId, ct); + + /// + /// Переслать сообщение. + /// + public static Task ForwardMessageAsync(this PageContext ctx, string fromChatId, string messageId, + string toChatId, bool disableNotification = false, CancellationToken ct = default) + => ctx.Adapter.ForwardMessageAsync(fromChatId, messageId, toChatId, disableNotification, ct); + + /// + /// Копировать сообщение. + /// + public static Task CopyMessageAsync(this PageContext ctx, string fromChatId, string messageId, + string toChatId, string? caption = null, MessageFormat? captionFormat = null, + bool disableNotification = false, CancellationToken ct = default) + => ctx.Adapter.CopyMessageAsync(fromChatId, messageId, toChatId, caption, captionFormat, + disableNotification, ct); } \ No newline at end of file diff --git a/BotPages.Core/Context/PinInfo.cs b/BotPages.Core/Context/PinInfo.cs new file mode 100644 index 0000000..cb6e2e4 --- /dev/null +++ b/BotPages.Core/Context/PinInfo.cs @@ -0,0 +1,16 @@ +namespace BotPages.Core.Context; + +/// +/// Информация о закреплении сообщения. +/// +public class PinInfo +{ + /// ID закрепленного сообщения. + public required string MessageId { get; init; } + + /// Дата закрепления. + public DateTime PinDate { get; init; } + + /// Отключено ли уведомление. + public bool NotificationDisabled { get; init; } +} \ No newline at end of file diff --git a/BotPages.Core/Context/UpdateContext.cs b/BotPages.Core/Context/UpdateContext.cs index ce54a0c..a8f9014 100644 --- a/BotPages.Core/Context/UpdateContext.cs +++ b/BotPages.Core/Context/UpdateContext.cs @@ -1,25 +1,6 @@ -namespace BotPages.Core.Context; +using BotPages.Core.Abstractions; -using BotPages.Core.Abstractions; - -/// -/// Тип входящего обновления. -/// -[Flags] -public enum UpdateKind -{ - /// Неизвестное сообщение. - None = 0, - - /// Текстовое сообщение. - Text = 1 << 0, - - /// Файлы (один или несколько). - File = 1 << 1, - - /// Нажатие кнопки. - Button = 1 << 2, -} +namespace BotPages.Core.Context; /// /// Контекст входящего обновления от мессенджера. @@ -27,8 +8,11 @@ public enum UpdateKind /// public sealed class UpdateContext { - /// Тип мессенджера. - public required string MessengerType { get; init; } + /// Идентификатор адаптера, от которого пришло обновление. + public required string AdapterId { get; init; } + + /// Тип адаптера (определяется адаптером). + public required string AdapterType { get; init; } /// /// Данные пользователя, от которого пришло обновление. @@ -51,9 +35,34 @@ public sealed class UpdateContext /// public string? Text { get; init; } + /// + /// ID сообщения, к которому относится обновление. + /// + public string? MessageId { get; init; } + + /// + /// ID сообщения, на которое дан ответ (если есть). + /// + public string? ReplyToMessageId { get; init; } + /// /// Список файлов, если Kind = File. /// Может содержать один или несколько файлов. /// public List Files { get; init; } = new(); + + /// + /// Информация о редактировании (если Kind = Edit). + /// + public EditInfo? EditInfo { get; init; } + + /// + /// Информация об удалении (если Kind = Delete). + /// + public DeleteInfo? DeleteInfo { get; init; } + + /// + /// Информация о закреплении (если Kind = Pin). + /// + public PinInfo? PinInfo { get; init; } } diff --git a/BotPages.Core/Context/UpdateKind.cs b/BotPages.Core/Context/UpdateKind.cs new file mode 100644 index 0000000..f448749 --- /dev/null +++ b/BotPages.Core/Context/UpdateKind.cs @@ -0,0 +1,32 @@ +namespace BotPages.Core.Context; + +/// +/// Тип входящего обновления. +/// +[Flags] +public enum UpdateKind +{ + /// Неизвестное сообщение. + None = 0, + + /// Текстовое сообщение. + Text = 1 << 0, + + /// Файлы (один или несколько). + File = 1 << 1, + + /// Нажатие кнопки. + Button = 1 << 2, + + /// Редактирование сообщения. + Edit = 1 << 3, + + /// Удаление сообщения. + Delete = 1 << 4, + + /// Закрепление сообщения. + Pin = 1 << 5, + + /// Ответ на сообщение. + Reply = 1 << 6, +} diff --git a/BotPages.Core/Messaging/MessageBuilder.cs b/BotPages.Core/Messaging/MessageBuilder.cs index 5048c18..ace6636 100644 --- a/BotPages.Core/Messaging/MessageBuilder.cs +++ b/BotPages.Core/Messaging/MessageBuilder.cs @@ -4,7 +4,7 @@ namespace BotPages.Core.Messaging; /// /// Fluent‑билдер для отправки сообщений (текст, кнопки, файлы, альбомы, прогресс). -/// Поддерживает указание адаптер-специфичных опций через `WithAdapterOption`. +/// Поддерживает указание адаптер-специфичных опций через . /// public sealed class MessageBuilder { @@ -17,11 +17,77 @@ public sealed class MessageBuilder private readonly List<(FileDescriptor file, string? caption, MessageFormat? captionFormat)> _album = new(); private bool _disableReplyKeyboard; private AdapterOptionsBag? _adapterOptions = null; - private string? _chatId = null; + private string? _replyToMessageId = null; + private bool _quoteReply = true; + private string? _quoteTitle = null; + private bool _disableWebPagePreview = false; + private bool _disableNotification = false; + private bool _protectContent = false; + private MessageStyle? _style = null; + private DateTime? _scheduleDate = null; + private string? _topic = null; + private string? _messageId = null; + private IMessengerAdapter? _targetAdapter = null; + private string? _targetChatId = null; /// Создать билдер сообщений. public MessageBuilder(PageContext ctx) => _ctx = ctx; + /// + /// Отправить сообщение через указанный адаптер. + /// + public MessageBuilder WithAdapter(IMessengerAdapter adapter) + { + _targetAdapter = adapter; + return this; + } + /// + /// Отправить сообщение через адаптер по ID. + /// + public MessageBuilder WithAdapter(string adapterId) + { + _targetAdapter = _ctx.AdapterFactory.Resolve(adapterId); + return this; + } + + /// + /// Использовать текущий адаптер (по умолчанию). + /// + public MessageBuilder UseCurrentAdapter() + { + _targetAdapter = null; + return this; + } + + /// + /// Отправить в указанный чат (с указанием адаптера). + /// + public MessageBuilder ToChat(string chatId, IMessengerAdapter? adapter) + { + _targetChatId = chatId; + _targetAdapter = adapter; + return this; + } + + /// + /// Отправить в указанный чат через адаптер по ID. + /// + public MessageBuilder ToChat(string chatId, string adapterId) + { + _targetChatId = chatId; + _targetAdapter = _ctx.AdapterFactory.Resolve(adapterId); + return this; + } + + /// + /// Отправить в указанный чат. + /// + public MessageBuilder ToChat(string chatId) + { + _targetChatId = chatId; + return this; + } + /// /// Установить опции для конкретного адаптера. Ключ адаптера определяется адаптером (напр., "telegram"). /// @@ -32,13 +98,6 @@ public sealed class MessageBuilder return this; } - /// ID чата куда отрпавить сообщение. - public MessageBuilder ChatId(string chatId) - { - _chatId = chatId; - return this; - } - /// Текст сообщения. public MessageBuilder Text(string text, MessageFormat format = MessageFormat.Plain) { @@ -47,6 +106,71 @@ public sealed class MessageBuilder return this; } + /// Стиль сообщения. + public MessageBuilder Style(MessageStyle style) + { + _style = style; + return this; + } + + /// Тема сообщения (для форумов). + public MessageBuilder Topic(string topic) + { + _topic = topic; + return this; + } + + /// Запланировать отправку на определенную дату. + public MessageBuilder Schedule(DateTime scheduleDate) + { + _scheduleDate = scheduleDate; + return this; + } + + /// Отключить предпросмотр ссылок. + public MessageBuilder DisableWebPagePreview() + { + _disableWebPagePreview = true; + return this; + } + + /// Отключить уведомление. + public MessageBuilder DisableNotification() + { + _disableNotification = true; + return this; + } + + /// Защитить содержимое от пересылки. + public MessageBuilder ProtectContent() + { + _protectContent = true; + return this; + } + + /// Ответить на сообщение. + public MessageBuilder ReplyTo(string messageId, bool quote = true, string? quoteTitle = null) + { + _replyToMessageId = messageId; + _quoteReply = quote; + _quoteTitle = quoteTitle; + return this; + } + + /// Ответить на текущее сообщение. + public MessageBuilder ReplyToCurrent(bool quote = true, string? quoteTitle = null) + { + if (_ctx.Update is not null) + { + // Предполагаем, что Update содержит ID текущего сообщения + // Это может потребовать расширения UpdateContext + _replyToMessageId = _ctx.Update.MessageId; + _quoteReply = quote; + _quoteTitle = quoteTitle; + } + return this; + } + /// Добавить inline‑кнопку. public MessageBuilder Inline(string label, string value) { @@ -132,7 +256,104 @@ public sealed class MessageBuilder /// Удалить сообщение. public async Task DeleteAsync(string messageId, CancellationToken ct = default) { - await _ctx.DeleteAsync(this._chatId ?? _ctx.Update.Chat.Id, messageId, ct); + await _ctx.Adapter.DeleteAsync(GetEffectiveChatId(), messageId, ct); + } + + /// Удалить несколько сообщений. + public async Task DeleteMultipleAsync(IEnumerable messageIds, CancellationToken ct = default) + { + return await _ctx.Adapter.DeleteMultipleAsync(GetEffectiveChatId(), messageIds, ct); + } + + /// Редактировать только текст сообщения. + public async Task EditTextAsync(string messageId, string newText, + MessageFormat? format = null, CancellationToken ct = default) + { + return await _ctx.Adapter.EditTextAsync( + GetEffectiveChatId(), + messageId, + newText, + format ?? _format, + ct); + } + + /// Редактировать только кнопки сообщения. + public async Task EditButtonsAsync(string messageId, + IEnumerable>? inlineButtons = null, + CancellationToken ct = default) + { + return await _ctx.Adapter.EditButtonsAsync( + GetEffectiveChatId(), + messageId, + inlineButtons ?? _inline, + ct); + } + + /// Закрепить сообщение. + public async Task PinAsync(string messageId, bool disableNotification = false, + CancellationToken ct = default) + { + return await _ctx.Adapter.PinMessageAsync( + GetEffectiveChatId(), + messageId, + disableNotification, + ct); + } + + /// Открепить сообщение. + public async Task UnpinAsync(string messageId, CancellationToken ct = default) + { + return await _ctx.Adapter.UnpinMessageAsync( + GetEffectiveChatId(), + messageId, + ct); + } + + /// Получить информацию о сообщении. + public async Task GetInfoAsync(string messageId, CancellationToken ct = default) + { + return await _ctx.Adapter.GetMessageInfoAsync( + GetEffectiveChatId(), + messageId, + ct); + } + + /// Переслать сообщение. + public async Task ForwardAsync(string fromChatId, string messageId, string toChatId, + bool disableNotification = false, CancellationToken ct = default) + { + return await _ctx.Adapter.ForwardMessageAsync( + fromChatId, + messageId, + toChatId ?? GetEffectiveChatId(), + disableNotification, + ct); + } + + /// Копировать сообщение. + public async Task CopyAsync(string fromChatId, string messageId, string toChatId, + string? caption = null, MessageFormat? captionFormat = null, + bool disableNotification = false, CancellationToken ct = default) + { + return await _ctx.Adapter.CopyMessageAsync( + fromChatId, + messageId, + toChatId ?? GetEffectiveChatId(), + caption, + captionFormat, + disableNotification, + ct); + } + + private string GetEffectiveChatId() + { + return _targetChatId ?? _ctx.Update.Chat.Id; + } + + private IMessengerAdapter GetEffectiveAdapter() + { + return _targetAdapter ?? _ctx.Adapter + ?? throw new InvalidOperationException("No adapter specified for sending message"); } /// Отправить собранное сообщение. @@ -142,27 +363,43 @@ public sealed class MessageBuilder /// Редактировать сообщение. public async Task SendAsync(string messageId, CancellationToken ct = default) { + _messageId = string.IsNullOrWhiteSpace(messageId) ? null : messageId; string? outMessageId = null; List>? reply = null; if (_disableReplyKeyboard) reply = new(); else if (_reply.Any()) reply = _reply; + // Определяем целевой адаптер + var adapter = GetEffectiveAdapter(); + + // Определяем целевой чат + var targetChatId = GetEffectiveChatId(); + // Текст if (!string.IsNullOrWhiteSpace(_text)) { var req = new SendRequest { - ChatId = _chatId ?? _ctx.Update.Chat.Id, + ChatId = targetChatId, Text = _text, TextFormat = _format, Inline = _inline, Reply = reply, - MessageId = string.IsNullOrWhiteSpace(messageId) ? null : messageId, + MessageId = _messageId, + ReplyToMessageId = _replyToMessageId, + QuoteReply = _quoteReply, + QuoteTitle = _quoteTitle, + DisableWebPagePreview = _disableWebPagePreview, + DisableNotification = _disableNotification, + ProtectContent = _protectContent, + Style = _style, + ScheduleDate = _scheduleDate, + Topic = _topic, AdapterOptions = _adapterOptions }; - outMessageId = await _ctx.SendAsync(req, ct); + outMessageId = await adapter.SendAsync(req, ct); } // Файлы @@ -170,16 +407,25 @@ public sealed class MessageBuilder { var req = new SendRequest { - ChatId = _chatId ?? _ctx.Update.Chat.Id, + ChatId = targetChatId, File = file, Caption = caption, CaptionFormat = captionFormat, Inline = _inline, Reply = reply, + ReplyToMessageId = _replyToMessageId, + QuoteReply = _quoteReply, + QuoteTitle = _quoteTitle, + DisableWebPagePreview = _disableWebPagePreview, + DisableNotification = _disableNotification, + ProtectContent = _protectContent, + Style = _style, + ScheduleDate = _scheduleDate, + Topic = _topic, AdapterOptions = _adapterOptions }; - var res = await _ctx.SendAsync(req, ct); + var res = await adapter.SendAsync(req, ct); // сохранить первый возвращённый id сообщения if (outMessageId is null && res is not null) outMessageId = res; } @@ -187,22 +433,58 @@ public sealed class MessageBuilder // Альбом if (_album.Count > 0) { - var builder = _ctx.Albums; + // Для альбома нужен PageContext с правильным адаптером + var albumCtx = CreateAlbumContext(_ctx, adapter); + var builder = adapter.CreateAlbumBuilder(albumCtx); foreach (var (file, caption, captionFormat) in _album) builder.Add(file, caption, captionFormat); await builder.SendAsync(ct); } - _text = null; - _files.Clear(); - _album.Clear(); - - _adapterOptions = null; - _chatId = null; - - _reply.Clear(); - _inline.Clear(); + Reset(); return outMessageId; } -} + + private PageContext CreateAlbumContext(PageContext originalCtx, IMessengerAdapter adapter) + { + // Создаем новый контекст для альбома с указанным адаптером + return new PageContext + { + SessionKey = originalCtx.SessionKey, + Update = originalCtx.Update, + StateStorage = originalCtx.StateStorage, + Navigation = originalCtx.Navigation, + AdapterFactory = originalCtx.AdapterFactory, + Adapter = adapter, + }; + } + + /// + /// Сбросить состояние билдера для повторного использования. + /// + public MessageBuilder Reset() + { + _text = null; + _files.Clear(); + _album.Clear(); + _adapterOptions = null; + _targetAdapter = null; + _targetChatId = null; + _replyToMessageId = null; + _quoteReply = true; + _quoteTitle = null; + _disableWebPagePreview = false; + _disableNotification = false; + _protectContent = false; + _style = null; + _scheduleDate = null; + _topic = null; + _messageId = null; + _reply.Clear(); + _inline.Clear(); + _disableReplyKeyboard = false; + + return this; + } +} \ No newline at end of file diff --git a/BotPages.Core/Messaging/MessageBuilderAdapterExtensions.cs b/BotPages.Core/Messaging/MessageBuilderAdapterExtensions.cs new file mode 100644 index 0000000..4ea7030 --- /dev/null +++ b/BotPages.Core/Messaging/MessageBuilderAdapterExtensions.cs @@ -0,0 +1,55 @@ +using BotPages.Core.Abstractions; + +namespace BotPages.Core.Messaging; + +/// +/// Расширения для MessageBuilder для работы с разными адаптерами. +/// +public static class MessageBuilderAdapterExtensions +{ + /// + /// Отправить сообщение через конкретный адаптер. + /// + public static MessageBuilder WithAdapter(this MessageBuilder builder, IMessengerAdapter adapter) + { + builder.WithAdapter(adapter); + return builder; + } + + /// + /// Отправить сообщение через адаптер по типу и ID экземпляра. + /// + public static MessageBuilder WithAdapter(this MessageBuilder builder, string messengerType, string instanceId) + { + // Получаем фабрику адаптеров через сервис локатор или контекст + // В реальной реализации нужно будет передать фабрику адаптеров + return builder; + } + + /// + /// Отправить сообщение через адаптер по полному ключу. + /// + public static MessageBuilder WithAdapter(this MessageBuilder builder, string fullMessengerKey) + { + // В реальной реализации нужно будет передать фабрику адаптеров + return builder; + } + + /// + /// Отправить сообщение в указанный чат с указанным адаптером. + /// + public static MessageBuilder ToChat(this MessageBuilder builder, string chatId, IMessengerAdapter adapter) + { + return builder.ToChat(chatId, adapter); + } + + /// + /// Отправить сообщение в чат в другом адаптере того же типа. + /// + public static MessageBuilder ToChatInOtherInstance(this MessageBuilder builder, string chatId, string instanceId) + { + // Получаем тип текущего адаптера и используем другой экземпляр + // В реальной реализации нужно будет передать фабрику адаптеров + return builder; + } +} \ No newline at end of file diff --git a/BotPages.Core/Middleware/LoggingMiddleware.cs b/BotPages.Core/Middleware/LoggingMiddleware.cs index f56cf5f..a01cb03 100644 --- a/BotPages.Core/Middleware/LoggingMiddleware.cs +++ b/BotPages.Core/Middleware/LoggingMiddleware.cs @@ -17,7 +17,7 @@ public sealed class LoggingMiddleware : IPageMiddleware public async Task InvokeAsync(PageContext ctx, Func next, CancellationToken ct) { // Логируем базовую информацию - _logger.Log(LogLevel.Info, $"Update from {ctx.Update.MessengerType} | Chat: {ctx.Update.Chat.Id} | User: {ctx.Update.User.Id}"); + _logger.Log(LogLevel.Info, $"Update from {ctx.Update.AdapterId} | Chat: {ctx.Update.Chat.Id} | User: {ctx.Update.User.Id}"); // Логируем текст, кнопки, файлы if (ctx.Update.Text is not null) diff --git a/BotPages.Core/Pages/Page.cs b/BotPages.Core/Pages/Page.cs index 82f4c46..cf279dd 100644 --- a/BotPages.Core/Pages/Page.cs +++ b/BotPages.Core/Pages/Page.cs @@ -10,19 +10,34 @@ public abstract class Page { /// Вход на страницу. public virtual Task OnEnter(PageContext ctx, CancellationToken ct) => Task.CompletedTask; + /// Общий обработчик обновлений. public virtual Task OnUpdate(PageContext ctx, UpdateContext update, CancellationToken ct) => Task.CompletedTask; + /// Обработка текста. public virtual Task OnText(PageContext ctx, string text, CancellationToken ct) => Task.CompletedTask; + /// Обработка файлов. public virtual Task OnFile(PageContext ctx, List files, CancellationToken ct) => Task.CompletedTask; + /// Обработка кнопки. public virtual Task OnButton(PageContext ctx, string payload, CancellationToken ct) => Task.CompletedTask; + + /// Обработка редактирования сообщения. + public virtual Task OnEdit(PageContext ctx, EditInfo editInfo, CancellationToken ct) => Task.CompletedTask; + + /// Обработка удаления сообщения. + public virtual Task OnDelete(PageContext ctx, DeleteInfo deleteInfo, CancellationToken ct) => Task.CompletedTask; + + /// Обработка закрепления сообщения. + public virtual Task OnPin(PageContext ctx, PinInfo pinInfo, CancellationToken ct) => Task.CompletedTask; + /// Выход со страницы. public virtual Task OnLeave(PageContext ctx, CancellationToken ct) => Task.CompletedTask; + /// Таймаут бездействия. public virtual Task OnTimeout(PageContext ctx, TimeSpan timeout, CancellationToken ct) => Task.CompletedTask; + /// Обработка ошибок. public virtual Task OnError(PageContext ctx, Exception ex, CancellationToken ct) => Task.CompletedTask; - } \ No newline at end of file diff --git a/BotPages.Telegram/BotPagesAppExtension.cs b/BotPages.Telegram/BotPagesAppExtension.cs index 2f9cb98..1c738a7 100644 --- a/BotPages.Telegram/BotPagesAppExtension.cs +++ b/BotPages.Telegram/BotPagesAppExtension.cs @@ -1,4 +1,5 @@ using BotPages.Core; +using System; namespace BotPages.Telegram; @@ -8,25 +9,28 @@ namespace BotPages.Telegram; public static class BotPagesAppExtension { /// - /// Добавление адаптера для телеграмм в + /// Добавление адаптера для Telegram. /// - /// - /// - /// - /// - 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 = "") + /// Если адаптер с таким ID уже существует. + public static BotPagesApp AddTelegramAdapter(this BotPagesApp app, string token, string adapterId, TelegramOptions? options = null) { + if (app.HasAdapter(adapterId)) + { + throw new ArgumentException($"Adapter with ID '{adapterId}' already exists", nameof(adapterId)); + } + var telegram = new TelegramAdapter(app.Logger, token, options); - - if (!string.IsNullOrWhiteSpace(messengerType)) telegram.MessengerType = messengerType; - - app.AddAdapter(telegram.MessengerType, telegram); + app.AddAdapter(adapterId, telegram); return app; } -} + + /// + /// Добавить Telegram бота с автоматическим ID. + /// + public static BotPagesApp AddTelegramAdapter(this BotPagesApp app, string token, TelegramOptions? options = null) + { + var telegram = new TelegramAdapter(app.Logger, token, options); + app.AddAdapter(telegram); + return app; + } +} \ No newline at end of file diff --git a/BotPages.Telegram/MessageBuilderExtensions.cs b/BotPages.Telegram/MessageBuilderExtensions.cs deleted file mode 100644 index 0df0629..0000000 --- a/BotPages.Telegram/MessageBuilderExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using BotPages.Core.Messaging; - -namespace BotPages.Telegram; - -/// -/// `MessageBuilder` Telegram. -/// `TelegramOptions` -/// . -/// -public static class MessageBuilderExtensions -{ - /// - /// Telegram ( ). - /// - public static MessageBuilder WithTelegramOptions(this MessageBuilder builder, TelegramOptions options) - { - return builder.WithAdapterOption(TelegramAdapter.AdapterType, options); - } -} diff --git a/BotPages.Telegram/TelegramAdapter.cs b/BotPages.Telegram/TelegramAdapter.cs index 6f65992..ea5ece3 100644 --- a/BotPages.Telegram/TelegramAdapter.cs +++ b/BotPages.Telegram/TelegramAdapter.cs @@ -16,15 +16,12 @@ using Telegram.Bot.Types.ReplyMarkups; namespace BotPages.Telegram; - /// /// Адаптер для Telegram на базе Telegram.Bot. /// Реализует отправку текста, кнопок, файлов, альбомов и прогресса. /// -public sealed class TelegramAdapter : IMessengerAdapterSetup +public sealed class TelegramAdapter : MessengerAdapterBase { - internal static readonly string AdapterType = typeof(TelegramAdapter).FullName; - private readonly ILogger _logger; private TelegramBotClient? _client; private string _token; @@ -45,29 +42,35 @@ public sealed class TelegramAdapter : IMessengerAdapterSetup _logger = logger; _token = token; _options = options ?? new TelegramOptions(); + + // Устанавливаем имя для отображения + DisplayName = $"Telegram Bot"; } + /// Тип адаптера. + public override string AdapterType => "Telegram"; + /// - ///Идентификатор мессенджера / адаптера + /// Идентификатор мессенджера / адаптера /// - public string MessengerType { get; set; } = "Telegram: " + Guid.NewGuid().ToString(); + public string MessengerType => "Telegram"; /// /// Доступные возможности адаптера. /// - public Capabilities Capabilities => _capabilities; + public override Capabilities Capabilities => _capabilities; /// /// Запустить polling для приема обновлений от Telegram. /// - public async Task StartAdapterAsync(Func onUpdate, List commands, CancellationToken ct) + public override async Task StartAdapterAsync(Func onUpdate, List commands, CancellationToken ct) { _client = new TelegramBotClient(_token); _client.StartReceiving( updateHandler: async (_, update, ct2) => { - var mapped = TelegramUpdateMapper.Map(MessengerType, update, _client); + var mapped = TelegramUpdateMapper.Map(this, update, _client); if (mapped is not null) await onUpdate(mapped); if (update.CallbackQuery is not null) @@ -78,18 +81,18 @@ public sealed class TelegramAdapter : IMessengerAdapterSetup errorHandler: async (_, ex, ct2) => { - _logger.Log(LogLevel.Warn, $"{MessengerType} error.", ex); + _logger.Log(LogLevel.Warn, $"{AdapterType} ({AdapterId}) error.", ex); await Task.CompletedTask; }, 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}"); + DisplayName = $"Telegram: @{me.Username}"; + _logger.Log(LogLevel.Info, $"{AdapterType} ({AdapterId}) started: {DisplayName}"); return; } @@ -98,7 +101,7 @@ public sealed class TelegramAdapter : IMessengerAdapterSetup /// Универсальный внутренний метод отправки — определяет, нужно ли отправлять текст или файл по параметрам. /// Возвращает id сообщения (или null). /// - public async Task SendAsync(SendRequest req, CancellationToken ct) + public override async Task SendAsync(SendRequest req, CancellationToken ct) { if (_client is null) { @@ -112,33 +115,80 @@ public sealed class TelegramAdapter : IMessengerAdapterSetup { telegramOptions = opt; } - var disableNotification = !telegramOptions.NotifyOnSend; + var disableNotification = !telegramOptions.NotifyOnSend || req.DisableNotification; // Build markup var inlineMarkup = BuildInlineMarkup(req.Inline); ReplyMarkup? markup = BuildReplyMarkup(req.Reply); + // Ответ на сообщение + int? replyToMessageId = null; + if (!string.IsNullOrEmpty(req.ReplyToMessageId) && int.TryParse(req.ReplyToMessageId, out var replyId)) + { + replyToMessageId = replyId; + } + // Файлы: сейчас поддерживается один файл через SendRequest.File. // При необходимости для нескольких файлов следует использовать альбомы (CreateAlbumBuilder) if (req.File is not null) { if (inlineMarkup is not null) markup = inlineMarkup; - var sent = await SendFileAsync(req.File, req.ChatId, markup, disableNotification, req.Caption, req.CaptionFormat, ct); + var sent = await SendFileAsync(req.File, req.ChatId, markup, disableNotification, + req.Caption, req.CaptionFormat, replyToMessageId, req.ProtectContent, ct); return sent?.MessageId.ToString(); } // Текст if (!string.IsNullOrWhiteSpace(req.Text)) { - return await SendTextAsync(req, inlineMarkup, markup, disableNotification, ct); + return await SendTextAsync(req, inlineMarkup, markup, disableNotification, + replyToMessageId, req.DisableWebPagePreview, req.ProtectContent, ct); } return null; } /// - public Task DeleteAsync(string chatId, string messageId, CancellationToken ct = default) + public override Task DeleteAsync(string chatId, string messageId, CancellationToken ct = default) + { + if (_client is null) + { + _logger.Log(LogLevel.Critical, $"{MessengerType} client is not initialized."); + return Task.CompletedTask; + } + + return _client.DeleteMessage(chatId, Convert.ToInt32(messageId), ct); + } + + /// + public override async Task DeleteMultipleAsync(string chatId, IEnumerable messageIds, CancellationToken ct = default) + { + if (_client is null) + { + _logger.Log(LogLevel.Critical, $"{MessengerType} client is not initialized."); + return false; + } + + try + { + // Telegram не поддерживает массовое удаление, удаляем по одному + foreach (var messageId in messageIds) + { + await DeleteAsync(chatId, messageId, ct); + } + return true; + } + catch (Exception ex) + { + _logger.Log(LogLevel.Warn, $"Failed to delete multiple messages: {ex.Message}"); + return false; + } + } + + /// + public override async Task EditTextAsync(string chatId, string messageId, string text, + MessageFormat? format = null, CancellationToken ct = default) { if (_client is null) { @@ -146,7 +196,171 @@ public sealed class TelegramAdapter : IMessengerAdapterSetup return null; } - return _client.DeleteMessage(chatId, Convert.ToInt32(messageId), ct); + try + { + var parseMode = GetParseMode(format); + await _client.EditMessageText( + chatId: long.Parse(chatId), + messageId: int.Parse(messageId), + text: text, + parseMode: parseMode, + cancellationToken: ct + ); + return messageId; + } + catch (Exception ex) + { + _logger.Log(LogLevel.Warn, $"Failed to edit text: {ex.Message}"); + return null; + } + } + + /// + public override async Task EditButtonsAsync(string chatId, string messageId, + IEnumerable>? inlineButtons = null, CancellationToken ct = default) + { + if (_client is null) + { + _logger.Log(LogLevel.Critical, $"{MessengerType} client is not initialized."); + return null; + } + + try + { + var inlineMarkup = BuildInlineMarkup(inlineButtons); + await _client.EditMessageReplyMarkup( + chatId: long.Parse(chatId), + messageId: int.Parse(messageId), + replyMarkup: inlineMarkup, + cancellationToken: ct + ); + return messageId; + } + catch (Exception ex) + { + _logger.Log(LogLevel.Warn, $"Failed to edit buttons: {ex.Message}"); + return null; + } + } + + /// + public override async Task PinMessageAsync(string chatId, string messageId, bool disableNotification = false, + CancellationToken ct = default) + { + if (_client is null) + { + _logger.Log(LogLevel.Critical, $"{MessengerType} client is not initialized."); + return false; + } + + try + { + await _client.PinChatMessage( + chatId: long.Parse(chatId), + messageId: int.Parse(messageId), + disableNotification: disableNotification, + cancellationToken: ct + ); + return true; + } + catch (Exception ex) + { + _logger.Log(LogLevel.Warn, $"Failed to pin message: {ex.Message}"); + return false; + } + } + + /// + public override async Task UnpinMessageAsync(string chatId, string messageId, CancellationToken ct = default) + { + if (_client is null) + { + _logger.Log(LogLevel.Critical, $"{MessengerType} client is not initialized."); + return false; + } + + try + { + await _client.UnpinChatMessage( + chatId: long.Parse(chatId), + messageId: int.Parse(messageId), + cancellationToken: ct + ); + return true; + } + catch (Exception ex) + { + _logger.Log(LogLevel.Warn, $"Failed to unpin message: {ex.Message}"); + return false; + } + } + + /// + public override async Task GetMessageInfoAsync(string chatId, string messageId, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + /// + public override async Task ForwardMessageAsync(string fromChatId, string messageId, string toChatId, + bool disableNotification = false, CancellationToken ct = default) + { + if (_client is null) + { + _logger.Log(LogLevel.Critical, $"{MessengerType} client is not initialized."); + return null; + } + + try + { + var message = await _client.ForwardMessage( + chatId: long.Parse(toChatId), + fromChatId: long.Parse(fromChatId), + messageId: int.Parse(messageId), + disableNotification: disableNotification, + cancellationToken: ct + ); + + return message.MessageId.ToString(); + } + catch (Exception ex) + { + _logger.Log(LogLevel.Warn, $"Failed to forward message: {ex.Message}"); + return null; + } + } + + /// + public override async Task CopyMessageAsync(string fromChatId, string messageId, string toChatId, + string? caption = null, MessageFormat? captionFormat = null, + bool disableNotification = false, CancellationToken ct = default) + { + if (_client is null) + { + _logger.Log(LogLevel.Critical, $"{MessengerType} client is not initialized."); + return null; + } + + try + { + var parseMode = GetParseMode(captionFormat); + var message = await _client.CopyMessage( + chatId: long.Parse(toChatId), + fromChatId: long.Parse(fromChatId), + messageId: int.Parse(messageId), + caption: caption, + parseMode: parseMode, + disableNotification: disableNotification, + cancellationToken: ct + ); + + return message.Id.ToString(); + } + catch (Exception ex) + { + _logger.Log(LogLevel.Warn, $"Failed to copy message: {ex.Message}"); + return null; + } } // --- Helpers --- @@ -209,21 +423,64 @@ public sealed class TelegramAdapter : IMessengerAdapterSetup } } - private async Task SendFileAsync(FileDescriptor file, string chatId, ReplyMarkup? markup, bool disableNotification, string? caption, MessageFormat? captionFormat, CancellationToken ct) + private async Task SendFileAsync(FileDescriptor file, string chatId, ReplyMarkup? markup, + bool disableNotification, string? caption, MessageFormat? captionFormat, int? replyToMessageId, + bool protectContent, CancellationToken ct) { var inputFile = await CreateInputFileAsync(file, ct); var parseMode = GetParseMode(captionFormat); return file.Kind switch { - FileKind.Photo => await _client.SendPhoto(long.Parse(chatId), inputFile, caption ?? "", parseMode, replyMarkup: markup, disableNotification: disableNotification, cancellationToken: ct), - FileKind.Video => await _client.SendVideo(long.Parse(chatId), inputFile, caption: caption ?? "", parseMode, replyMarkup: markup, disableNotification: disableNotification, cancellationToken: ct), - FileKind.Audio => await _client.SendAudio(long.Parse(chatId), inputFile, caption ?? "", parseMode, replyMarkup: markup, disableNotification: disableNotification, cancellationToken: ct), - _ => await _client.SendDocument(long.Parse(chatId), inputFile, caption ?? "", parseMode, replyMarkup: markup, disableNotification: disableNotification, cancellationToken: ct), + FileKind.Photo => await _client.SendPhoto( + long.Parse(chatId), + inputFile, + caption ?? "", + parseMode, + replyParameters: replyToMessageId, + replyMarkup: markup, + disableNotification: disableNotification, + protectContent: protectContent, + cancellationToken: ct), + + FileKind.Video => await _client.SendVideo( + long.Parse(chatId), + inputFile, + caption: caption ?? "", + parseMode, + replyParameters: replyToMessageId, + replyMarkup: markup, + disableNotification: disableNotification, + protectContent: protectContent, + cancellationToken: ct), + + FileKind.Audio => await _client.SendAudio( + long.Parse(chatId), + inputFile, + caption ?? "", + parseMode, + replyParameters: replyToMessageId, + replyMarkup: markup, + disableNotification: disableNotification, + protectContent: protectContent, + cancellationToken: ct), + + _ => await _client.SendDocument( + long.Parse(chatId), + inputFile, + caption ?? "", + parseMode, + replyParameters: replyToMessageId, + replyMarkup: markup, + disableNotification: disableNotification, + protectContent: protectContent, + cancellationToken: ct), }; } - private async Task SendTextAsync(SendRequest req, InlineKeyboardMarkup? inlineMarkup, ReplyMarkup? markup, bool disableNotification, CancellationToken ct) + private async Task SendTextAsync(SendRequest req, InlineKeyboardMarkup? inlineMarkup, + ReplyMarkup? markup, bool disableNotification, int? replyToMessageId, + bool disableWebPagePreview, bool protectContent, CancellationToken ct) { var format = req.TextFormat ?? MessageFormat.Plain; var parseMode = GetParseMode(format); @@ -242,6 +499,10 @@ public sealed class TelegramAdapter : IMessengerAdapterSetup chatId: long.Parse(req.ChatId), text: text, parseMode: parseMode, + linkPreviewOptions: new() + { + IsDisabled = disableWebPagePreview, + }, replyMarkup: inlineMarkup, cancellationToken: ct ); @@ -256,8 +517,14 @@ public sealed class TelegramAdapter : IMessengerAdapterSetup chatId: long.Parse(req.ChatId), text: text, parseMode: parseMode, + linkPreviewOptions: new() + { + IsDisabled = disableWebPagePreview, + }, replyMarkup: markup, + replyParameters: replyToMessageId, disableNotification: disableNotification, + protectContent: protectContent, cancellationToken: ct ); @@ -266,8 +533,8 @@ public sealed class TelegramAdapter : IMessengerAdapterSetup } /// - public IAlbumBuilder CreateAlbumBuilder(PageContext ctx) => new TelegramAlbumBuilder(this, ctx, _logger, _client); + public override IAlbumBuilder CreateAlbumBuilder(PageContext ctx) => new TelegramAlbumBuilder(this, ctx, _logger, _client); /// - public Task OnLeaveAsync(PageContext ctx, CancellationToken ct) => Task.CompletedTask; + public override Task OnLeaveAsync(PageContext ctx, CancellationToken ct) => Task.CompletedTask; } \ No newline at end of file diff --git a/BotPages.Telegram/TelegramUpdateMapper.cs b/BotPages.Telegram/TelegramUpdateMapper.cs index 8c5728e..c17ae65 100644 --- a/BotPages.Telegram/TelegramUpdateMapper.cs +++ b/BotPages.Telegram/TelegramUpdateMapper.cs @@ -19,29 +19,56 @@ public static class TelegramUpdateMapper /// /// Маппинг Telegram Update в UpdateContext BotPages. /// - public static UpdateContext Map(string MessengerType, Update update, TelegramBotClient client) + public static UpdateContext? Map(TelegramAdapter adapter, Update update, TelegramBotClient client) { - var chat = update.Message?.Chat ?? update.CallbackQuery?.Message?.Chat; - var user = update.Message?.From ?? update.CallbackQuery?.From; + var chat = update.Message?.Chat ?? update.CallbackQuery?.Message?.Chat ?? update.EditedMessage?.Chat; + var user = update.Message?.From ?? update.CallbackQuery?.From ?? update.EditedMessage?.From; + + if (chat == null || user == null) + return null; var userContext = new UserContext { - Id = user?.Id.ToString() ?? "unknown", - DisplayName = user?.Username, + Id = user.Id.ToString(), + DisplayName = user.Username, }; var chatContext = new ChatContext { - Id = chat?.Id.ToString() ?? "unknown", - Title = chat?.Title, + Id = chat.Id.ToString(), + Title = chat.Title, }; string? text = null; UpdateKind kind = UpdateKind.None; var files = new List(); + string? messageId = null; + string? replyToMessageId = null; + EditInfo? editInfo = null; + DeleteInfo? deleteInfo = null; + PinInfo? pinInfo = null; + // Обработка разных типов обновлений if (update.Message is { } msg) { + messageId = msg.MessageId.ToString(); + + if (msg.ReplyToMessage != null) + { + kind |= UpdateKind.Reply; + replyToMessageId = msg.ReplyToMessage.MessageId.ToString(); + } + + if (msg.PinnedMessage != null) + { + kind |= UpdateKind.Pin; + pinInfo = new PinInfo + { + MessageId = msg.PinnedMessage.MessageId.ToString(), + PinDate = msg.Date, + NotificationDisabled = false // Telegram не передает эту информацию + }; + } if (msg.Text is not null) { @@ -112,28 +139,66 @@ public static class TelegramUpdateMapper kind |= UpdateKind.File; } } - - if (update.CallbackQuery is { } cb) + else if (update.EditedMessage is { } editedMsg) { + messageId = editedMsg.MessageId.ToString(); + kind |= UpdateKind.Edit; + text = editedMsg.Text; + + editInfo = new EditInfo + { + NewText = editedMsg.Text, + EditDate = editedMsg.EditDate ?? DateTime.UtcNow + }; + } + else if (update.CallbackQuery is { } cb) + { + messageId = cb.Message?.MessageId.ToString(); kind |= UpdateKind.Button; text = cb.Data; } + else if (update.ChannelPost is { } channelPost) + { + messageId = channelPost.MessageId.ToString(); + if (channelPost.Text is not null) + { + text = channelPost.Text; + kind |= UpdateKind.Text; + } + } + else if (update.EditedChannelPost is { } editedChannelPost) + { + messageId = editedChannelPost.MessageId.ToString(); + kind |= UpdateKind.Edit; + text = editedChannelPost.Text; + + editInfo = new EditInfo + { + NewText = editedChannelPost.Text, + EditDate = editedChannelPost.EditDate ?? DateTime.UtcNow + }; + } return new UpdateContext { - MessengerType = MessengerType, + AdapterId = adapter.AdapterId, + AdapterType = adapter.AdapterType, User = userContext, Chat = chatContext, Text = text, Kind = kind, - Files = files + MessageId = messageId, + ReplyToMessageId = replyToMessageId, + Files = files, + EditInfo = editInfo, + DeleteInfo = deleteInfo, + PinInfo = pinInfo }; } private static Func> GetStreamAsync(TelegramBotClient client, string fileId) { - Func> getStreamAsync = async _ => { var file = await client.GetFile(fileId); diff --git a/Demo/Program.cs b/Demo/Program.cs index fb95aae..b54d23f 100644 --- a/Demo/Program.cs +++ b/Demo/Program.cs @@ -51,8 +51,16 @@ namespace Demo .AddTelegramAdapter(token, "Telegram") .Build(cts.Token); - Console.ReadKey(); - cts.Cancel(); + + Console.WriteLine("Bot is running. Press Ctrl+C to stop."); + Console.CancelKeyPress += (sender, e) => + { + Console.WriteLine("Cancel key pressed"); + cts.Cancel(); + e.Cancel = true; + }; + + app.Wait(); } } }