OpenAI gpt-4o-mini-tts (female voice nova by default; TTS_MODEL/TTS_VOICE) renders OGG/Opus, sent with sendVoice in order with rendered output. Up to 4500 characters per call, split into 1500-character parts at paragraph/sentence/word boundaries; all parts are synthesized before any is sent, and API errors come back to the agent as text with the key hidden. On whenever OPENAI_API_KEY is set.
227 lines
11 KiB
C#
227 lines
11 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Telegram.Bot;
|
|
using Telegram.Bot.Exceptions;
|
|
using Telegram.Bot.Types;
|
|
using Telegram.Bot.Types.Enums;
|
|
using Telegram.Bot.Types.ReplyMarkups;
|
|
|
|
namespace Volcanic.AgenticCore.Telegram.Rendering;
|
|
|
|
/// <summary>
|
|
/// Sends and edits chat messages without ever throwing (except on cancellation): a failed call is logged and reported
|
|
/// as null/false. HTML Telegram rejects is resent as plain text; "message is not modified" counts as success.
|
|
/// </summary>
|
|
public sealed class TelegramSender(ITelegramBotClient bot, ILogger<TelegramSender> logger)
|
|
{
|
|
public ITelegramBotClient Bot => bot;
|
|
|
|
/// <summary>Send HTML; returns the message id, or null when sending failed.</summary>
|
|
public async Task<int?> SendHtmlAsync(long chatId, string html, string plain, ReplyMarkup? replyMarkup = null, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
var message = await bot.SendMessage(chatId, html, parseMode: ParseMode.Html, replyMarkup: replyMarkup,
|
|
linkPreviewOptions: NoPreview, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
return message.Id;
|
|
}
|
|
catch (ApiRequestException ex) when (IsBadMarkup(ex))
|
|
{
|
|
return await SendTextAsync(chatId, plain, replyMarkup, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
logger.LogWarning(ex, "Sending a message to chat {ChatId} failed", chatId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>Send plain text; returns the message id, or null when sending failed.</summary>
|
|
public async Task<int?> SendTextAsync(long chatId, string text, ReplyMarkup? replyMarkup = null, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
var message = await bot.SendMessage(chatId, Truncate(text), replyMarkup: replyMarkup,
|
|
linkPreviewOptions: NoPreview, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
return message.Id;
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
logger.LogWarning(ex, "Sending a message to chat {ChatId} failed", chatId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>Replace a message's text with HTML (plain fallback). Returns false when the edit failed.</summary>
|
|
public async Task<bool> EditHtmlAsync(long chatId, int messageId, string html, string plain, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
await bot.EditMessageText(chatId, messageId, html, parseMode: ParseMode.Html,
|
|
linkPreviewOptions: NoPreview, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
return true;
|
|
}
|
|
catch (ApiRequestException ex) when (IsNotModified(ex))
|
|
{
|
|
return true;
|
|
}
|
|
catch (ApiRequestException ex) when (IsBadMarkup(ex))
|
|
{
|
|
try
|
|
{
|
|
await bot.EditMessageText(chatId, messageId, Truncate(plain), linkPreviewOptions: NoPreview,
|
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
return true;
|
|
}
|
|
catch (ApiRequestException inner) when (IsNotModified(inner))
|
|
{
|
|
return true;
|
|
}
|
|
catch (Exception inner) when (inner is not OperationCanceledException)
|
|
{
|
|
logger.LogWarning(inner, "Editing message {MessageId} in chat {ChatId} failed", messageId, chatId);
|
|
return false;
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
logger.LogWarning(ex, "Editing message {MessageId} in chat {ChatId} failed", messageId, chatId);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Delete a message; a message that is already gone counts as deleted.</summary>
|
|
public async Task DeleteAsync(long chatId, int messageId, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
await bot.DeleteMessage(chatId, messageId, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
logger.LogWarning(ex, "Deleting message {MessageId} in chat {ChatId} failed", messageId, chatId);
|
|
}
|
|
}
|
|
|
|
/// <summary>Result of <see cref="SendFileAsync"/>: the message id, or the error; <c>PhotoRejected</c> when Telegram refused a photo with a 400.</summary>
|
|
public sealed record FileSendResult(int? MessageId, string? Error, bool PhotoRejected);
|
|
|
|
/// <summary>Delays before retrying an upload after a network failure (1 s, 3 s, like replies). Settable for tests.</summary>
|
|
internal IReadOnlyList<TimeSpan> FileRetryDelays { get; set; } = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)];
|
|
|
|
/// <summary>
|
|
/// Upload a file as a photo or a document with a plain-text caption. Never throws except on cancellation: a network
|
|
/// failure is retried, an API error is returned (a 400 on a photo sets <c>PhotoRejected</c>).
|
|
/// </summary>
|
|
public async Task<FileSendResult> SendFileAsync(long chatId, string path, bool asPhoto, string? caption, CancellationToken cancellationToken = default)
|
|
{
|
|
for (var attempt = 0; ; attempt++)
|
|
{
|
|
try
|
|
{
|
|
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true);
|
|
var file = InputFile.FromStream(stream, Path.GetFileName(path));
|
|
var message = asPhoto
|
|
? await bot.SendPhoto(chatId, file, caption: caption, cancellationToken: cancellationToken).ConfigureAwait(false)
|
|
: await bot.SendDocument(chatId, file, caption: caption, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
return new FileSendResult(message.Id, null, false);
|
|
}
|
|
catch (ApiRequestException ex)
|
|
{
|
|
logger.LogWarning(ex, "Uploading {File} to chat {ChatId} failed", Path.GetFileName(path), chatId);
|
|
return new FileSendResult(null, ex.Message, asPhoto && ex.ErrorCode == 400);
|
|
}
|
|
catch (RequestException ex) when (attempt < FileRetryDelays.Count)
|
|
{
|
|
logger.LogDebug(ex, "Uploading {File} to chat {ChatId} failed; retrying", Path.GetFileName(path), chatId);
|
|
await Task.Delay(FileRetryDelays[attempt], cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
logger.LogWarning(ex, "Uploading {File} to chat {ChatId} failed", Path.GetFileName(path), chatId);
|
|
return new FileSendResult(null, ex.Message, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Upload OGG/Opus audio as a voice note. Never throws except on cancellation: a network failure is retried like
|
|
/// <see cref="SendFileAsync"/>, an API error is returned.
|
|
/// </summary>
|
|
public async Task<FileSendResult> SendVoiceAsync(long chatId, byte[] ogg, string fileName, CancellationToken cancellationToken = default)
|
|
{
|
|
for (var attempt = 0; ; attempt++)
|
|
{
|
|
try
|
|
{
|
|
using var stream = new MemoryStream(ogg, writable: false);
|
|
var message = await bot.SendVoice(chatId, InputFile.FromStream(stream, fileName), cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
return new FileSendResult(message.Id, null, false);
|
|
}
|
|
catch (ApiRequestException ex)
|
|
{
|
|
logger.LogWarning(ex, "Sending a voice note to chat {ChatId} failed", chatId);
|
|
return new FileSendResult(null, ex.Message, false);
|
|
}
|
|
catch (RequestException ex) when (attempt < FileRetryDelays.Count)
|
|
{
|
|
logger.LogDebug(ex, "Sending a voice note to chat {ChatId} failed; retrying", chatId);
|
|
await Task.Delay(FileRetryDelays[attempt], cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
logger.LogWarning(ex, "Sending a voice note to chat {ChatId} failed", chatId);
|
|
return new FileSendResult(null, ex.Message, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task SendTypingAsync(long chatId, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
await bot.SendChatAction(chatId, ChatAction.Typing, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
logger.LogDebug(ex, "Sending the typing action to chat {ChatId} failed", chatId);
|
|
}
|
|
}
|
|
|
|
/// <summary>React to a message with <paramref name="emoji"/>. Best-effort: a failure is logged at Debug and swallowed.</summary>
|
|
public async Task ReactAsync(long chatId, int messageId, string emoji, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
await bot.SetMessageReaction(chatId, messageId, [new ReactionTypeEmoji { Emoji = emoji }], cancellationToken: cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
logger.LogDebug(ex, "Reacting to message {MessageId} in chat {ChatId} failed", messageId, chatId);
|
|
}
|
|
}
|
|
|
|
public async Task AnswerCallbackAsync(string callbackQueryId, string? text = null, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
await bot.AnswerCallbackQuery(callbackQueryId, text, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
logger.LogDebug(ex, "Answering callback query {Id} failed", callbackQueryId);
|
|
}
|
|
}
|
|
|
|
private static readonly LinkPreviewOptions NoPreview = new() { IsDisabled = true };
|
|
|
|
private static string Truncate(string text) =>
|
|
text.Length <= TelegramFormatter.MessageLimit ? text : text[..(TelegramFormatter.MessageLimit - 1)] + "…";
|
|
|
|
private static bool IsBadMarkup(ApiRequestException ex) =>
|
|
ex.ErrorCode == 400 && ex.Message.Contains("parse entities", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static bool IsNotModified(ApiRequestException ex) =>
|
|
ex.ErrorCode == 400 && ex.Message.Contains("message is not modified", StringComparison.OrdinalIgnoreCase);
|
|
}
|