Добавлены новые методы отправки сообщений

This commit is contained in:
2026-02-06 04:38:06 +03:00
parent 69ff3cf7d4
commit cd280369bc
25 changed files with 1525 additions and 219 deletions

View File

@@ -1,4 +1,5 @@
using BotPages.Core;
using System;
namespace BotPages.Telegram;
@@ -8,25 +9,28 @@ namespace BotPages.Telegram;
public static class BotPagesAppExtension
{
/// <summary>
/// Добавление адаптера для телеграмм в <see cref="BotPages.Core.Abstractions.IMessengerAdapterFactory"/>
/// Добавление адаптера для Telegram.
/// </summary>
/// <param name="app"></param>
/// <param name="token"></param>
/// <param name="messengerType"></param>
/// <returns></returns>
public static BotPagesApp AddTelegramAdapter(this BotPagesApp app, string token, string messengerType = "")
=> app.AddTelegramAdapter(token, null, messengerType);
/// <summary>
/// Добавление адаптера для телеграмм с опциями.
/// </summary>
public static BotPagesApp AddTelegramAdapter(this BotPagesApp app, string token, TelegramOptions? options, string messengerType = "")
/// <exception cref="ArgumentException">Если адаптер с таким ID уже существует.</exception>
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;
}
}
/// <summary>
/// Добавить Telegram бота с автоматическим ID.
/// </summary>
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;
}
}

View File

@@ -1,19 +0,0 @@
using BotPages.Core.Messaging;
namespace BotPages.Telegram;
/// <summary>
/// Ðàñøèðåíèÿ äëÿ `MessageBuilder` ñïåöèôè÷íûå äëÿ Telegram.
/// Ïîçâîëÿþò óäîáíî çàäàòü `TelegramOptions` äëÿ êîíêðåòíîãî ñîîáùåíèÿ
/// áåç èçìåíåíèÿ ñàìîãî áèëäåðà.
/// </summary>
public static class MessageBuilderExtensions
{
/// <summary>
/// Óñòàíîâèòü îïöèè Telegram äëÿ êîíêðåòíîãî ñîîáùåíèÿ (ïåðåîïðåäåëÿþò îïöèè àäàïòåðà).
/// </summary>
public static MessageBuilder WithTelegramOptions(this MessageBuilder builder, TelegramOptions options)
{
return builder.WithAdapterOption(TelegramAdapter.AdapterType, options);
}
}

View File

@@ -16,15 +16,12 @@ using Telegram.Bot.Types.ReplyMarkups;
namespace BotPages.Telegram;
/// <summary>
/// Адаптер для Telegram на базе Telegram.Bot.
/// Реализует отправку текста, кнопок, файлов, альбомов и прогресса.
/// </summary>
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";
}
/// <summary>Тип адаптера.</summary>
public override string AdapterType => "Telegram";
/// <summary>
///Идентификатор мессенджера / адаптера
/// Идентификатор мессенджера / адаптера
/// </summary>
public string MessengerType { get; set; } = "Telegram: " + Guid.NewGuid().ToString();
public string MessengerType => "Telegram";
/// <summary>
/// Доступные возможности адаптера.
/// </summary>
public Capabilities Capabilities => _capabilities;
public override Capabilities Capabilities => _capabilities;
/// <summary>
/// Запустить polling для приема обновлений от Telegram.
/// </summary>
public async Task StartAdapterAsync(Func<UpdateContext, Task> onUpdate, List<BotPages.Core.Routing.Command> commands, CancellationToken ct)
public override async Task StartAdapterAsync(Func<UpdateContext, Task> onUpdate, List<BotPages.Core.Routing.Command> 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).
/// </summary>
public async Task<string?> SendAsync(SendRequest req, CancellationToken ct)
public override async Task<string?> 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;
}
/// <inheritdoc/>
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);
}
/// <inheritdoc/>
public override async Task<bool> DeleteMultipleAsync(string chatId, IEnumerable<string> 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;
}
}
/// <inheritdoc/>
public override async Task<string?> 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;
}
}
/// <inheritdoc/>
public override async Task<string?> EditButtonsAsync(string chatId, string messageId,
IEnumerable<IEnumerable<InlineButton>>? 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;
}
}
/// <inheritdoc/>
public override async Task<bool> 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;
}
}
/// <inheritdoc/>
public override async Task<bool> 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;
}
}
/// <inheritdoc/>
public override async Task<MessageInfo?> GetMessageInfoAsync(string chatId, string messageId, CancellationToken ct = default)
{
throw new NotImplementedException();
}
/// <inheritdoc/>
public override async Task<string?> 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;
}
}
/// <inheritdoc/>
public override async Task<string?> 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<Message?> SendFileAsync(FileDescriptor file, string chatId, ReplyMarkup? markup, bool disableNotification, string? caption, MessageFormat? captionFormat, CancellationToken ct)
private async Task<Message?> 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<string?> SendTextAsync(SendRequest req, InlineKeyboardMarkup? inlineMarkup, ReplyMarkup? markup, bool disableNotification, CancellationToken ct)
private async Task<string?> 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
}
/// <inheritdoc />
public IAlbumBuilder CreateAlbumBuilder(PageContext ctx) => new TelegramAlbumBuilder(this, ctx, _logger, _client);
public override IAlbumBuilder CreateAlbumBuilder(PageContext ctx) => new TelegramAlbumBuilder(this, ctx, _logger, _client);
/// <inheritdoc />
public Task OnLeaveAsync(PageContext ctx, CancellationToken ct) => Task.CompletedTask;
public override Task OnLeaveAsync(PageContext ctx, CancellationToken ct) => Task.CompletedTask;
}

View File

@@ -19,29 +19,56 @@ public static class TelegramUpdateMapper
/// <summary>
/// Маппинг Telegram Update в UpdateContext BotPages.
/// </summary>
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<FileDescriptor>();
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<CancellationToken, Task<Stream>> GetStreamAsync(TelegramBotClient client, string fileId)
{
Func<CancellationToken, Task<Stream>> getStreamAsync = async _ =>
{
var file = await client.GetFile(fileId);