579 lines
19 KiB
C#
579 lines
19 KiB
C#
using BotPages.Core;
|
|
using BotPages.Core.Abstractions;
|
|
using BotPages.Core.Context;
|
|
using BotPages.Core.Logging;
|
|
using BotPages.Core.Messaging;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Telegram.Bot;
|
|
using Telegram.Bot.Types;
|
|
using Telegram.Bot.Types.Enums;
|
|
using Telegram.Bot.Types.ReplyMarkups;
|
|
|
|
namespace BotPages.Telegram;
|
|
|
|
/// <summary>
|
|
/// Адаптер для Telegram на базе Telegram.Bot.
|
|
/// Реализует отправку текста, кнопок, файлов, альбомов и прогресса.
|
|
/// </summary>
|
|
public sealed class TelegramAdapter : IMessengerAdapterSetup
|
|
{
|
|
private readonly ILogger _logger;
|
|
private TelegramBotClient? _client;
|
|
private string _token;
|
|
private readonly TelegramOptions _options;
|
|
private static Capabilities _capabilities = new()
|
|
{
|
|
SupportsInlineButtons = true,
|
|
SupportsReplyButtons = true,
|
|
SupportsAlbums = true,
|
|
SupportsFormattingMarkdown = true,
|
|
SupportsFormattingHtml = true,
|
|
MaxMessageLength = 4096,
|
|
};
|
|
|
|
/// <summary>Создать адаптер Telegram.</summary>
|
|
public TelegramAdapter(ILogger logger, string token, TelegramOptions? options = null)
|
|
{
|
|
_logger = logger;
|
|
_token = token;
|
|
_options = options ?? new TelegramOptions();
|
|
|
|
Id = Guid.NewGuid().ToString();
|
|
DisplayName = $"Telegram {Id}";
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public string Type => "Telegram";
|
|
|
|
/// <inheritdoc/>
|
|
public string Id { get; set; }
|
|
|
|
/// <summary>
|
|
/// Получение отображаемого имени бота (например, "Telegram: @mybot"). Доступно после запуска адаптера.
|
|
/// </summary>
|
|
public string DisplayName { get; private set; }
|
|
|
|
/// <inheritdoc/>
|
|
public void SetAdapterId(string adapterId) => Id = adapterId;
|
|
|
|
/// <summary>
|
|
/// Доступные возможности адаптера.
|
|
/// </summary>
|
|
public Capabilities Capabilities => _capabilities;
|
|
|
|
/// <summary>
|
|
/// Запустить polling для приема обновлений от Telegram.
|
|
/// </summary>
|
|
public 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(this, update, _client);
|
|
if (mapped is not null)
|
|
await onUpdate(mapped);
|
|
if (update.CallbackQuery is not null)
|
|
{
|
|
await _.AnswerCallbackQuery(update.CallbackQuery.Id);
|
|
}
|
|
},
|
|
|
|
errorHandler: async (_, ex, ct2) =>
|
|
{
|
|
_logger.Log(LogLevel.Warn, $"{Type} ({Id}) 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();
|
|
DisplayName = $"Telegram: @{me.Username}";
|
|
_logger.Log(LogLevel.Info, $"{Type} ({Id}) started: {DisplayName}");
|
|
|
|
return;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Универсальный внутренний метод отправки — определяет, нужно ли отправлять текст или файл по параметрам.
|
|
/// Возвращает id сообщения (или null).
|
|
/// </summary>
|
|
public async Task<string?> SendAsync(SendRequest req, CancellationToken ct)
|
|
{
|
|
if (_client is null)
|
|
{
|
|
_logger.Log(LogLevel.Critical, $"{Type} client is not initialized.");
|
|
return null;
|
|
}
|
|
|
|
// Определите параметры адаптера (TelegramOptions) из папки или используйте параметры адаптера по умолчанию.
|
|
TelegramOptions telegramOptions = _options;
|
|
if (req.AdapterOptions is AdapterOptionsBag bag && bag.TryGet(Type, out TelegramOptions? opt) && opt is not null)
|
|
{
|
|
telegramOptions = opt;
|
|
}
|
|
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, replyToMessageId, req.ProtectContent, ct);
|
|
return sent?.MessageId.ToString();
|
|
}
|
|
|
|
// Текст
|
|
if (!string.IsNullOrWhiteSpace(req.Text))
|
|
{
|
|
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)
|
|
{
|
|
if (_client is null)
|
|
{
|
|
_logger.Log(LogLevel.Critical, $"{Type} client is not initialized.");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
return _client.DeleteMessage(chatId, Convert.ToInt32(messageId), ct);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task<bool> DeleteMultipleAsync(string chatId, IEnumerable<string> messageIds, CancellationToken ct = default)
|
|
{
|
|
if (_client is null)
|
|
{
|
|
_logger.Log(LogLevel.Critical, $"{Type} 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 async Task<string?> EditTextAsync(string chatId, string messageId, string text,
|
|
MessageFormat? format = null, CancellationToken ct = default)
|
|
{
|
|
if (_client is null)
|
|
{
|
|
_logger.Log(LogLevel.Critical, $"{Type} client is not initialized.");
|
|
return null;
|
|
}
|
|
|
|
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 async Task<string?> EditButtonsAsync(string chatId, string messageId,
|
|
IEnumerable<IEnumerable<InlineButton>>? inlineButtons = null, CancellationToken ct = default)
|
|
{
|
|
if (_client is null)
|
|
{
|
|
_logger.Log(LogLevel.Critical, $"{Type} 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 async Task<bool> PinMessageAsync(string chatId, string messageId, bool disableNotification = false,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (_client is null)
|
|
{
|
|
_logger.Log(LogLevel.Critical, $"{Type} 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 async Task<bool> UnpinMessageAsync(string chatId, string messageId, CancellationToken ct = default)
|
|
{
|
|
if (_client is null)
|
|
{
|
|
_logger.Log(LogLevel.Critical, $"{Type} 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 async Task<MessageInfo?> GetMessageInfoAsync(string chatId, string messageId, CancellationToken ct = default)
|
|
{
|
|
throw new NotImplementedException();
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task<string?> ForwardMessageAsync(string fromChatId, string messageId, string toChatId,
|
|
bool disableNotification = false, CancellationToken ct = default)
|
|
{
|
|
if (_client is null)
|
|
{
|
|
_logger.Log(LogLevel.Critical, $"{Type} 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 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, $"{Type} 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 ---
|
|
|
|
private InlineKeyboardMarkup? BuildInlineMarkup(IEnumerable<IEnumerable<InlineButton>>? inline)
|
|
{
|
|
if (inline is null || !inline.Any()) return null;
|
|
return new InlineKeyboardMarkup(
|
|
inline.Select(row => row.Select(b => new InlineKeyboardButton(b.Label, b.Value)
|
|
{
|
|
Style = b.Style switch
|
|
{
|
|
ButtonStyle.Default => null,
|
|
ButtonStyle.Info => KeyboardButtonStyle.Primary,
|
|
ButtonStyle.Error => KeyboardButtonStyle.Danger,
|
|
ButtonStyle.Success => KeyboardButtonStyle.Success,
|
|
_ => null,
|
|
}
|
|
}).ToArray()).ToArray()
|
|
);
|
|
}
|
|
|
|
private ReplyMarkup? BuildReplyMarkup(IEnumerable<IEnumerable<ReplyButton>>? reply)
|
|
{
|
|
if (reply is null) return null;
|
|
|
|
if (reply.Any())
|
|
{
|
|
return new ReplyKeyboardMarkup(reply.Select(row => row.Select(b => new KeyboardButton(b.Label)
|
|
{
|
|
Style = b.Style switch
|
|
{
|
|
ButtonStyle.Default => null,
|
|
ButtonStyle.Info => KeyboardButtonStyle.Primary,
|
|
ButtonStyle.Error => KeyboardButtonStyle.Danger,
|
|
ButtonStyle.Success => KeyboardButtonStyle.Success,
|
|
_ => null,
|
|
}
|
|
}).ToArray()).ToArray())
|
|
{
|
|
ResizeKeyboard = true
|
|
};
|
|
}
|
|
else
|
|
{
|
|
return new ReplyKeyboardRemove();
|
|
}
|
|
}
|
|
|
|
private static ParseMode GetParseMode(MessageFormat? format)
|
|
{
|
|
return format switch
|
|
{
|
|
MessageFormat.Html => ParseMode.Html,
|
|
MessageFormat.Markdown => ParseMode.MarkdownV2,
|
|
MessageFormat.Plain => ParseMode.None,
|
|
_ => ParseMode.None,
|
|
};
|
|
}
|
|
|
|
private async Task<InputFile> CreateInputFileAsync(FileDescriptor file, CancellationToken ct)
|
|
{
|
|
Stream? stream = null;
|
|
if (file.GetStreamAsync is not null)
|
|
{
|
|
stream = await file.GetStreamAsync(ct);
|
|
if (stream is not null) stream.Position = 0;
|
|
}
|
|
|
|
if (stream is not null && stream != Stream.Null)
|
|
{
|
|
return new InputFileStream(stream, file.Name);
|
|
}
|
|
else if (file.Id.StartsWith("http://") || file.Id.StartsWith("https://"))
|
|
{
|
|
return new InputFileUrl(file.Id);
|
|
}
|
|
else
|
|
{
|
|
return new InputFileId(file.Id);
|
|
}
|
|
}
|
|
|
|
private async Task<Message?> SendFileAsync(FileDescriptor file, string chatId, ReplyMarkup? markup,
|
|
bool disableNotification, string? caption, MessageFormat? captionFormat, int? replyToMessageId,
|
|
bool protectContent, CancellationToken ct)
|
|
{
|
|
if (_client is null)
|
|
{
|
|
_logger.Log(LogLevel.Critical, $"{Type} client is not initialized.");
|
|
return null;
|
|
}
|
|
|
|
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,
|
|
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, int? replyToMessageId,
|
|
bool disableWebPagePreview, bool protectContent, CancellationToken ct)
|
|
{
|
|
if (_client is null)
|
|
{
|
|
_logger.Log(LogLevel.Critical, $"{Type} client is not initialized.");
|
|
return null;
|
|
}
|
|
|
|
var format = req.TextFormat ?? MessageFormat.Plain;
|
|
var parseMode = GetParseMode(format);
|
|
|
|
var text = req.Text!;
|
|
if (text.Length > Capabilities.MaxMessageLength)
|
|
{
|
|
_logger.Log(LogLevel.Warn, $"Message too long ({text.Length}). Truncated to {Capabilities.MaxMessageLength}.");
|
|
text = text.Substring(0, Capabilities.MaxMessageLength);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(req.MessageId))
|
|
{
|
|
await _client.EditMessageText(
|
|
messageId: int.Parse(req.MessageId),
|
|
chatId: long.Parse(req.ChatId),
|
|
text: text,
|
|
parseMode: parseMode,
|
|
linkPreviewOptions: new()
|
|
{
|
|
IsDisabled = disableWebPagePreview,
|
|
},
|
|
replyMarkup: inlineMarkup,
|
|
cancellationToken: ct
|
|
);
|
|
|
|
return req.MessageId;
|
|
}
|
|
else
|
|
{
|
|
if (inlineMarkup is not null) markup = inlineMarkup;
|
|
|
|
var message = await _client.SendMessage(
|
|
chatId: long.Parse(req.ChatId),
|
|
text: text,
|
|
parseMode: parseMode,
|
|
linkPreviewOptions: new()
|
|
{
|
|
IsDisabled = disableWebPagePreview,
|
|
},
|
|
replyMarkup: markup,
|
|
replyParameters: replyToMessageId,
|
|
disableNotification: disableNotification,
|
|
protectContent: protectContent,
|
|
cancellationToken: ct
|
|
);
|
|
|
|
return message.MessageId.ToString();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IAlbumBuilder CreateAlbumBuilder(PageContext ctx) => new TelegramAlbumBuilder(this, ctx, _logger, _client);
|
|
|
|
/// <inheritdoc />
|
|
public Task OnLeaveAsync(PageContext ctx, CancellationToken ct) => Task.CompletedTask;
|
|
} |