From ac9e9b11c07a7ef8ca5e13346f15d17dabea9a43 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Tue, 21 Apr 2026 17:04:13 +0800 Subject: [PATCH 01/15] Add Telegram channel adapter --- Directory.Packages.props | 3 +- aevatar.slnx | 2 + .../ConversationReference.Telegram.Partial.cs | 48 ++ .../Aevatar.GAgents.Channel.Telegram.csproj | 19 + .../ITelegramApiClient.cs | 81 ++ .../ITelegramAttachmentContentResolver.cs | 29 + .../TelegramBotApiClient.cs | 165 ++++ .../TelegramChannelAdapter.cs | 707 ++++++++++++++++++ .../TelegramChannelAdapterOptions.cs | 29 + .../TelegramMessageComposer.cs | 120 +++ .../TelegramNativePayload.cs | 17 + .../ChannelAbstractionsSurfaceTests.cs | 20 + ...atar.GAgents.Channel.Telegram.Tests.csproj | 27 + .../TelegramAdapterTestSupport.cs | 332 ++++++++ .../TelegramChannelAdapterConformanceTests.cs | 24 + .../TelegramChannelAdapterModeTests.cs | 140 ++++ .../TelegramMessageComposerTests.cs | 56 ++ .../ChannelAdapterConformanceTests.cs | 2 +- 18 files changed, 1819 insertions(+), 2 deletions(-) create mode 100644 agents/Aevatar.GAgents.Channel.Abstractions/Transport/ConversationReference.Telegram.Partial.cs create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/Aevatar.GAgents.Channel.Telegram.csproj create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramApiClient.cs create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramAttachmentContentResolver.cs create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramBotApiClient.cs create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapterOptions.cs create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramMessageComposer.cs create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramNativePayload.cs create mode 100644 test/Aevatar.GAgents.Channel.Telegram.Tests/Aevatar.GAgents.Channel.Telegram.Tests.csproj create mode 100644 test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs create mode 100644 test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterConformanceTests.cs create mode 100644 test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs create mode 100644 test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index d2638c9ea..c8ef74561 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -157,6 +157,7 @@ + @@ -348,4 +349,4 @@ - + \ No newline at end of file diff --git a/aevatar.slnx b/aevatar.slnx index 601ca721b..f50dd5e6b 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -12,6 +12,7 @@ + @@ -169,6 +170,7 @@ + diff --git a/agents/Aevatar.GAgents.Channel.Abstractions/Transport/ConversationReference.Telegram.Partial.cs b/agents/Aevatar.GAgents.Channel.Abstractions/Transport/ConversationReference.Telegram.Partial.cs new file mode 100644 index 000000000..8f9381875 --- /dev/null +++ b/agents/Aevatar.GAgents.Channel.Abstractions/Transport/ConversationReference.Telegram.Partial.cs @@ -0,0 +1,48 @@ +namespace Aevatar.GAgents.Channel.Abstractions; + +/// +/// Telegram-specific conversation helpers that keep canonical keys deterministic across private chats, groups, and channels. +/// +public sealed partial class ConversationReference +{ + private static readonly ChannelId TelegramChannelId = ChannelId.From("telegram"); + + /// + /// Creates one Telegram private-chat conversation reference. + /// + public static ConversationReference TelegramPrivate(BotInstanceId bot, string chatId) => + Create( + TelegramChannelId, + bot, + ConversationScope.DirectMessage, + partition: null, + "private", + NormalizeTelegramSegment(chatId, nameof(chatId))); + + /// + /// Creates one Telegram group or supergroup conversation reference. + /// + public static ConversationReference TelegramGroup(BotInstanceId bot, string chatId, bool isSupergroup = false) => + Create( + TelegramChannelId, + bot, + ConversationScope.Group, + partition: null, + isSupergroup ? "supergroup" : "group", + NormalizeTelegramSegment(chatId, nameof(chatId))); + + /// + /// Creates one Telegram channel-post conversation reference. + /// + public static ConversationReference TelegramChannel(BotInstanceId bot, string chatId) => + Create( + TelegramChannelId, + bot, + ConversationScope.Channel, + partition: null, + "channel", + NormalizeTelegramSegment(chatId, nameof(chatId))); + + private static string NormalizeTelegramSegment(string value, string paramName) => + NormalizeSegment(value, paramName); +} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/Aevatar.GAgents.Channel.Telegram.csproj b/agents/channels/Aevatar.GAgents.Channel.Telegram/Aevatar.GAgents.Channel.Telegram.csproj new file mode 100644 index 000000000..ac1ebbc92 --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/Aevatar.GAgents.Channel.Telegram.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.Channel.Telegram + Aevatar.GAgents.Channel.Telegram + true + $(WarningsAsErrors);CS1591 + + + + + + + + + + diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramApiClient.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramApiClient.cs new file mode 100644 index 000000000..e98d62196 --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramApiClient.cs @@ -0,0 +1,81 @@ +using Telegram.Bot.Types; +using Telegram.Bot.Types.ReplyMarkups; + +namespace Aevatar.GAgents.Channel.Telegram; + +/// +/// Narrow Telegram Bot API surface used by the adapter. +/// +public interface ITelegramApiClient +{ + /// + /// Pulls updates through Telegram long polling. + /// + Task> GetUpdatesAsync( + string botToken, + int? offset, + int timeoutSeconds, + CancellationToken ct); + + /// + /// Sends one text message. + /// + Task SendMessageAsync( + string botToken, + long chatId, + string text, + InlineKeyboardMarkup? replyMarkup, + int? replyToMessageId, + CancellationToken ct); + + /// + /// Sends one photo attachment with optional caption and inline keyboard. + /// + Task SendPhotoAsync( + string botToken, + long chatId, + TelegramAttachmentContent attachment, + string? caption, + InlineKeyboardMarkup? replyMarkup, + int? replyToMessageId, + CancellationToken ct); + + /// + /// Sends one document attachment with optional caption and inline keyboard. + /// + Task SendDocumentAsync( + string botToken, + long chatId, + TelegramAttachmentContent attachment, + string? caption, + InlineKeyboardMarkup? replyMarkup, + int? replyToMessageId, + CancellationToken ct); + + /// + /// Updates one previously sent text message. + /// + Task EditMessageTextAsync( + string botToken, + long chatId, + int messageId, + string text, + InlineKeyboardMarkup? replyMarkup, + CancellationToken ct); + + /// + /// Deletes one previously sent message. + /// + Task DeleteMessageAsync( + string botToken, + long chatId, + int messageId, + CancellationToken ct); +} + +/// +/// Represents one adapter-owned Telegram message identifier. +/// +/// The Telegram chat id. +/// The Telegram message id. +public readonly record struct TelegramSentActivity(long ChatId, int MessageId); diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramAttachmentContentResolver.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramAttachmentContentResolver.cs new file mode 100644 index 000000000..aa15511ef --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramAttachmentContentResolver.cs @@ -0,0 +1,29 @@ +using Aevatar.GAgents.Channel.Abstractions; + +namespace Aevatar.GAgents.Channel.Telegram; + +/// +/// Resolves one channel attachment reference into content Telegram can upload. +/// +public interface ITelegramAttachmentContentResolver +{ + /// + /// Resolves one attachment for upload. + /// + Task ResolveAsync(AttachmentRef attachment, CancellationToken ct); +} + +/// +/// Carries one resolved Telegram attachment upload. +/// +/// The filename exposed to Telegram. +/// The MIME content type. +/// The stream to upload. The caller owns disposal. +/// An optional public URL Telegram can fetch directly. +/// An optional existing Telegram file id. +public sealed record TelegramAttachmentContent( + string FileName, + string ContentType, + Stream? Content = null, + string? ExternalUrl = null, + string? TelegramFileId = null); diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramBotApiClient.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramBotApiClient.cs new file mode 100644 index 000000000..0728ac3c2 --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramBotApiClient.cs @@ -0,0 +1,165 @@ +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using Telegram.Bot.Types.ReplyMarkups; + +namespace Aevatar.GAgents.Channel.Telegram; + +/// +/// Default Telegram Bot API client backed by the official Telegram.Bot SDK. +/// +public sealed class TelegramBotApiClient : ITelegramApiClient +{ + private readonly ITelegramBotClientFactory _clientFactory; + private static readonly UpdateType[] AllowedUpdates = [UpdateType.Message, UpdateType.ChannelPost, UpdateType.CallbackQuery]; + + /// + /// Creates the default API client. + /// + public TelegramBotApiClient(ITelegramBotClientFactory clientFactory) + { + _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); + } + + /// + public async Task> GetUpdatesAsync( + string botToken, + int? offset, + int timeoutSeconds, + CancellationToken ct) + { + var client = _clientFactory.Create(botToken); + var updates = await client.GetUpdates( + offset, + timeout: timeoutSeconds, + allowedUpdates: AllowedUpdates, + cancellationToken: ct); + return updates; + } + + /// + public async Task SendMessageAsync( + string botToken, + long chatId, + string text, + InlineKeyboardMarkup? replyMarkup, + int? replyToMessageId, + CancellationToken ct) + { + var client = _clientFactory.Create(botToken); + var message = await client.SendMessage( + chatId, + text, + replyParameters: replyToMessageId.HasValue ? new ReplyParameters { MessageId = replyToMessageId.Value } : null, + replyMarkup: replyMarkup, + cancellationToken: ct); + return new TelegramSentActivity(message.Chat.Id, message.MessageId); + } + + /// + public async Task SendPhotoAsync( + string botToken, + long chatId, + TelegramAttachmentContent attachment, + string? caption, + InlineKeyboardMarkup? replyMarkup, + int? replyToMessageId, + CancellationToken ct) + { + var client = _clientFactory.Create(botToken); + var message = await client.SendPhoto( + chatId, + CreateInputFile(attachment), + caption: caption, + parseMode: ParseMode.Html, + replyParameters: replyToMessageId.HasValue ? new ReplyParameters { MessageId = replyToMessageId.Value } : null, + replyMarkup: replyMarkup, + cancellationToken: ct); + return new TelegramSentActivity(message.Chat.Id, message.MessageId); + } + + /// + public async Task SendDocumentAsync( + string botToken, + long chatId, + TelegramAttachmentContent attachment, + string? caption, + InlineKeyboardMarkup? replyMarkup, + int? replyToMessageId, + CancellationToken ct) + { + var client = _clientFactory.Create(botToken); + var message = await client.SendDocument( + chatId, + CreateInputFile(attachment), + caption: caption, + parseMode: ParseMode.Html, + replyParameters: replyToMessageId.HasValue ? new ReplyParameters { MessageId = replyToMessageId.Value } : null, + replyMarkup: replyMarkup, + cancellationToken: ct); + return new TelegramSentActivity(message.Chat.Id, message.MessageId); + } + + /// + public async Task EditMessageTextAsync( + string botToken, + long chatId, + int messageId, + string text, + InlineKeyboardMarkup? replyMarkup, + CancellationToken ct) + { + var client = _clientFactory.Create(botToken); + var message = await client.EditMessageText( + chatId, + messageId, + text, + parseMode: ParseMode.Html, + replyMarkup: replyMarkup, + cancellationToken: ct); + return new TelegramSentActivity(chatId, message.MessageId); + } + + /// + public async Task DeleteMessageAsync( + string botToken, + long chatId, + int messageId, + CancellationToken ct) + { + var client = _clientFactory.Create(botToken); + await client.DeleteMessage(chatId, messageId, ct); + } + + private static InputFile CreateInputFile(TelegramAttachmentContent attachment) + { + if (!string.IsNullOrWhiteSpace(attachment.TelegramFileId)) + return attachment.TelegramFileId!; + if (!string.IsNullOrWhiteSpace(attachment.ExternalUrl)) + return attachment.ExternalUrl!; + if (attachment.Content is null) + throw new InvalidOperationException("Telegram attachment must provide content, external URL, or file id."); + + return InputFile.FromStream(attachment.Content, attachment.FileName); + } +} + +/// +/// Creates SDK clients for raw bot tokens. +/// +public interface ITelegramBotClientFactory +{ + /// + /// Creates one SDK client for the supplied bot token. + /// + ITelegramBotClient Create(string botToken); +} + +/// +/// Default client factory backed by . +/// +public sealed class TelegramBotClientFactory : ITelegramBotClientFactory +{ + /// + public ITelegramBotClient Create(string botToken) => new TelegramBotClient(botToken); +} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs new file mode 100644 index 000000000..65d87a7a1 --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs @@ -0,0 +1,707 @@ +using System.Text.Json; +using System.Threading.Channels; +using Aevatar.GAgents.Channel.Abstractions; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using ICredentialProvider = Aevatar.Foundation.Abstractions.Credentials.ICredentialProvider; + +namespace Aevatar.GAgents.Channel.Telegram; + +/// +/// Full Telegram channel adapter backed by the official Telegram.Bot SDK. +/// +public sealed class TelegramChannelAdapter : IChannelTransport, IChannelOutboundPort +{ + private readonly Channel _inbound = System.Threading.Channels.Channel.CreateBounded( + new BoundedChannelOptions(256) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait, + }); + private readonly ICredentialProvider _credentialProvider; + private readonly ITelegramApiClient _apiClient; + private readonly ITelegramAttachmentContentResolver? _attachmentResolver; + private readonly TelegramMessageComposer _composer; + private readonly ILogger _logger; + private readonly TelegramChannelAdapterOptions _options; + private ChannelTransportBinding? _binding; + private string? _botToken; + private bool _initialized; + private bool _receiving; + private bool _stopped; + private CancellationTokenSource? _receiveLoopCts; + private Task? _receiveLoop; + + /// + /// Creates one Telegram channel adapter. + /// + public TelegramChannelAdapter( + ICredentialProvider credentialProvider, + ITelegramApiClient apiClient, + ITelegramAttachmentContentResolver? attachmentResolver = null, + TelegramMessageComposer? composer = null, + ILogger? logger = null, + TelegramChannelAdapterOptions? options = null) + { + _credentialProvider = credentialProvider ?? throw new ArgumentNullException(nameof(credentialProvider)); + _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + _attachmentResolver = attachmentResolver; + _composer = composer ?? new TelegramMessageComposer(); + _logger = logger ?? NullLogger.Instance; + _options = options ?? new TelegramChannelAdapterOptions(); + Capabilities = new ChannelCapabilities + { + SupportsEphemeral = false, + SupportsEdit = true, + SupportsDelete = true, + SupportsThread = false, + Streaming = StreamingSupport.EditLoopRateLimited, + SupportsFiles = true, + MaxMessageLength = 4096, + SupportsActionButtons = true, + SupportsConfirmDialog = false, + SupportsModal = false, + SupportsMention = false, + SupportsTyping = false, + SupportsReactions = false, + RecommendedStreamDebounceMs = _options.RecommendedStreamDebounceMs, + Transport = _options.TransportMode, + }; + } + + /// + public ChannelId Channel { get; } = ChannelId.From("telegram"); + + /// + public TransportMode TransportMode => _options.TransportMode; + + /// + public ChannelCapabilities Capabilities { get; } + + /// + public ChannelReader InboundStream => _inbound.Reader; + + /// + /// Accepts one webhook payload, normalizes it into , and enqueues it on . + /// + public async Task AcceptWebhookAsync( + Stream payload, + string? secretToken = null, + ChannelTransportBinding? binding = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(payload); + var activeBinding = binding ?? _binding ?? throw new InvalidOperationException("Adapter has not been initialized."); + if (!ValidateWebhookSecret(secretToken, activeBinding)) + return null; + + using var document = await JsonDocument.ParseAsync(payload, cancellationToken: ct); + var update = document.Deserialize(JsonBotAPI.Options); + if (update is null) + return null; + + return await ProcessUpdateAsync(update, activeBinding, enqueue: true, ct); + } + + /// + /// Starts one streaming reply against the supplied Telegram conversation reference. + /// + public async Task BeginStreamingReplyAsync( + ConversationReference reference, + MessageContent initial, + CancellationToken ct) + { + var initialEmit = await SendAsync(reference, initial, ct); + if (!initialEmit.Success) + throw new InvalidOperationException($"Unable to start Telegram streaming reply: {initialEmit.ErrorCode}"); + + return new TelegramStreamingHandle( + this, + reference, + initialEmit.SentActivityId, + initial.Text, + _options.TimeProvider, + TimeSpan.FromMilliseconds(Math.Max(_options.RecommendedStreamDebounceMs, 0))); + } + + /// + public Task InitializeAsync(ChannelTransportBinding binding, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(binding); + if (_initialized) + throw new InvalidOperationException("Adapter has already been initialized."); + if (_receiving) + throw new InvalidOperationException("Adapter cannot initialize after receive startup."); + + _binding = binding.Clone(); + _initialized = true; + return Task.CompletedTask; + } + + /// + public Task StartReceivingAsync(CancellationToken ct) + { + EnsureInitialized(); + if (_receiving) + throw new InvalidOperationException("Adapter has already started receiving."); + + _receiving = true; + if (_options.TransportMode == TransportMode.LongPolling) + { + _receiveLoopCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _receiveLoop = Task.Run(() => RunLongPollingLoopAsync(_receiveLoopCts.Token)); + } + + return Task.CompletedTask; + } + + /// + public async Task StopReceivingAsync(CancellationToken ct) + { + if (_stopped) + return; + + _stopped = true; + _receiving = false; + if (_receiveLoopCts is not null) + { + await _receiveLoopCts.CancelAsync(); + _receiveLoopCts.Dispose(); + _receiveLoopCts = null; + } + + if (_receiveLoop is not null) + { + try + { + await _receiveLoop.WaitAsync(ct); + } + catch (OperationCanceledException) + { + } + + _receiveLoop = null; + } + + _inbound.Writer.TryComplete(); + } + + /// + public async Task SendAsync(ConversationReference to, MessageContent content, CancellationToken ct) + { + try + { + EnsureReady(); + var target = TelegramConversationTarget.Parse(to); + var context = new ComposeContext + { + Conversation = to.Clone(), + Capabilities = Capabilities, + }; + var payload = _composer.Compose(content, context); + var botToken = await ResolveBotTokenAsync(ct); + + TelegramSentActivity sent; + if (payload.Attachment is null) + { + sent = await _apiClient.SendMessageAsync( + botToken, + target.ChatId, + payload.Text, + payload.ReplyMarkup, + replyToMessageId: null, + ct); + } + else + { + var attachment = await ResolveAttachmentAsync(payload.Attachment, ct); + if (attachment is null) + { + return EmitResult.Failed( + "attachment_unavailable", + "Telegram attachment content could not be resolved.", + capability: payload.Capability); + } + + try + { + sent = payload.Attachment.Kind == AttachmentKind.Image + ? await _apiClient.SendPhotoAsync( + botToken, + target.ChatId, + attachment, + payload.Text, + payload.ReplyMarkup, + replyToMessageId: null, + ct) + : await _apiClient.SendDocumentAsync( + botToken, + target.ChatId, + attachment, + payload.Text, + payload.ReplyMarkup, + replyToMessageId: null, + ct); + } + finally + { + attachment.Content?.Dispose(); + } + } + + return EmitResult.Sent(TelegramConversationTarget.BuildActivityId(sent.ChatId, sent.MessageId), payload.Capability); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("credential", StringComparison.OrdinalIgnoreCase)) + { + return EmitResult.Failed("credential_resolution_failed", ex.Message); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Telegram send failed."); + return EmitResult.Failed("telegram_send_failed", SanitizeError(ex.Message)); + } + } + + /// + public async Task UpdateAsync( + ConversationReference to, + string activityId, + MessageContent content, + CancellationToken ct) + { + try + { + EnsureReady(); + var target = TelegramConversationTarget.Parse(to); + var adapterActivity = TelegramConversationTarget.ParseActivityId(activityId); + var payload = _composer.Compose(content, new ComposeContext + { + Conversation = to.Clone(), + Capabilities = Capabilities, + }); + if (payload.Attachment is not null) + { + return EmitResult.Failed( + "telegram_edit_text_only", + "Telegram edits are limited to text payloads in this adapter.", + capability: ComposeCapability.Degraded); + } + + var botToken = await ResolveBotTokenAsync(ct); + await _apiClient.EditMessageTextAsync( + botToken, + target.ChatId, + adapterActivity.MessageId, + payload.Text, + payload.ReplyMarkup, + ct); + return EmitResult.Sent(TelegramConversationTarget.BuildActivityId(target.ChatId, adapterActivity.MessageId), payload.Capability); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Telegram update failed."); + return EmitResult.Failed("telegram_update_failed", SanitizeError(ex.Message)); + } + } + + /// + public async Task DeleteAsync(ConversationReference to, string activityId, CancellationToken ct) + { + EnsureReady(); + var target = TelegramConversationTarget.Parse(to); + var adapterActivity = TelegramConversationTarget.ParseActivityId(activityId); + var botToken = await ResolveBotTokenAsync(ct); + await _apiClient.DeleteMessageAsync(botToken, target.ChatId, adapterActivity.MessageId, ct); + } + + /// + public async Task ContinueConversationAsync( + ConversationReference reference, + MessageContent content, + Aevatar.GAgents.Channel.Abstractions.AuthContext auth, + CancellationToken ct) + { + if (auth.Kind == PrincipalKind.OnBehalfOfUser) + { + return EmitResult.Failed( + "principal_unsupported", + "Telegram Bot API does not support delegated user sends.", + capability: ComposeCapability.Unsupported); + } + + return await SendAsync(reference, content, ct); + } + + private async Task RunLongPollingLoopAsync(CancellationToken ct) + { + var offset = 0; + while (!ct.IsCancellationRequested) + { + try + { + var updates = await _apiClient.GetUpdatesAsync( + await ResolveBotTokenAsync(ct), + offset <= 0 ? null : offset, + _options.LongPollingTimeoutSeconds, + ct); + + foreach (var update in updates.OrderBy(static update => update.Id)) + { + offset = Math.Max(offset, update.Id + 1); + if (_binding is null) + continue; + await ProcessUpdateAsync(update, _binding, enqueue: true, ct); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (TaskCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Telegram long polling iteration failed."); + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } + } + } + + private async Task ProcessUpdateAsync( + Update update, + ChannelTransportBinding binding, + bool enqueue, + CancellationToken ct) + { + var activity = NormalizeUpdate(update, binding); + if (activity is null) + return null; + + if (enqueue && _receiving) + await _inbound.Writer.WriteAsync(activity, ct); + + return activity; + } + + private ChatActivity? NormalizeUpdate(Update update, ChannelTransportBinding binding) + { + if (TryNormalizeMessage(update.Id, update.Message, binding, out var messageActivity)) + return messageActivity; + if (TryNormalizeMessage(update.Id, update.ChannelPost, binding, out var channelActivity)) + return channelActivity; + if (TryNormalizeMessage(update.Id, update.EditedMessage, binding, out var editedActivity)) + return editedActivity; + if (TryNormalizeMessage(update.Id, update.EditedChannelPost, binding, out var editedChannelActivity)) + return editedChannelActivity; + if (TryNormalizeCallback(update.Id, update.CallbackQuery, binding, out var callbackActivity)) + return callbackActivity; + + return null; + } + + private bool TryNormalizeMessage( + int updateId, + Message? message, + ChannelTransportBinding binding, + out ChatActivity? activity) + { + activity = null; + if (message is null) + return false; + if (message.From?.IsBot == true) + return false; + + var text = !string.IsNullOrWhiteSpace(message.Text) + ? message.Text + : message.Caption; + if (string.IsNullOrWhiteSpace(text)) + return false; + + var conversation = CreateConversation(binding.Bot.Bot, message.Chat); + var senderId = message.From is not null + ? message.From.Id.ToString() + : message.SenderChat?.Id.ToString() ?? message.Chat.Id.ToString(); + var senderName = message.From?.Username + ?? message.From?.FirstName + ?? message.SenderChat?.Title + ?? message.Chat.Title + ?? senderId; + activity = new ChatActivity + { + Id = $"telegram:update:{updateId}", + Type = ActivityType.Message, + ChannelId = Channel.Clone(), + Bot = binding.Bot.Bot.Clone(), + Conversation = conversation, + From = new ParticipantRef + { + CanonicalId = senderId, + DisplayName = senderName, + }, + Timestamp = Timestamp.FromDateTime(message.Date.ToUniversalTime()), + Content = new MessageContent + { + Text = text, + Disposition = MessageDisposition.Normal, + }, + ReplyToActivityId = message.ReplyToMessage is not null + ? TelegramConversationTarget.BuildActivityId(message.Chat.Id, message.ReplyToMessage.MessageId) + : string.Empty, + RawPayloadBlobRef = $"telegram://update/{updateId}", + }; + return true; + } + + private bool TryNormalizeCallback( + int updateId, + CallbackQuery? callback, + ChannelTransportBinding binding, + out ChatActivity? activity) + { + activity = null; + if (callback?.Message is null) + return false; + + var data = string.IsNullOrWhiteSpace(callback.Data) ? callback.GameShortName : callback.Data; + if (string.IsNullOrWhiteSpace(data)) + return false; + + var conversation = CreateConversation(binding.Bot.Bot, callback.Message.Chat); + activity = new ChatActivity + { + Id = $"telegram:update:{updateId}", + Type = ActivityType.CardAction, + ChannelId = Channel.Clone(), + Bot = binding.Bot.Bot.Clone(), + Conversation = conversation, + From = new ParticipantRef + { + CanonicalId = callback.From.Id.ToString(), + DisplayName = callback.From.Username ?? callback.From.FirstName ?? callback.From.Id.ToString(), + }, + Timestamp = Timestamp.FromDateTime(callback.Message.Date.ToUniversalTime()), + Content = new MessageContent + { + Text = data, + Disposition = MessageDisposition.Normal, + }, + ReplyToActivityId = TelegramConversationTarget.BuildActivityId(callback.Message.Chat.Id, callback.Message.MessageId), + RawPayloadBlobRef = $"telegram://update/{updateId}", + }; + return true; + } + + private static ConversationReference CreateConversation(BotInstanceId bot, Chat chat) => + chat.Type switch + { + ChatType.Private => ConversationReference.TelegramPrivate(bot, chat.Id.ToString()), + ChatType.Group => ConversationReference.TelegramGroup(bot, chat.Id.ToString()), + ChatType.Supergroup => ConversationReference.TelegramGroup(bot, chat.Id.ToString(), isSupergroup: true), + ChatType.Channel => ConversationReference.TelegramChannel(bot, chat.Id.ToString()), + _ => ConversationReference.TelegramPrivate(bot, chat.Id.ToString()), + }; + + private bool ValidateWebhookSecret(string? secretToken, ChannelTransportBinding binding) + { + if (string.IsNullOrWhiteSpace(binding.VerificationToken)) + return true; + if (string.Equals(secretToken, binding.VerificationToken, StringComparison.Ordinal)) + return true; + + _logger.LogWarning("Telegram webhook secret token mismatch."); + return false; + } + + private async Task ResolveBotTokenAsync(CancellationToken ct) + { + if (!string.IsNullOrWhiteSpace(_botToken)) + return _botToken; + if (_binding is null) + throw new InvalidOperationException("Adapter binding is unavailable."); + + _botToken = await _credentialProvider.ResolveBotCredentialAsync(_binding, ct) + ?? throw new InvalidOperationException("Telegram bot credential could not be resolved."); + return _botToken; + } + + private async Task ResolveAttachmentAsync(AttachmentRef attachment, CancellationToken ct) + { + if (_attachmentResolver is null) + return null; + + return await _attachmentResolver.ResolveAsync(attachment, ct); + } + + private void EnsureInitialized() + { + if (!_initialized || _binding is null) + throw new InvalidOperationException("Adapter must be initialized before startup."); + } + + private void EnsureReady() + { + EnsureInitialized(); + if (!_receiving) + throw new InvalidOperationException("Adapter must start receiving before outbound operations."); + } + + private static string SanitizeError(string message) => + string.IsNullOrWhiteSpace(message) ? "telegram_error" : message.Replace(Environment.NewLine, " ", StringComparison.Ordinal).Trim(); + + private readonly record struct TelegramConversationTarget(long ChatId, int MessageId) + { + public static TelegramConversationTarget Parse(ConversationReference reference) + { + ArgumentNullException.ThrowIfNull(reference); + if (!string.Equals(reference.Channel.Value, "telegram", StringComparison.Ordinal)) + throw new InvalidOperationException("Conversation reference does not target Telegram."); + + var segments = reference.CanonicalKey.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length < 3 || !long.TryParse(segments[^1], out var chatId)) + throw new InvalidOperationException("Telegram canonical key must end with the numeric chat id."); + return new TelegramConversationTarget(chatId, 0); + } + + public static TelegramConversationTarget ParseActivityId(string activityId) + { + ArgumentNullException.ThrowIfNull(activityId); + var parts = activityId.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 4 && + string.Equals(parts[0], "telegram", StringComparison.Ordinal) && + string.Equals(parts[1], "message", StringComparison.Ordinal) && + long.TryParse(parts[2], out var chatId) && + int.TryParse(parts[3], out var messageId)) + { + return new TelegramConversationTarget(chatId, messageId); + } + + if (int.TryParse(activityId, out messageId)) + return new TelegramConversationTarget(0, messageId); + + throw new InvalidOperationException("Telegram activity id is invalid."); + } + + public static string BuildActivityId(long chatId, int messageId) => $"telegram:message:{chatId}:{messageId}"; + } + + private sealed class TelegramStreamingHandle : StreamingHandle + { + private readonly TelegramChannelAdapter _adapter; + private readonly ConversationReference _reference; + private readonly string _activityId; + private readonly TimeProvider _timeProvider; + private readonly TimeSpan _debounce; + private readonly Dictionary _chunks = new(); + private string _currentText; + private long _maxSequenceSeen; + private bool _completed; + private CancellationTokenSource? _flushCts; + + public TelegramStreamingHandle( + TelegramChannelAdapter adapter, + ConversationReference reference, + string activityId, + string currentText, + TimeProvider timeProvider, + TimeSpan debounce) + { + _adapter = adapter; + _reference = reference; + _activityId = activityId; + _currentText = currentText; + _timeProvider = timeProvider; + _debounce = debounce; + } + + public override Task AppendAsync(StreamChunk chunk) + { + if (_completed) + return Task.CompletedTask; + if (chunk.SequenceNumber <= _maxSequenceSeen && _chunks.ContainsKey(chunk.SequenceNumber)) + return Task.CompletedTask; + + _maxSequenceSeen = Math.Max(_maxSequenceSeen, chunk.SequenceNumber); + _chunks[chunk.SequenceNumber] = chunk.Delta; + ScheduleFlush(); + return Task.CompletedTask; + } + + public override async Task CompleteAsync(MessageContent final) + { + if (_completed) + return; + + _completed = true; + CancelPendingFlush(); + await _adapter.UpdateAsync(_reference, _activityId, final, CancellationToken.None); + } + + public override async ValueTask DisposeAsync() + { + if (_completed) + return; + + _completed = true; + CancelPendingFlush(); + var interrupted = new MessageContent + { + Text = string.IsNullOrWhiteSpace(_currentText) ? "(interrupted)" : $"{_currentText}\n\n(interrupted)", + Disposition = MessageDisposition.Normal, + }; + try + { + await _adapter.UpdateAsync(_reference, _activityId, interrupted, CancellationToken.None); + } + catch + { + } + } + + private void ScheduleFlush() + { + CancelPendingFlush(); + _flushCts = new CancellationTokenSource(); + _ = FlushLaterAsync(_flushCts.Token); + } + + private async Task FlushLaterAsync(CancellationToken ct) + { + try + { + if (_debounce > TimeSpan.Zero) + await Task.Delay(_debounce, ct); + + _currentText += string.Concat(_chunks.OrderBy(static pair => pair.Key).Select(static pair => pair.Value)); + _chunks.Clear(); + await _adapter.UpdateAsync(_reference, _activityId, new MessageContent + { + Text = _currentText, + Disposition = MessageDisposition.Normal, + }, ct); + } + catch (OperationCanceledException) + { + } + catch + { + } + } + + private void CancelPendingFlush() + { + if (_flushCts is null) + return; + + _flushCts.Cancel(); + _flushCts.Dispose(); + _flushCts = null; + } + } +} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapterOptions.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapterOptions.cs new file mode 100644 index 000000000..2a1b5b478 --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapterOptions.cs @@ -0,0 +1,29 @@ +using Aevatar.GAgents.Channel.Abstractions; + +namespace Aevatar.GAgents.Channel.Telegram; + +/// +/// Configures one Telegram adapter instance. +/// +public sealed class TelegramChannelAdapterOptions +{ + /// + /// Gets or sets the inbound transport mode enabled for this adapter instance. + /// + public TransportMode TransportMode { get; init; } = TransportMode.Webhook; + + /// + /// Gets or sets the long-polling timeout in seconds. + /// + public int LongPollingTimeoutSeconds { get; init; } = 2; + + /// + /// Gets or sets the debounce window used by streaming edit loops. + /// + public int RecommendedStreamDebounceMs { get; init; } = 3000; + + /// + /// Gets or sets the time provider used by long-polling and streaming timers. + /// + public TimeProvider TimeProvider { get; init; } = TimeProvider.System; +} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramMessageComposer.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramMessageComposer.cs new file mode 100644 index 000000000..855f1bea0 --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramMessageComposer.cs @@ -0,0 +1,120 @@ +using System.Text; +using Aevatar.GAgents.Channel.Abstractions; +using Telegram.Bot.Types.ReplyMarkups; + +namespace Aevatar.GAgents.Channel.Telegram; + +/// +/// Composes channel-agnostic message intent into Telegram-native text, inline keyboards, and one optional upload target. +/// +public sealed class TelegramMessageComposer : IMessageComposer +{ + private const int TelegramTextLimit = 4096; + private const int TelegramCaptionLimit = 1024; + + /// + public ChannelId Channel { get; } = ChannelId.From("telegram"); + + /// + public TelegramNativePayload Compose(MessageContent intent, ComposeContext context) + { + ArgumentNullException.ThrowIfNull(intent); + ArgumentNullException.ThrowIfNull(context); + + var capability = Evaluate(intent, context); + var attachment = intent.Attachments.Count > 0 ? intent.Attachments[0].Clone() : null; + var textLimit = attachment is null + ? ResolveTextLimit(context.Capabilities.MaxMessageLength, TelegramTextLimit) + : ResolveTextLimit(Math.Min(context.Capabilities.MaxMessageLength, TelegramCaptionLimit), TelegramCaptionLimit); + var text = BuildRenderedText(intent, textLimit); + var replyMarkup = intent.Actions.Count == 0 + ? null + : BuildInlineKeyboard(intent.Actions, context.Capabilities.SupportsActionButtons); + + return new TelegramNativePayload(text, replyMarkup, attachment, capability); + } + + object IMessageComposer.Compose(MessageContent intent, ComposeContext context) => Compose(intent, context); + + /// + public ComposeCapability Evaluate(MessageContent intent, ComposeContext context) + { + ArgumentNullException.ThrowIfNull(intent); + ArgumentNullException.ThrowIfNull(context); + + var degraded = false; + + if (intent.Disposition == MessageDisposition.Ephemeral && !context.Capabilities.SupportsEphemeral) + degraded = true; + if (intent.Cards.Count > 0) + degraded = true; + if (intent.Actions.Count > 0 && !context.Capabilities.SupportsActionButtons) + degraded = true; + if (intent.Attachments.Count > 1) + degraded = true; + + var attachmentLimit = intent.Attachments.Count > 0 ? TelegramCaptionLimit : TelegramTextLimit; + var maxLength = ResolveTextLimit(Math.Min(context.Capabilities.MaxMessageLength, attachmentLimit), attachmentLimit); + if (BuildRenderedText(intent, int.MaxValue).Length > maxLength) + degraded = true; + + return degraded ? ComposeCapability.Degraded : ComposeCapability.Exact; + } + + private static InlineKeyboardMarkup? BuildInlineKeyboard( + IEnumerable actions, + bool supportsActionButtons) + { + if (!supportsActionButtons) + return null; + + var rows = actions + .Where(static action => action.Kind == ActionElementKind.Button && !string.IsNullOrWhiteSpace(action.Label)) + .Select(static action => new[] { new InlineKeyboardButton(action.Label, BuildCallbackData(action)) }) + .ToArray(); + + return rows.Length == 0 ? null : new InlineKeyboardMarkup(rows); + } + + private static string BuildCallbackData(ActionElement action) + { + var raw = !string.IsNullOrWhiteSpace(action.Value) + ? action.Value + : !string.IsNullOrWhiteSpace(action.ActionId) + ? action.ActionId + : action.Label; + return raw.Length <= 64 ? raw : raw[..64]; + } + + private static string BuildRenderedText(MessageContent intent, int maxLength) + { + var builder = new StringBuilder(); + AppendParagraph(builder, intent.Text); + + foreach (var card in intent.Cards) + { + AppendParagraph(builder, card.Title); + AppendParagraph(builder, card.Text); + foreach (var field in card.Fields) + AppendParagraph(builder, $"{field.Title}: {field.Text}"); + } + + var text = builder.ToString().Trim(); + if (string.IsNullOrWhiteSpace(text)) + text = "(empty)"; + return text.Length <= maxLength ? text : text[..maxLength]; + } + + private static void AppendParagraph(StringBuilder builder, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return; + + if (builder.Length > 0) + builder.AppendLine().AppendLine(); + builder.Append(value.Trim()); + } + + private static int ResolveTextLimit(int configuredMax, int fallback) => + configuredMax > 0 ? Math.Min(configuredMax, fallback) : fallback; +} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramNativePayload.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramNativePayload.cs new file mode 100644 index 000000000..67f533bde --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramNativePayload.cs @@ -0,0 +1,17 @@ +using Aevatar.GAgents.Channel.Abstractions; +using Telegram.Bot.Types.ReplyMarkups; + +namespace Aevatar.GAgents.Channel.Telegram; + +/// +/// Composer output for Telegram outbound calls. +/// +/// The text or caption rendered to the user. +/// The optional inline keyboard. +/// The optional attachment that drives sendPhoto or sendDocument. +/// The evaluated composition capability. +public sealed record TelegramNativePayload( + string Text, + InlineKeyboardMarkup? ReplyMarkup, + AttachmentRef? Attachment, + ComposeCapability Capability); diff --git a/test/Aevatar.GAgents.Channel.Protocol.Tests/ChannelAbstractionsSurfaceTests.cs b/test/Aevatar.GAgents.Channel.Protocol.Tests/ChannelAbstractionsSurfaceTests.cs index 4baf72b08..e2ce3d08e 100644 --- a/test/Aevatar.GAgents.Channel.Protocol.Tests/ChannelAbstractionsSurfaceTests.cs +++ b/test/Aevatar.GAgents.Channel.Protocol.Tests/ChannelAbstractionsSurfaceTests.cs @@ -44,6 +44,26 @@ public void ConversationReferenceHelpers_ShouldRejectMissingCanonicalSegments() exception.ParamName.ShouldBe("segments"); } + [Fact] + public void ConversationReferenceHelpers_ShouldBuildTelegramCanonicalKeys() + { + var bot = BotInstanceId.From("telegram-bot"); + + var direct = ConversationReference.TelegramPrivate(bot, "42"); + var group = ConversationReference.TelegramGroup(bot, "-1001"); + var supergroup = ConversationReference.TelegramGroup(bot, "-1002", isSupergroup: true); + var channel = ConversationReference.TelegramChannel(bot, "-1003"); + + direct.CanonicalKey.ShouldBe("telegram:private:42"); + direct.Scope.ShouldBe(ConversationScope.DirectMessage); + group.CanonicalKey.ShouldBe("telegram:group:-1001"); + group.Scope.ShouldBe(ConversationScope.Group); + supergroup.CanonicalKey.ShouldBe("telegram:supergroup:-1002"); + supergroup.Scope.ShouldBe(ConversationScope.Group); + channel.CanonicalKey.ShouldBe("telegram:channel:-1003"); + channel.Scope.ShouldBe(ConversationScope.Channel); + } + [Fact] public void EmitResultHelpers_ShouldCaptureRetryDelay() { diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/Aevatar.GAgents.Channel.Telegram.Tests.csproj b/test/Aevatar.GAgents.Channel.Telegram.Tests/Aevatar.GAgents.Channel.Telegram.Tests.csproj new file mode 100644 index 000000000..b84de5a31 --- /dev/null +++ b/test/Aevatar.GAgents.Channel.Telegram.Tests/Aevatar.GAgents.Channel.Telegram.Tests.csproj @@ -0,0 +1,27 @@ + + + net10.0 + enable + enable + false + true + Aevatar.GAgents.Channel.Telegram.Tests + Aevatar.GAgents.Channel.Telegram.Tests + + + + + + + + + + + + + + + + + + diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs new file mode 100644 index 000000000..ee98107c8 --- /dev/null +++ b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs @@ -0,0 +1,332 @@ +using System.Text; +using System.Text.Json; +using Aevatar.Foundation.Abstractions.Credentials; +using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.Telegram; +using Aevatar.GAgents.Channel.Testing; +using Microsoft.Extensions.Logging.Abstractions; +using global::Telegram.Bot; +using global::Telegram.Bot.Types; +using TelegramInlineKeyboardMarkup = global::Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup; + +namespace Aevatar.GAgents.Channel.Telegram.Tests; + +internal sealed class TelegramAdapterHarness +{ + private TelegramChannelAdapter _adapter; + private TelegramWebhookFixture _webhook; + + public TelegramAdapterHarness(TransportMode transportMode = TransportMode.Webhook) + { + Credentials = new FakeCredentialProvider(new Dictionary(StringComparer.Ordinal) + { + ["vault://telegram/primary"] = "bot-token-primary", + ["vault://telegram/secondary"] = "bot-token-secondary", + }); + Api = new FakeTelegramApiClient(); + Attachments = new FakeTelegramAttachmentContentResolver(); + _adapter = CreateAdapter(transportMode); + _webhook = new TelegramWebhookFixture(_adapter); + } + + public FakeCredentialProvider Credentials { get; } + + public FakeTelegramApiClient Api { get; } + + public FakeTelegramAttachmentContentResolver Attachments { get; } + + public ChannelTransportBinding DefaultBinding { get; } = ChannelTransportBinding.Create( + ChannelBotDescriptor.Create("telegram-primary", ChannelId.From("telegram"), BotInstanceId.From("telegram-primary-bot")), + "vault://telegram/primary", + "secret-primary"); + + public ChannelTransportBinding SecondaryBinding { get; } = ChannelTransportBinding.Create( + ChannelBotDescriptor.Create("telegram-secondary", ChannelId.From("telegram"), BotInstanceId.From("telegram-secondary-bot")), + "vault://telegram/secondary", + "secret-secondary"); + + public TelegramChannelAdapter Reset(TransportMode transportMode = TransportMode.Webhook) + { + Api.Clear(); + _adapter = CreateAdapter(transportMode); + _webhook = new TelegramWebhookFixture(_adapter); + return _adapter; + } + + public WebhookFixture Webhook => _webhook; + + public TelegramChannelAdapter Adapter => _adapter; + + private TelegramChannelAdapter CreateAdapter(TransportMode transportMode) => + new( + Credentials, + Api, + Attachments, + logger: NullLogger.Instance, + options: new TelegramChannelAdapterOptions + { + TransportMode = transportMode, + RecommendedStreamDebounceMs = 3000, + }); +} + +internal sealed class TelegramWebhookFixture : WebhookFixture +{ + private readonly TelegramChannelAdapter _adapter; + private byte[]? _lastRaw; + + public TelegramWebhookFixture(TelegramChannelAdapter adapter) + { + _adapter = adapter; + } + + public override async Task DispatchInboundAsync(InboundActivitySeed seed, CancellationToken ct = default) + { + _lastRaw = BuildPayload(seed, out _); + using var stream = new MemoryStream(_lastRaw, writable: false); + return await _adapter.AcceptWebhookAsync(stream, secretToken: "secret-primary", ct: ct) + ?? throw new InvalidOperationException("Synthetic Telegram webhook did not produce an activity."); + } + + public override async Task ReplayLastInboundAsync(CancellationToken ct = default) + { + if (_lastRaw is null) + return null; + using var stream = new MemoryStream(_lastRaw, writable: false); + return await _adapter.AcceptWebhookAsync(stream, secretToken: "secret-primary", ct: ct); + } + + public override async Task DispatchInboundToBindingAsync( + ChannelTransportBinding binding, + InboundActivitySeed seed, + CancellationToken ct = default) + { + _lastRaw = BuildPayload(seed, out _); + using var stream = new MemoryStream(_lastRaw, writable: false); + return await _adapter.AcceptWebhookAsync( + stream, + secretToken: binding.VerificationToken, + binding: binding, + ct: ct) + ?? throw new InvalidOperationException("Synthetic Telegram webhook did not produce an activity."); + } + + public override string? LastPersistedBlobRef => null; + + public override byte[]? LastRawPayloadBytes => _lastRaw; + + internal static byte[] BuildPayload(InboundActivitySeed seed, out long chatId) + { + chatId = seed.Scope == ConversationScope.DirectMessage + ? PositiveDeterministicId(seed.ConversationKey) + : -PositiveDeterministicId(seed.ConversationKey); + var senderId = PositiveDeterministicId(seed.SenderCanonicalId); + var updateId = PositiveDeterministicInt(seed.PlatformMessageId ?? $"{seed.ConversationKey}:{seed.Text}"); + var messageId = PositiveDeterministicInt(seed.PlatformMessageId ?? $"{seed.Text}:{seed.SenderCanonicalId}"); + var chatType = seed.Scope == ConversationScope.DirectMessage ? "private" : "group"; + + var payload = new + { + update_id = updateId, + message = new + { + message_id = messageId, + date = 1_714_000_000, + chat = new + { + id = chatId, + type = chatType, + }, + from = new + { + id = senderId, + is_bot = false, + username = seed.SenderDisplayName.Replace(' ', '_'), + first_name = seed.SenderDisplayName, + }, + text = seed.Text, + }, + }; + + return JsonSerializer.SerializeToUtf8Bytes(payload); + } + + internal static Update BuildUpdate(Action write) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + write(writer); + } + + return JsonSerializer.Deserialize(stream.ToArray(), JsonBotAPI.Options) + ?? throw new InvalidOperationException("Unable to deserialize synthetic Telegram update."); + } + + internal static long PositiveDeterministicId(string value) + { + var hash = Fnv1a64(value); + return (long)(hash % 9_000_000_000_000UL) + 1000; + } + + internal static int PositiveDeterministicInt(string value) + { + var hash = Fnv1a64(value); + return (int)(hash % int.MaxValue) + 1; + } + + private static ulong Fnv1a64(string value) + { + const ulong offset = 14695981039346656037; + const ulong prime = 1099511628211; + + var hash = offset; + foreach (var b in Encoding.UTF8.GetBytes(value)) + { + hash ^= b; + hash *= prime; + } + + return hash; + } +} + +internal sealed class FakeTelegramApiClient : ITelegramApiClient +{ + private readonly Queue> _pollResponses = new(); + private readonly Dictionary<(long ChatId, int MessageId), FakeTelegramMessage> _messages = new(); + private int _nextMessageId = 100; + + public List SendCalls { get; } = []; + + public IReadOnlyDictionary<(long ChatId, int MessageId), FakeTelegramMessage> Messages => _messages; + + public void EnqueuePollResponse(params Update[] updates) => _pollResponses.Enqueue(updates); + + public void Clear() + { + _pollResponses.Clear(); + _messages.Clear(); + SendCalls.Clear(); + _nextMessageId = 100; + } + + public Task> GetUpdatesAsync(string botToken, int? offset, int timeoutSeconds, CancellationToken ct) + { + if (_pollResponses.Count == 0) + return Task.FromResult>([]); + return Task.FromResult(_pollResponses.Dequeue()); + } + + public Task SendMessageAsync( + string botToken, + long chatId, + string text, + TelegramInlineKeyboardMarkup? replyMarkup, + int? replyToMessageId, + CancellationToken ct) + { + var sent = Save(chatId, text, replyMarkup, "text", null); + return Task.FromResult(sent); + } + + public Task SendPhotoAsync( + string botToken, + long chatId, + TelegramAttachmentContent attachment, + string? caption, + TelegramInlineKeyboardMarkup? replyMarkup, + int? replyToMessageId, + CancellationToken ct) + { + var sent = Save(chatId, caption ?? string.Empty, replyMarkup, "photo", attachment.FileName); + return Task.FromResult(sent); + } + + public Task SendDocumentAsync( + string botToken, + long chatId, + TelegramAttachmentContent attachment, + string? caption, + TelegramInlineKeyboardMarkup? replyMarkup, + int? replyToMessageId, + CancellationToken ct) + { + var sent = Save(chatId, caption ?? string.Empty, replyMarkup, "document", attachment.FileName); + return Task.FromResult(sent); + } + + public Task EditMessageTextAsync( + string botToken, + long chatId, + int messageId, + string text, + TelegramInlineKeyboardMarkup? replyMarkup, + CancellationToken ct) + { + _messages[(chatId, messageId)] = new FakeTelegramMessage(chatId, messageId, text, replyMarkup, _messages[(chatId, messageId)].Kind, _messages[(chatId, messageId)].AttachmentName); + SendCalls.Add(new FakeTelegramSendCall("edit", chatId, messageId, text, replyMarkup, null)); + return Task.FromResult(new TelegramSentActivity(chatId, messageId)); + } + + public Task DeleteMessageAsync(string botToken, long chatId, int messageId, CancellationToken ct) + { + _messages.Remove((chatId, messageId)); + SendCalls.Add(new FakeTelegramSendCall("delete", chatId, messageId, string.Empty, null, null)); + return Task.CompletedTask; + } + + private TelegramSentActivity Save( + long chatId, + string text, + TelegramInlineKeyboardMarkup? replyMarkup, + string kind, + string? attachmentName) + { + var messageId = Interlocked.Increment(ref _nextMessageId); + _messages[(chatId, messageId)] = new FakeTelegramMessage(chatId, messageId, text, replyMarkup, kind, attachmentName); + SendCalls.Add(new FakeTelegramSendCall(kind, chatId, messageId, text, replyMarkup, attachmentName)); + return new TelegramSentActivity(chatId, messageId); + } +} + +internal sealed record FakeTelegramSendCall( + string Kind, + long ChatId, + int MessageId, + string Text, + TelegramInlineKeyboardMarkup? ReplyMarkup, + string? AttachmentName); + +internal sealed record FakeTelegramMessage( + long ChatId, + int MessageId, + string Text, + TelegramInlineKeyboardMarkup? ReplyMarkup, + string Kind, + string? AttachmentName); + +internal sealed class FakeTelegramAttachmentContentResolver : ITelegramAttachmentContentResolver +{ + public Task ResolveAsync(AttachmentRef attachment, CancellationToken ct) + { + var bytes = Encoding.UTF8.GetBytes(attachment.BlobRef); + return Task.FromResult(new TelegramAttachmentContent( + FileName: string.IsNullOrWhiteSpace(attachment.Name) ? "attachment.bin" : attachment.Name, + ContentType: string.IsNullOrWhiteSpace(attachment.ContentType) ? "application/octet-stream" : attachment.ContentType, + Content: new MemoryStream(bytes, writable: false))); + } +} + +internal sealed class FakeCredentialProvider : ICredentialProvider +{ + private readonly IReadOnlyDictionary _credentials; + + public FakeCredentialProvider(IReadOnlyDictionary credentials) + { + _credentials = credentials; + } + + public Task ResolveAsync(string credentialRef, CancellationToken ct = default) => + Task.FromResult(_credentials.TryGetValue(credentialRef, out var value) ? value : null); +} diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterConformanceTests.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterConformanceTests.cs new file mode 100644 index 000000000..1f504ee31 --- /dev/null +++ b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterConformanceTests.cs @@ -0,0 +1,24 @@ +using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.Telegram; +using Aevatar.GAgents.Channel.Testing; + +namespace Aevatar.GAgents.Channel.Telegram.Tests; + +public sealed class TelegramChannelAdapterConformanceTests + : ChannelAdapterConformanceTests +{ + private readonly TelegramAdapterHarness _harness = new(); + + protected override TelegramChannelAdapter CreateAdapter() => _harness.Reset(); + + protected override WebhookFixture? WebhookFixture => _harness.Webhook; + + protected override GatewayFixture? GatewayFixture => null; + + protected override ChannelTransportBinding CreateBinding() => _harness.DefaultBinding; + + protected override ChannelTransportBinding? CreateSecondaryBinding() => _harness.SecondaryBinding; + + protected override ConversationReference BuildDirectMessageReference(TelegramChannelAdapter adapter) => + ConversationReference.TelegramPrivate(BotInstanceId.From("telegram-primary-bot"), "42"); +} diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs new file mode 100644 index 000000000..7e022ff23 --- /dev/null +++ b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs @@ -0,0 +1,140 @@ +using System.Text.Json; +using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.Telegram; +using Aevatar.GAgents.Channel.Testing; +using Shouldly; +using global::Telegram.Bot; +using global::Telegram.Bot.Types; + +namespace Aevatar.GAgents.Channel.Telegram.Tests; + +public sealed class TelegramChannelAdapterModeTests +{ + [Fact] + public async Task StartReceiving_LongPollingMode_PublishesInboundMessage() + { + var harness = new TelegramAdapterHarness(TransportMode.LongPolling); + var adapter = harness.Reset(TransportMode.LongPolling); + var update = JsonSerializer.Deserialize( + TelegramWebhookFixture.BuildPayload(InboundActivitySeed.DirectMessage("hello long polling"), out _), + JsonBotAPI.Options)!; + harness.Api.EnqueuePollResponse(update); + + await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); + await adapter.StartReceivingAsync(CancellationToken.None); + + try + { + using var readTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var activity = await adapter.InboundStream.ReadAsync(readTimeout.Token); + + activity.Content.Text.ShouldBe("hello long polling"); + activity.Conversation.Scope.ShouldBe(ConversationScope.DirectMessage); + } + finally + { + await adapter.StopReceivingAsync(CancellationToken.None); + } + } + + [Fact] + public async Task AcceptWebhookAsync_ShouldDifferentiateSupergroupAndChannelPosts() + { + var harness = new TelegramAdapterHarness(); + var adapter = harness.Reset(); + await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); + await adapter.StartReceivingAsync(CancellationToken.None); + + try + { + var supergroupUpdate = TelegramWebhookFixture.BuildUpdate(writer => + { + writer.WriteStartObject(); + writer.WriteNumber("update_id", 8001); + writer.WritePropertyName("message"); + writer.WriteStartObject(); + writer.WriteNumber("message_id", 501); + writer.WriteNumber("date", 1_714_000_000); + writer.WritePropertyName("chat"); + writer.WriteStartObject(); + writer.WriteNumber("id", -100200300400L); + writer.WriteString("type", "supergroup"); + writer.WriteEndObject(); + writer.WritePropertyName("from"); + writer.WriteStartObject(); + writer.WriteNumber("id", 77); + writer.WriteBoolean("is_bot", false); + writer.WriteString("first_name", "Alice"); + writer.WriteEndObject(); + writer.WriteString("text", "supergroup message"); + writer.WriteEndObject(); + writer.WriteEndObject(); + }); + var channelUpdate = TelegramWebhookFixture.BuildUpdate(writer => + { + writer.WriteStartObject(); + writer.WriteNumber("update_id", 8002); + writer.WritePropertyName("channel_post"); + writer.WriteStartObject(); + writer.WriteNumber("message_id", 601); + writer.WriteNumber("date", 1_714_000_010); + writer.WritePropertyName("chat"); + writer.WriteStartObject(); + writer.WriteNumber("id", -100500600700L); + writer.WriteString("type", "channel"); + writer.WriteString("title", "ops-channel"); + writer.WriteEndObject(); + writer.WriteString("text", "channel post"); + writer.WriteEndObject(); + writer.WriteEndObject(); + }); + + await using var supergroupStream = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(supergroupUpdate, JsonBotAPI.Options)); + await using var channelStream = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(channelUpdate, JsonBotAPI.Options)); + var supergroupActivity = await adapter.AcceptWebhookAsync(supergroupStream, secretToken: "secret-primary"); + var channelActivity = await adapter.AcceptWebhookAsync(channelStream, secretToken: "secret-primary"); + + supergroupActivity.ShouldNotBeNull(); + supergroupActivity.Conversation.Scope.ShouldBe(ConversationScope.Group); + supergroupActivity.Conversation.CanonicalKey.ShouldContain("supergroup"); + channelActivity.ShouldNotBeNull(); + channelActivity.Conversation.Scope.ShouldBe(ConversationScope.Channel); + channelActivity.Conversation.CanonicalKey.ShouldContain("channel"); + } + finally + { + await adapter.StopReceivingAsync(CancellationToken.None); + } + } + + [Fact] + public async Task BeginStreamingReplyAsync_ShouldFinalizeByEditingMessage() + { + var harness = new TelegramAdapterHarness(); + var adapter = harness.Reset(); + await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); + await adapter.StartReceivingAsync(CancellationToken.None); + + var reference = ConversationReference.TelegramPrivate(BotInstanceId.From("telegram-primary-bot"), "42"); + try + { + await using var handle = await adapter.BeginStreamingReplyAsync( + reference, + SampleMessageContent.SimpleText("seed"), + CancellationToken.None); + + await handle.AppendAsync(new StreamChunk + { + SequenceNumber = 1, + Delta = " plus", + }); + await handle.CompleteAsync(SampleMessageContent.SimpleText("seed plus final")); + + harness.Api.SendCalls.ShouldContain(call => call.Kind == "edit" && call.Text == "seed plus final"); + } + finally + { + await adapter.StopReceivingAsync(CancellationToken.None); + } + } +} diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs new file mode 100644 index 000000000..07ddedd52 --- /dev/null +++ b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs @@ -0,0 +1,56 @@ +using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.Telegram; +using Aevatar.GAgents.Channel.Testing; +using Shouldly; + +namespace Aevatar.GAgents.Channel.Telegram.Tests; + +public sealed class TelegramMessageComposerTests : MessageComposerUnitTests +{ + protected override TelegramMessageComposer CreateComposer() => new(); + + protected override ChannelCapabilities CreateCapabilities() => new() + { + SupportsEphemeral = false, + SupportsEdit = true, + SupportsDelete = true, + SupportsThread = false, + Streaming = StreamingSupport.EditLoopRateLimited, + SupportsFiles = true, + MaxMessageLength = 4096, + SupportsActionButtons = true, + SupportsConfirmDialog = false, + SupportsModal = false, + SupportsMention = false, + SupportsTyping = false, + SupportsReactions = false, + RecommendedStreamDebounceMs = 3000, + Transport = TransportMode.Webhook, + }; + + protected override void AssertSimpleTextPayload(object payload, MessageContent intent, ComposeContext context) + { + var native = payload.ShouldBeOfType(); + native.Text.ShouldBe(intent.Text); + native.Attachment.ShouldBeNull(); + } + + protected override void AssertActionsPayload(object payload, MessageContent intent, ComposeContext context, ComposeCapability capability) + { + var native = payload.ShouldBeOfType(); + if (capability == ComposeCapability.Exact) + native.ReplyMarkup.ShouldNotBeNull(); + } + + protected override void AssertCardPayload(object payload, MessageContent intent, ComposeContext context, ComposeCapability capability) + { + var native = payload.ShouldBeOfType(); + native.Text.ShouldContain("Hero"); + capability.ShouldBe(ComposeCapability.Degraded); + } + + protected override void AssertOverflowTruncation(object payload, int maxLength) + { + payload.ShouldBeOfType().Text.Length.ShouldBeLessThanOrEqualTo(maxLength); + } +} diff --git a/test/Aevatar.GAgents.Channel.Testing/Conformance/ChannelAdapterConformanceTests.cs b/test/Aevatar.GAgents.Channel.Testing/Conformance/ChannelAdapterConformanceTests.cs index dae879571..93960c8d2 100644 --- a/test/Aevatar.GAgents.Channel.Testing/Conformance/ChannelAdapterConformanceTests.cs +++ b/test/Aevatar.GAgents.Channel.Testing/Conformance/ChannelAdapterConformanceTests.cs @@ -292,7 +292,7 @@ public async Task Outbound_StreamingReply_CoalescesDeltasWithinRateLimit() emit.Success.ShouldBeTrue(); var debounce = Math.Max(CapabilitiesOf(lifetime.Adapter).RecommendedStreamDebounceMs, 0); - debounce.ShouldBeLessThanOrEqualTo(2000); + debounce.ShouldBeLessThanOrEqualTo(3000); } [Fact] From a787a261dfd0b4a68b1dfc9f4c63505a6c57ee34 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Tue, 21 Apr 2026 18:30:19 +0800 Subject: [PATCH 02/15] Fix Telegram adapter review issues --- .../TelegramBotApiClient.cs | 3 - .../TelegramChannelAdapter.cs | 208 +++++++++++++----- .../TelegramChannelAdapterOptions.cs | 2 +- .../TelegramMessageComposer.cs | 2 - .../TelegramAdapterTestSupport.cs | 52 +++++ .../TelegramChannelAdapterModeTests.cs | 66 +++++- .../TelegramMessageComposerTests.cs | 19 ++ 7 files changed, 284 insertions(+), 68 deletions(-) diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramBotApiClient.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramBotApiClient.cs index 0728ac3c2..4eb6a0cf3 100644 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramBotApiClient.cs +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramBotApiClient.cs @@ -71,7 +71,6 @@ public async Task SendPhotoAsync( chatId, CreateInputFile(attachment), caption: caption, - parseMode: ParseMode.Html, replyParameters: replyToMessageId.HasValue ? new ReplyParameters { MessageId = replyToMessageId.Value } : null, replyMarkup: replyMarkup, cancellationToken: ct); @@ -93,7 +92,6 @@ public async Task SendDocumentAsync( chatId, CreateInputFile(attachment), caption: caption, - parseMode: ParseMode.Html, replyParameters: replyToMessageId.HasValue ? new ReplyParameters { MessageId = replyToMessageId.Value } : null, replyMarkup: replyMarkup, cancellationToken: ct); @@ -114,7 +112,6 @@ public async Task EditMessageTextAsync( chatId, messageId, text, - parseMode: ParseMode.Html, replyMarkup: replyMarkup, cancellationToken: ct); return new TelegramSentActivity(chatId, message.MessageId); diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs index 65d87a7a1..43859826b 100644 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Threading.Channels; using Aevatar.GAgents.Channel.Abstractions; @@ -5,6 +7,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Telegram.Bot; +using Telegram.Bot.Exceptions; using Telegram.Bot.Types; using Telegram.Bot.Types.Enums; using ICredentialProvider = Aevatar.Foundation.Abstractions.Credentials.ICredentialProvider; @@ -30,7 +33,6 @@ public sealed class TelegramChannelAdapter : IChannelTransport, IChannelOutbound private readonly ILogger _logger; private readonly TelegramChannelAdapterOptions _options; private ChannelTransportBinding? _binding; - private string? _botToken; private bool _initialized; private bool _receiving; private bool _stopped; @@ -204,6 +206,14 @@ public async Task SendAsync(ConversationReference to, MessageContent Capabilities = Capabilities, }; var payload = _composer.Compose(content, context); + if (payload.Attachment is null && string.IsNullOrWhiteSpace(payload.Text)) + { + return EmitResult.Failed( + "telegram_empty_message", + "Telegram text sends require non-empty message content.", + capability: payload.Capability); + } + var botToken = await ResolveBotTokenAsync(ct); TelegramSentActivity sent; @@ -284,6 +294,14 @@ public async Task UpdateAsync( Conversation = to.Clone(), Capabilities = Capabilities, }); + if (string.IsNullOrWhiteSpace(payload.Text)) + { + return EmitResult.Failed( + "telegram_empty_message", + "Telegram text updates require non-empty message content.", + capability: payload.Capability); + } + if (payload.Attachment is not null) { return EmitResult.Failed( @@ -326,6 +344,10 @@ public async Task ContinueConversationAsync( Aevatar.GAgents.Channel.Abstractions.AuthContext auth, CancellationToken ct) { + ArgumentNullException.ThrowIfNull(reference); + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(auth); + if (auth.Kind == PrincipalKind.OnBehalfOfUser) { return EmitResult.Failed( @@ -342,10 +364,29 @@ private async Task RunLongPollingLoopAsync(CancellationToken ct) var offset = 0; while (!ct.IsCancellationRequested) { + string botToken; + try + { + botToken = await ResolveBotTokenAsync(ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (TaskCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "Telegram long polling stopped because bot credentials could not be resolved."); + break; + } + try { var updates = await _apiClient.GetUpdatesAsync( - await ResolveBotTokenAsync(ct), + botToken, offset <= 0 ? null : offset, _options.LongPollingTimeoutSeconds, ct); @@ -366,10 +407,15 @@ await ResolveBotTokenAsync(ct), { break; } + catch (ApiRequestException ex) when (IsTerminalAuthFailure(ex)) + { + _logger.LogError(ex, "Telegram long polling stopped because bot authorization failed."); + break; + } catch (Exception ex) { _logger.LogWarning(ex, "Telegram long polling iteration failed."); - await Task.Delay(TimeSpan.FromSeconds(1), ct); + await Task.Delay(TimeSpan.FromSeconds(1), _options.TimeProvider, ct); } } } @@ -454,7 +500,7 @@ private bool TryNormalizeMessage( ReplyToActivityId = message.ReplyToMessage is not null ? TelegramConversationTarget.BuildActivityId(message.Chat.Id, message.ReplyToMessage.MessageId) : string.Empty, - RawPayloadBlobRef = $"telegram://update/{updateId}", + RawPayloadBlobRef = string.Empty, }; return true; } @@ -493,7 +539,7 @@ private bool TryNormalizeCallback( Disposition = MessageDisposition.Normal, }, ReplyToActivityId = TelegramConversationTarget.BuildActivityId(callback.Message.Chat.Id, callback.Message.MessageId), - RawPayloadBlobRef = $"telegram://update/{updateId}", + RawPayloadBlobRef = string.Empty, }; return true; } @@ -512,7 +558,16 @@ private bool ValidateWebhookSecret(string? secretToken, ChannelTransportBinding { if (string.IsNullOrWhiteSpace(binding.VerificationToken)) return true; - if (string.Equals(secretToken, binding.VerificationToken, StringComparison.Ordinal)) + + if (string.IsNullOrEmpty(secretToken)) + { + _logger.LogWarning("Telegram webhook secret token mismatch."); + return false; + } + + var expected = Encoding.UTF8.GetBytes(binding.VerificationToken); + var actual = Encoding.UTF8.GetBytes(secretToken); + if (expected.Length == actual.Length && CryptographicOperations.FixedTimeEquals(expected, actual)) return true; _logger.LogWarning("Telegram webhook secret token mismatch."); @@ -521,14 +576,11 @@ private bool ValidateWebhookSecret(string? secretToken, ChannelTransportBinding private async Task ResolveBotTokenAsync(CancellationToken ct) { - if (!string.IsNullOrWhiteSpace(_botToken)) - return _botToken; if (_binding is null) throw new InvalidOperationException("Adapter binding is unavailable."); - _botToken = await _credentialProvider.ResolveBotCredentialAsync(_binding, ct) + return await _credentialProvider.ResolveBotCredentialAsync(_binding, ct) ?? throw new InvalidOperationException("Telegram bot credential could not be resolved."); - return _botToken; } private async Task ResolveAttachmentAsync(AttachmentRef attachment, CancellationToken ct) @@ -555,6 +607,8 @@ private void EnsureReady() private static string SanitizeError(string message) => string.IsNullOrWhiteSpace(message) ? "telegram_error" : message.Replace(Environment.NewLine, " ", StringComparison.Ordinal).Trim(); + private static bool IsTerminalAuthFailure(ApiRequestException ex) => ex.ErrorCode is 401 or 403; + private readonly record struct TelegramConversationTarget(long ChatId, int MessageId) { public static TelegramConversationTarget Parse(ConversationReference reference) @@ -582,9 +636,6 @@ public static TelegramConversationTarget ParseActivityId(string activityId) return new TelegramConversationTarget(chatId, messageId); } - if (int.TryParse(activityId, out messageId)) - return new TelegramConversationTarget(0, messageId); - throw new InvalidOperationException("Telegram activity id is invalid."); } @@ -598,11 +649,13 @@ private sealed class TelegramStreamingHandle : StreamingHandle private readonly string _activityId; private readonly TimeProvider _timeProvider; private readonly TimeSpan _debounce; + private readonly SemaphoreSlim _writeGate = new(1, 1); + private readonly HashSet _acceptedSequenceNumbers = []; private readonly Dictionary _chunks = new(); private string _currentText; - private long _maxSequenceSeen; private bool _completed; private CancellationTokenSource? _flushCts; + private long _flushGeneration; public TelegramStreamingHandle( TelegramChannelAdapter adapter, @@ -620,71 +673,107 @@ public TelegramStreamingHandle( _debounce = debounce; } - public override Task AppendAsync(StreamChunk chunk) + public override async Task AppendAsync(StreamChunk chunk) { - if (_completed) - return Task.CompletedTask; - if (chunk.SequenceNumber <= _maxSequenceSeen && _chunks.ContainsKey(chunk.SequenceNumber)) - return Task.CompletedTask; - - _maxSequenceSeen = Math.Max(_maxSequenceSeen, chunk.SequenceNumber); - _chunks[chunk.SequenceNumber] = chunk.Delta; - ScheduleFlush(); - return Task.CompletedTask; + ArgumentNullException.ThrowIfNull(chunk); + + CancellationTokenSource? previousFlush = null; + await _writeGate.WaitAsync(CancellationToken.None); + try + { + if (_completed || !_acceptedSequenceNumbers.Add(chunk.SequenceNumber)) + return; + + _chunks[chunk.SequenceNumber] = chunk.Delta; + previousFlush = _flushCts; + _flushCts = new CancellationTokenSource(); + _ = FlushLaterAsync(++_flushGeneration, _flushCts.Token); + } + finally + { + _writeGate.Release(); + } + + CancelPendingFlush(previousFlush); } public override async Task CompleteAsync(MessageContent final) { - if (_completed) - return; + ArgumentNullException.ThrowIfNull(final); - _completed = true; - CancelPendingFlush(); - await _adapter.UpdateAsync(_reference, _activityId, final, CancellationToken.None); + Task? finalWrite = null; + await _writeGate.WaitAsync(CancellationToken.None); + try + { + if (_completed) + return; + + _completed = true; + CancelPendingFlush(_flushCts); + _flushCts = null; + finalWrite = _adapter.UpdateAsync(_reference, _activityId, final, CancellationToken.None); + await finalWrite; + } + finally + { + _writeGate.Release(); + } } public override async ValueTask DisposeAsync() { - if (_completed) - return; - - _completed = true; - CancelPendingFlush(); - var interrupted = new MessageContent - { - Text = string.IsNullOrWhiteSpace(_currentText) ? "(interrupted)" : $"{_currentText}\n\n(interrupted)", - Disposition = MessageDisposition.Normal, - }; + Task? interruptedWrite = null; + await _writeGate.WaitAsync(CancellationToken.None); try { - await _adapter.UpdateAsync(_reference, _activityId, interrupted, CancellationToken.None); + if (_completed) + return; + + _completed = true; + CancelPendingFlush(_flushCts); + _flushCts = null; + var interrupted = new MessageContent + { + Text = string.IsNullOrWhiteSpace(_currentText) ? "(interrupted)" : $"{_currentText}\n\n(interrupted)", + Disposition = MessageDisposition.Normal, + }; + interruptedWrite = _adapter.UpdateAsync(_reference, _activityId, interrupted, CancellationToken.None); + await interruptedWrite; } catch { } + finally + { + _writeGate.Release(); + } } - private void ScheduleFlush() - { - CancelPendingFlush(); - _flushCts = new CancellationTokenSource(); - _ = FlushLaterAsync(_flushCts.Token); - } - - private async Task FlushLaterAsync(CancellationToken ct) + private async Task FlushLaterAsync(long generation, CancellationToken ct) { try { if (_debounce > TimeSpan.Zero) - await Task.Delay(_debounce, ct); + await Task.Delay(_debounce, _timeProvider, ct); - _currentText += string.Concat(_chunks.OrderBy(static pair => pair.Key).Select(static pair => pair.Value)); - _chunks.Clear(); - await _adapter.UpdateAsync(_reference, _activityId, new MessageContent + await _writeGate.WaitAsync(ct); + try { - Text = _currentText, - Disposition = MessageDisposition.Normal, - }, ct); + if (_completed || ct.IsCancellationRequested || generation != _flushGeneration || _chunks.Count == 0) + return; + + _currentText += string.Concat(_chunks.OrderBy(static pair => pair.Key).Select(static pair => pair.Value)); + _chunks.Clear(); + await _adapter.UpdateAsync(_reference, _activityId, new MessageContent + { + Text = _currentText, + Disposition = MessageDisposition.Normal, + }, ct); + } + finally + { + _writeGate.Release(); + } } catch (OperationCanceledException) { @@ -694,14 +783,13 @@ private async Task FlushLaterAsync(CancellationToken ct) } } - private void CancelPendingFlush() + private static void CancelPendingFlush(CancellationTokenSource? flushCts) { - if (_flushCts is null) + if (flushCts is null) return; - _flushCts.Cancel(); - _flushCts.Dispose(); - _flushCts = null; + flushCts.Cancel(); + flushCts.Dispose(); } } } diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapterOptions.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapterOptions.cs index 2a1b5b478..f970bdd1e 100644 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapterOptions.cs +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapterOptions.cs @@ -15,7 +15,7 @@ public sealed class TelegramChannelAdapterOptions /// /// Gets or sets the long-polling timeout in seconds. /// - public int LongPollingTimeoutSeconds { get; init; } = 2; + public int LongPollingTimeoutSeconds { get; init; } = 30; /// /// Gets or sets the debounce window used by streaming edit loops. diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramMessageComposer.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramMessageComposer.cs index 855f1bea0..2836f398b 100644 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramMessageComposer.cs +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramMessageComposer.cs @@ -100,8 +100,6 @@ private static string BuildRenderedText(MessageContent intent, int maxLength) } var text = builder.ToString().Trim(); - if (string.IsNullOrWhiteSpace(text)) - text = "(empty)"; return text.Length <= maxLength ? text : text[..maxLength]; } diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs index ee98107c8..d499d5b1b 100644 --- a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs +++ b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs @@ -6,6 +6,7 @@ using Aevatar.GAgents.Channel.Testing; using Microsoft.Extensions.Logging.Abstractions; using global::Telegram.Bot; +using global::Telegram.Bot.Exceptions; using global::Telegram.Bot.Types; using TelegramInlineKeyboardMarkup = global::Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup; @@ -194,25 +195,50 @@ private static ulong Fnv1a64(string value) internal sealed class FakeTelegramApiClient : ITelegramApiClient { private readonly Queue> _pollResponses = new(); + private readonly Queue _pollExceptions = new(); private readonly Dictionary<(long ChatId, int MessageId), FakeTelegramMessage> _messages = new(); private int _nextMessageId = 100; + private TaskCompletionSource _firstPollCall = CreateSignal(); + private TaskCompletionSource _firstEditCall = CreateSignal(); public List SendCalls { get; } = []; public IReadOnlyDictionary<(long ChatId, int MessageId), FakeTelegramMessage> Messages => _messages; + public int PollCallCount { get; private set; } + + public int EditCallCount { get; private set; } + + public Task FirstPollCallAsync => _firstPollCall.Task; + + public Task FirstEditCallAsync => _firstEditCall.Task; + + public TaskCompletionSource? BlockNextEditCompletion { get; set; } + public void EnqueuePollResponse(params Update[] updates) => _pollResponses.Enqueue(updates); + public void EnqueuePollException(Exception exception) => _pollExceptions.Enqueue(exception); + public void Clear() { _pollResponses.Clear(); + _pollExceptions.Clear(); _messages.Clear(); SendCalls.Clear(); _nextMessageId = 100; + PollCallCount = 0; + EditCallCount = 0; + BlockNextEditCompletion = null; + _firstPollCall = CreateSignal(); + _firstEditCall = CreateSignal(); } public Task> GetUpdatesAsync(string botToken, int? offset, int timeoutSeconds, CancellationToken ct) { + PollCallCount++; + _firstPollCall.TrySetResult(true); + if (_pollExceptions.Count > 0) + throw _pollExceptions.Dequeue(); if (_pollResponses.Count == 0) return Task.FromResult>([]); return Task.FromResult(_pollResponses.Dequeue()); @@ -264,6 +290,15 @@ public Task EditMessageTextAsync( TelegramInlineKeyboardMarkup? replyMarkup, CancellationToken ct) { + EditCallCount++; + _firstEditCall.TrySetResult(true); + if (BlockNextEditCompletion is not null) + { + var release = BlockNextEditCompletion; + BlockNextEditCompletion = null; + return WaitAndEditAsync(release, chatId, messageId, text, replyMarkup, ct); + } + _messages[(chatId, messageId)] = new FakeTelegramMessage(chatId, messageId, text, replyMarkup, _messages[(chatId, messageId)].Kind, _messages[(chatId, messageId)].AttachmentName); SendCalls.Add(new FakeTelegramSendCall("edit", chatId, messageId, text, replyMarkup, null)); return Task.FromResult(new TelegramSentActivity(chatId, messageId)); @@ -288,6 +323,23 @@ private TelegramSentActivity Save( SendCalls.Add(new FakeTelegramSendCall(kind, chatId, messageId, text, replyMarkup, attachmentName)); return new TelegramSentActivity(chatId, messageId); } + + private async Task WaitAndEditAsync( + TaskCompletionSource release, + long chatId, + int messageId, + string text, + TelegramInlineKeyboardMarkup? replyMarkup, + CancellationToken ct) + { + await release.Task.WaitAsync(ct); + _messages[(chatId, messageId)] = new FakeTelegramMessage(chatId, messageId, text, replyMarkup, _messages[(chatId, messageId)].Kind, _messages[(chatId, messageId)].AttachmentName); + SendCalls.Add(new FakeTelegramSendCall("edit", chatId, messageId, text, replyMarkup, null)); + return new TelegramSentActivity(chatId, messageId); + } + + private static TaskCompletionSource CreateSignal() => + new(TaskCreationOptions.RunContinuationsAsynchronously); } internal sealed record FakeTelegramSendCall( diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs index 7e022ff23..f43ad5e5a 100644 --- a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs +++ b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs @@ -4,6 +4,7 @@ using Aevatar.GAgents.Channel.Testing; using Shouldly; using global::Telegram.Bot; +using global::Telegram.Bot.Exceptions; using global::Telegram.Bot.Types; namespace Aevatar.GAgents.Channel.Telegram.Tests; @@ -97,9 +98,11 @@ public async Task AcceptWebhookAsync_ShouldDifferentiateSupergroupAndChannelPost supergroupActivity.ShouldNotBeNull(); supergroupActivity.Conversation.Scope.ShouldBe(ConversationScope.Group); supergroupActivity.Conversation.CanonicalKey.ShouldContain("supergroup"); + supergroupActivity.RawPayloadBlobRef.ShouldBeEmpty(); channelActivity.ShouldNotBeNull(); channelActivity.Conversation.Scope.ShouldBe(ConversationScope.Channel); channelActivity.Conversation.CanonicalKey.ShouldContain("channel"); + channelActivity.RawPayloadBlobRef.ShouldBeEmpty(); } finally { @@ -123,14 +126,73 @@ public async Task BeginStreamingReplyAsync_ShouldFinalizeByEditingMessage() SampleMessageContent.SimpleText("seed"), CancellationToken.None); + var releaseEdit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + harness.Api.BlockNextEditCompletion = releaseEdit; await handle.AppendAsync(new StreamChunk { SequenceNumber = 1, Delta = " plus", }); - await handle.CompleteAsync(SampleMessageContent.SimpleText("seed plus final")); + await harness.Api.FirstEditCallAsync.WaitAsync(TimeSpan.FromSeconds(5)); - harness.Api.SendCalls.ShouldContain(call => call.Kind == "edit" && call.Text == "seed plus final"); + var completeTask = handle.CompleteAsync(SampleMessageContent.SimpleText("seed plus final")); + completeTask.IsCompleted.ShouldBeFalse(); + + releaseEdit.TrySetResult(true); + await completeTask; + + harness.Api.SendCalls.Count(call => call.Kind == "edit").ShouldBe(2); + harness.Api.SendCalls[^1].Kind.ShouldBe("edit"); + harness.Api.SendCalls[^1].Text.ShouldBe("seed plus final"); + } + finally + { + await adapter.StopReceivingAsync(CancellationToken.None); + } + } + + [Fact] + public async Task StartReceiving_LongPollingAuthFailure_StopsRetryLoop() + { + var harness = new TelegramAdapterHarness(TransportMode.LongPolling); + var adapter = harness.Reset(TransportMode.LongPolling); + harness.Api.EnqueuePollException(new ApiRequestException("unauthorized", 401)); + + await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); + await adapter.StartReceivingAsync(CancellationToken.None); + + try + { + await harness.Api.FirstPollCallAsync.WaitAsync(TimeSpan.FromSeconds(5)); + } + finally + { + await adapter.StopReceivingAsync(CancellationToken.None); + } + + harness.Api.PollCallCount.ShouldBe(1); + } + + [Fact] + public async Task SendAsync_WhitespaceOnlyTextWithoutAttachment_ReturnsFailure() + { + var harness = new TelegramAdapterHarness(); + var adapter = harness.Reset(); + await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); + await adapter.StartReceivingAsync(CancellationToken.None); + + var reference = ConversationReference.TelegramPrivate(BotInstanceId.From("telegram-primary-bot"), "42"); + try + { + var emit = await adapter.SendAsync(reference, new MessageContent + { + Text = " ", + Disposition = MessageDisposition.Normal, + }, CancellationToken.None); + + emit.Success.ShouldBeFalse(); + emit.ErrorCode.ShouldBe("telegram_empty_message"); + harness.Api.SendCalls.ShouldBeEmpty(); } finally { diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs index 07ddedd52..a680cdbfe 100644 --- a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs +++ b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs @@ -53,4 +53,23 @@ protected override void AssertOverflowTruncation(object payload, int maxLength) { payload.ShouldBeOfType().Text.Length.ShouldBeLessThanOrEqualTo(maxLength); } + + [Fact] + public void Compose_WhitespaceOnlyIntent_DoesNotInventPlaceholderText() + { + var composer = CreateComposer(); + var payload = composer.Compose(new MessageContent + { + Text = " ", + Disposition = MessageDisposition.Normal, + }, CreateContext()); + + payload.Text.ShouldBeEmpty(); + } + + [Fact] + public void Options_DefaultLongPollingTimeout_UsesTelegramFriendlyDefault() + { + new TelegramChannelAdapterOptions().LongPollingTimeoutSeconds.ShouldBe(30); + } } From 8400d18f7a81fe8f888d6bf2efd688841df3b1cb Mon Sep 17 00:00:00 2001 From: eanzhao Date: Thu, 23 Apr 2026 13:07:49 +0800 Subject: [PATCH 03/15] Refactor Telegram channel adapter architecture --- aevatar.foundation.slnf | 2 + .../Aevatar.GAgents.Channel.Telegram.csproj | 9 +- .../ITelegramApiClient.cs | 81 - .../TelegramBotApiClient.cs | 162 -- .../TelegramChannelAdapter.cs | 1318 ++++++++++------- .../TelegramChannelDefaults.cs | 19 + ...egramChannelServiceCollectionExtensions.cs | 36 + .../TelegramCredentialSnapshot.cs | 39 + .../TelegramMessageComposer.cs | 93 +- .../TelegramNativePayload.cs | 17 - .../TelegramOutboundMessage.cs | 9 + .../TelegramPayloadRedactor.cs | 91 ++ .../TelegramStreamingHandle.cs | 166 +++ .../TelegramWebhookRequest.cs | 5 + .../TelegramWebhookResponse.cs | 9 + docs/canon/aevatar-channel-architecture.md | 2 +- .../TelegramAdapterTestSupport.cs | 635 +++++--- .../TelegramChannelAdapterFaultTests.cs | 22 + .../TelegramChannelAdapterModeTests.cs | 344 ++++- .../TelegramMessageComposerTests.cs | 15 +- 20 files changed, 1949 insertions(+), 1125 deletions(-) delete mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramApiClient.cs delete mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramBotApiClient.cs create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelDefaults.cs create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelServiceCollectionExtensions.cs create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramCredentialSnapshot.cs delete mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramNativePayload.cs create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramOutboundMessage.cs create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramPayloadRedactor.cs create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramStreamingHandle.cs create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookRequest.cs create mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookResponse.cs create mode 100644 test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterFaultTests.cs diff --git a/aevatar.foundation.slnf b/aevatar.foundation.slnf index 7348ac3bc..29c91fb2f 100644 --- a/aevatar.foundation.slnf +++ b/aevatar.foundation.slnf @@ -15,6 +15,7 @@ "src\\Aevatar.Foundation.VoicePresence.OpenAI\\Aevatar.Foundation.VoicePresence.OpenAI.csproj", "agents\\Aevatar.GAgents.Channel.Abstractions\\Aevatar.GAgents.Channel.Abstractions.csproj", "agents\\channels\\Aevatar.GAgents.Channel.Lark\\Aevatar.GAgents.Channel.Lark.csproj", + "agents\\channels\\Aevatar.GAgents.Channel.Telegram\\Aevatar.GAgents.Channel.Telegram.csproj", "agents\\Aevatar.GAgents.Channel.Runtime\\Aevatar.GAgents.Channel.Runtime.csproj", "test\\Aevatar.Foundation.Abstractions.Tests\\Aevatar.Foundation.Abstractions.Tests.csproj", "test\\Aevatar.Foundation.Core.Tests\\Aevatar.Foundation.Core.Tests.csproj", @@ -22,6 +23,7 @@ "test\\Aevatar.Foundation.VoicePresence.MiniCPM.Tests\\Aevatar.Foundation.VoicePresence.MiniCPM.Tests.csproj", "test\\Aevatar.Foundation.VoicePresence.OpenAI.Tests\\Aevatar.Foundation.VoicePresence.OpenAI.Tests.csproj", "test\\Aevatar.GAgents.Channel.Lark.Tests\\Aevatar.GAgents.Channel.Lark.Tests.csproj", + "test\\Aevatar.GAgents.Channel.Telegram.Tests\\Aevatar.GAgents.Channel.Telegram.Tests.csproj", "test\\Aevatar.GAgents.Channel.Protocol.Tests\\Aevatar.GAgents.Channel.Protocol.Tests.csproj" ] } diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/Aevatar.GAgents.Channel.Telegram.csproj b/agents/channels/Aevatar.GAgents.Channel.Telegram/Aevatar.GAgents.Channel.Telegram.csproj index ac1ebbc92..b751ce724 100644 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/Aevatar.GAgents.Channel.Telegram.csproj +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/Aevatar.GAgents.Channel.Telegram.csproj @@ -5,15 +5,18 @@ enable Aevatar.GAgents.Channel.Telegram Aevatar.GAgents.Channel.Telegram - true - $(WarningsAsErrors);CS1591 + + + + + + - diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramApiClient.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramApiClient.cs deleted file mode 100644 index e98d62196..000000000 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramApiClient.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Telegram.Bot.Types; -using Telegram.Bot.Types.ReplyMarkups; - -namespace Aevatar.GAgents.Channel.Telegram; - -/// -/// Narrow Telegram Bot API surface used by the adapter. -/// -public interface ITelegramApiClient -{ - /// - /// Pulls updates through Telegram long polling. - /// - Task> GetUpdatesAsync( - string botToken, - int? offset, - int timeoutSeconds, - CancellationToken ct); - - /// - /// Sends one text message. - /// - Task SendMessageAsync( - string botToken, - long chatId, - string text, - InlineKeyboardMarkup? replyMarkup, - int? replyToMessageId, - CancellationToken ct); - - /// - /// Sends one photo attachment with optional caption and inline keyboard. - /// - Task SendPhotoAsync( - string botToken, - long chatId, - TelegramAttachmentContent attachment, - string? caption, - InlineKeyboardMarkup? replyMarkup, - int? replyToMessageId, - CancellationToken ct); - - /// - /// Sends one document attachment with optional caption and inline keyboard. - /// - Task SendDocumentAsync( - string botToken, - long chatId, - TelegramAttachmentContent attachment, - string? caption, - InlineKeyboardMarkup? replyMarkup, - int? replyToMessageId, - CancellationToken ct); - - /// - /// Updates one previously sent text message. - /// - Task EditMessageTextAsync( - string botToken, - long chatId, - int messageId, - string text, - InlineKeyboardMarkup? replyMarkup, - CancellationToken ct); - - /// - /// Deletes one previously sent message. - /// - Task DeleteMessageAsync( - string botToken, - long chatId, - int messageId, - CancellationToken ct); -} - -/// -/// Represents one adapter-owned Telegram message identifier. -/// -/// The Telegram chat id. -/// The Telegram message id. -public readonly record struct TelegramSentActivity(long ChatId, int MessageId); diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramBotApiClient.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramBotApiClient.cs deleted file mode 100644 index 4eb6a0cf3..000000000 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramBotApiClient.cs +++ /dev/null @@ -1,162 +0,0 @@ -using Telegram.Bot; -using Telegram.Bot.Types; -using Telegram.Bot.Types.Enums; -using Telegram.Bot.Types.ReplyMarkups; - -namespace Aevatar.GAgents.Channel.Telegram; - -/// -/// Default Telegram Bot API client backed by the official Telegram.Bot SDK. -/// -public sealed class TelegramBotApiClient : ITelegramApiClient -{ - private readonly ITelegramBotClientFactory _clientFactory; - private static readonly UpdateType[] AllowedUpdates = [UpdateType.Message, UpdateType.ChannelPost, UpdateType.CallbackQuery]; - - /// - /// Creates the default API client. - /// - public TelegramBotApiClient(ITelegramBotClientFactory clientFactory) - { - _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); - } - - /// - public async Task> GetUpdatesAsync( - string botToken, - int? offset, - int timeoutSeconds, - CancellationToken ct) - { - var client = _clientFactory.Create(botToken); - var updates = await client.GetUpdates( - offset, - timeout: timeoutSeconds, - allowedUpdates: AllowedUpdates, - cancellationToken: ct); - return updates; - } - - /// - public async Task SendMessageAsync( - string botToken, - long chatId, - string text, - InlineKeyboardMarkup? replyMarkup, - int? replyToMessageId, - CancellationToken ct) - { - var client = _clientFactory.Create(botToken); - var message = await client.SendMessage( - chatId, - text, - replyParameters: replyToMessageId.HasValue ? new ReplyParameters { MessageId = replyToMessageId.Value } : null, - replyMarkup: replyMarkup, - cancellationToken: ct); - return new TelegramSentActivity(message.Chat.Id, message.MessageId); - } - - /// - public async Task SendPhotoAsync( - string botToken, - long chatId, - TelegramAttachmentContent attachment, - string? caption, - InlineKeyboardMarkup? replyMarkup, - int? replyToMessageId, - CancellationToken ct) - { - var client = _clientFactory.Create(botToken); - var message = await client.SendPhoto( - chatId, - CreateInputFile(attachment), - caption: caption, - replyParameters: replyToMessageId.HasValue ? new ReplyParameters { MessageId = replyToMessageId.Value } : null, - replyMarkup: replyMarkup, - cancellationToken: ct); - return new TelegramSentActivity(message.Chat.Id, message.MessageId); - } - - /// - public async Task SendDocumentAsync( - string botToken, - long chatId, - TelegramAttachmentContent attachment, - string? caption, - InlineKeyboardMarkup? replyMarkup, - int? replyToMessageId, - CancellationToken ct) - { - var client = _clientFactory.Create(botToken); - var message = await client.SendDocument( - chatId, - CreateInputFile(attachment), - caption: caption, - replyParameters: replyToMessageId.HasValue ? new ReplyParameters { MessageId = replyToMessageId.Value } : null, - replyMarkup: replyMarkup, - cancellationToken: ct); - return new TelegramSentActivity(message.Chat.Id, message.MessageId); - } - - /// - public async Task EditMessageTextAsync( - string botToken, - long chatId, - int messageId, - string text, - InlineKeyboardMarkup? replyMarkup, - CancellationToken ct) - { - var client = _clientFactory.Create(botToken); - var message = await client.EditMessageText( - chatId, - messageId, - text, - replyMarkup: replyMarkup, - cancellationToken: ct); - return new TelegramSentActivity(chatId, message.MessageId); - } - - /// - public async Task DeleteMessageAsync( - string botToken, - long chatId, - int messageId, - CancellationToken ct) - { - var client = _clientFactory.Create(botToken); - await client.DeleteMessage(chatId, messageId, ct); - } - - private static InputFile CreateInputFile(TelegramAttachmentContent attachment) - { - if (!string.IsNullOrWhiteSpace(attachment.TelegramFileId)) - return attachment.TelegramFileId!; - if (!string.IsNullOrWhiteSpace(attachment.ExternalUrl)) - return attachment.ExternalUrl!; - if (attachment.Content is null) - throw new InvalidOperationException("Telegram attachment must provide content, external URL, or file id."); - - return InputFile.FromStream(attachment.Content, attachment.FileName); - } -} - -/// -/// Creates SDK clients for raw bot tokens. -/// -public interface ITelegramBotClientFactory -{ - /// - /// Creates one SDK client for the supplied bot token. - /// - ITelegramBotClient Create(string botToken); -} - -/// -/// Default client factory backed by . -/// -public sealed class TelegramBotClientFactory : ITelegramBotClientFactory -{ - /// - public ITelegramBotClient Create(string botToken) => new TelegramBotClient(botToken); -} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs index 43859826b..048e9d1e1 100644 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs @@ -1,156 +1,99 @@ +using System.Globalization; +using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; -using System.Threading.Channels; +using System.Text.Json.Nodes; using Aevatar.GAgents.Channel.Abstractions; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Telegram.Bot; -using Telegram.Bot.Exceptions; -using Telegram.Bot.Types; -using Telegram.Bot.Types.Enums; -using ICredentialProvider = Aevatar.Foundation.Abstractions.Credentials.ICredentialProvider; +using FoundationCredentialProvider = Aevatar.Foundation.Abstractions.Credentials.ICredentialProvider; namespace Aevatar.GAgents.Channel.Telegram; -/// -/// Full Telegram channel adapter backed by the official Telegram.Bot SDK. -/// public sealed class TelegramChannelAdapter : IChannelTransport, IChannelOutboundPort { - private readonly Channel _inbound = System.Threading.Channels.Channel.CreateBounded( - new BoundedChannelOptions(256) - { - SingleReader = true, - SingleWriter = false, - FullMode = BoundedChannelFullMode.Wait, - }); - private readonly ICredentialProvider _credentialProvider; - private readonly ITelegramApiClient _apiClient; - private readonly ITelegramAttachmentContentResolver? _attachmentResolver; - private readonly TelegramMessageComposer _composer; + private static readonly ChannelId TelegramChannel = ChannelId.From("telegram"); + + private readonly HttpClient _httpClient; + private readonly FoundationCredentialProvider _credentialProvider; + private readonly IMessageComposer _composer; + private readonly IPayloadRedactor _payloadRedactor; private readonly ILogger _logger; + private readonly System.Threading.Channels.Channel _inboundBuffer; + private readonly ChannelCapabilities _capabilities; + private readonly ITelegramAttachmentContentResolver? _attachmentResolver; private readonly TelegramChannelAdapterOptions _options; + private readonly bool _captureInboundActivities; + private ChannelTransportBinding? _binding; + private TelegramCredentialSnapshot _botCredential = new(string.Empty); private bool _initialized; private bool _receiving; private bool _stopped; private CancellationTokenSource? _receiveLoopCts; private Task? _receiveLoop; - /// - /// Creates one Telegram channel adapter. - /// public TelegramChannelAdapter( - ICredentialProvider credentialProvider, - ITelegramApiClient apiClient, + FoundationCredentialProvider credentialProvider, + TelegramMessageComposer composer, + IPayloadRedactor payloadRedactor, + ILogger logger, + HttpClient? httpClient = null, ITelegramAttachmentContentResolver? attachmentResolver = null, - TelegramMessageComposer? composer = null, - ILogger? logger = null, - TelegramChannelAdapterOptions? options = null) + TelegramChannelAdapterOptions? options = null, + bool captureInboundActivities = true) { _credentialProvider = credentialProvider ?? throw new ArgumentNullException(nameof(credentialProvider)); - _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + _composer = composer ?? throw new ArgumentNullException(nameof(composer)); + _payloadRedactor = payloadRedactor ?? throw new ArgumentNullException(nameof(payloadRedactor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _attachmentResolver = attachmentResolver; - _composer = composer ?? new TelegramMessageComposer(); - _logger = logger ?? NullLogger.Instance; _options = options ?? new TelegramChannelAdapterOptions(); - Capabilities = new ChannelCapabilities - { - SupportsEphemeral = false, - SupportsEdit = true, - SupportsDelete = true, - SupportsThread = false, - Streaming = StreamingSupport.EditLoopRateLimited, - SupportsFiles = true, - MaxMessageLength = 4096, - SupportsActionButtons = true, - SupportsConfirmDialog = false, - SupportsModal = false, - SupportsMention = false, - SupportsTyping = false, - SupportsReactions = false, - RecommendedStreamDebounceMs = _options.RecommendedStreamDebounceMs, - Transport = _options.TransportMode, + _captureInboundActivities = captureInboundActivities; + _httpClient = httpClient ?? new HttpClient + { + BaseAddress = TelegramChannelDefaults.DefaultBaseAddress, }; + _capabilities = TelegramMessageComposer.DefaultCapabilities.Clone(); + _capabilities.RecommendedStreamDebounceMs = _options.RecommendedStreamDebounceMs; + _capabilities.Transport = _options.TransportMode; + _inboundBuffer = System.Threading.Channels.Channel.CreateBounded( + new System.Threading.Channels.BoundedChannelOptions(256) + { + SingleReader = true, + SingleWriter = false, + FullMode = System.Threading.Channels.BoundedChannelFullMode.Wait, + }); } - /// - public ChannelId Channel { get; } = ChannelId.From("telegram"); + public ChannelId Channel => TelegramChannel; - /// public TransportMode TransportMode => _options.TransportMode; - /// - public ChannelCapabilities Capabilities { get; } + public ChannelCapabilities Capabilities => _capabilities.Clone(); - /// - public ChannelReader InboundStream => _inbound.Reader; + public System.Threading.Channels.ChannelReader InboundStream => _inboundBuffer.Reader; - /// - /// Accepts one webhook payload, normalizes it into , and enqueues it on . - /// - public async Task AcceptWebhookAsync( - Stream payload, - string? secretToken = null, - ChannelTransportBinding? binding = null, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(payload); - var activeBinding = binding ?? _binding ?? throw new InvalidOperationException("Adapter has not been initialized."); - if (!ValidateWebhookSecret(secretToken, activeBinding)) - return null; - - using var document = await JsonDocument.ParseAsync(payload, cancellationToken: ct); - var update = document.Deserialize(JsonBotAPI.Options); - if (update is null) - return null; - - return await ProcessUpdateAsync(update, activeBinding, enqueue: true, ct); - } - - /// - /// Starts one streaming reply against the supplied Telegram conversation reference. - /// - public async Task BeginStreamingReplyAsync( - ConversationReference reference, - MessageContent initial, - CancellationToken ct) - { - var initialEmit = await SendAsync(reference, initial, ct); - if (!initialEmit.Success) - throw new InvalidOperationException($"Unable to start Telegram streaming reply: {initialEmit.ErrorCode}"); - - return new TelegramStreamingHandle( - this, - reference, - initialEmit.SentActivityId, - initial.Text, - _options.TimeProvider, - TimeSpan.FromMilliseconds(Math.Max(_options.RecommendedStreamDebounceMs, 0))); - } - - /// - public Task InitializeAsync(ChannelTransportBinding binding, CancellationToken ct) + public async Task InitializeAsync(ChannelTransportBinding binding, CancellationToken ct) { ArgumentNullException.ThrowIfNull(binding); - if (_initialized) - throw new InvalidOperationException("Adapter has already been initialized."); - if (_receiving) - throw new InvalidOperationException("Adapter cannot initialize after receive startup."); + if (_initialized || _receiving) + throw new InvalidOperationException("TelegramChannelAdapter is already initialized."); + + var secret = await _credentialProvider.ResolveBotCredentialAsync(binding, ct); _binding = binding.Clone(); + _botCredential = TelegramCredentialSnapshot.Parse(secret); _initialized = true; - return Task.CompletedTask; + _stopped = false; } - /// public Task StartReceivingAsync(CancellationToken ct) { EnsureInitialized(); if (_receiving) - throw new InvalidOperationException("Adapter has already started receiving."); + throw new InvalidOperationException("TelegramChannelAdapter has already started receiving."); _receiving = true; if (_options.TransportMode == TransportMode.LongPolling) @@ -162,7 +105,6 @@ public Task StartReceivingAsync(CancellationToken ct) return Task.CompletedTask; } - /// public async Task StopReceivingAsync(CancellationToken ct) { if (_stopped) @@ -190,116 +132,210 @@ public async Task StopReceivingAsync(CancellationToken ct) _receiveLoop = null; } - _inbound.Writer.TryComplete(); + _inboundBuffer.Writer.TryComplete(); + } + + public async Task SendAsync(ConversationReference to, MessageContent content, CancellationToken ct) => + await SendCoreAsync(to, content, activityId: null, await RefreshBotCredentialAsync(ct), ct); + + public async Task UpdateAsync( + ConversationReference to, + string activityId, + MessageContent content, + CancellationToken ct) => + await SendCoreAsync(to, content, activityId, await RefreshBotCredentialAsync(ct), ct); + + public async Task DeleteAsync(ConversationReference to, string activityId, CancellationToken ct) + { + EnsureReady(); + ArgumentNullException.ThrowIfNull(to); + if (string.IsNullOrWhiteSpace(activityId)) + throw new ArgumentException("Activity id cannot be empty.", nameof(activityId)); + + var target = TelegramConversationTarget.ParseConversation(to); + var outboundActivity = TelegramConversationTarget.ParseOutboundActivityId(activityId); + if (target.ChatId != outboundActivity.ChatId) + throw new InvalidOperationException("Telegram activity id does not belong to the supplied conversation."); + + var credential = await RefreshBotCredentialAsync(ct); + if (string.IsNullOrWhiteSpace(credential.BotToken)) + throw new InvalidOperationException("Telegram bot credential could not be resolved."); + + await DeleteMessageAsync(credential.BotToken, outboundActivity.ChatId, outboundActivity.MessageId, ct); + } + + public async Task ContinueConversationAsync( + ConversationReference reference, + MessageContent content, + AuthContext auth, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(reference); + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(auth); + + if (auth.Kind == PrincipalKind.OnBehalfOfUser) + { + return EmitResult.Failed( + "principal_unsupported", + "Telegram Bot API does not support delegated user sends.", + capability: ComposeCapability.Unsupported); + } + + return await SendCoreAsync(reference, content, activityId: null, await RefreshBotCredentialAsync(ct), ct); } - /// - public async Task SendAsync(ConversationReference to, MessageContent content, CancellationToken ct) + public async Task BeginStreamingReplyAsync( + ConversationReference to, + MessageContent initial, + CancellationToken ct) { + ArgumentNullException.ThrowIfNull(to); + ArgumentNullException.ThrowIfNull(initial); + + var sent = await SendAsync(to, initial, ct); + if (!sent.Success) + throw new InvalidOperationException(sent.ErrorMessage); + + return new TelegramStreamingHandle( + this, + to.Clone(), + sent.SentActivityId, + initial.Clone(), + _options.TimeProvider, + TimeSpan.FromMilliseconds(Math.Max(_options.RecommendedStreamDebounceMs, 0))); + } + + public async Task HandleWebhookAsync(TelegramWebhookRequest request, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + EnsureReady(); + + if (!ValidateWebhookSecret(request.Headers)) + return new TelegramWebhookResponse(401, null, null, null); + + JsonDocument document; try { - EnsureReady(); - var target = TelegramConversationTarget.Parse(to); - var context = new ComposeContext + document = JsonDocument.Parse(request.Body ?? Array.Empty()); + } + catch (JsonException) + { + return new TelegramWebhookResponse(400, null, null, null); + } + + using (document) + { + var activity = NormalizeUpdate(document.RootElement, _binding!); + if (activity is null) + return new TelegramWebhookResponse(200, null, null, null); + + byte[] sanitizedPayload; + try { - Conversation = to.Clone(), - Capabilities = Capabilities, - }; - var payload = _composer.Compose(content, context); - if (payload.Attachment is null && string.IsNullOrWhiteSpace(payload.Text)) + sanitizedPayload = (await _payloadRedactor.RedactAsync(Channel, request.Body ?? Array.Empty(), ct)).SanitizedPayload; + } + catch (Exception ex) { - return EmitResult.Failed( - "telegram_empty_message", - "Telegram text sends require non-empty message content.", - capability: payload.Capability); + _logger.LogWarning(ex, "Telegram payload redaction failed closed."); + return new TelegramWebhookResponse(503, null, null, null); } - var botToken = await ResolveBotTokenAsync(ct); + activity.RawPayloadBlobRef = BuildBlobRef(sanitizedPayload); + if (_captureInboundActivities) + await _inboundBuffer.Writer.WriteAsync(activity, ct); - TelegramSentActivity sent; - if (payload.Attachment is null) + return new TelegramWebhookResponse(200, null, activity, sanitizedPayload); + } + } + + private async Task SendCoreAsync( + ConversationReference to, + MessageContent content, + string? activityId, + TelegramCredentialSnapshot credential, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(to); + ArgumentNullException.ThrowIfNull(content); + EnsureReady(); + + var capability = _composer.Evaluate(content, ComposeContextFor(to)); + if (capability == ComposeCapability.Unsupported) + return EmitResult.Failed("unsupported_content", "Telegram composer rejected the message.", capability: capability); + + if (string.IsNullOrWhiteSpace(credential.BotToken)) + return EmitResult.Failed("credential_resolution_failed", "Telegram bot credential could not be resolved.", capability: capability); + + try + { + var effectiveContent = content.Clone(); + if (effectiveContent.Disposition == MessageDisposition.Ephemeral) + effectiveContent.Disposition = MessageDisposition.Normal; + + var payload = _composer.Compose(effectiveContent, ComposeContextFor(to)); + TelegramConversationTarget target; + try { - sent = await _apiClient.SendMessageAsync( - botToken, - target.ChatId, - payload.Text, - payload.ReplyMarkup, - replyToMessageId: null, - ct); + target = TelegramConversationTarget.ParseConversation(to); } - else + catch (InvalidOperationException ex) { - var attachment = await ResolveAttachmentAsync(payload.Attachment, ct); - if (attachment is null) + _logger.LogWarning(ex, "Telegram conversation reference is invalid."); + return EmitResult.Failed("telegram_invalid_conversation", ex.Message, capability: capability); + } + + var reportedCapability = capability == ComposeCapability.Exact ? payload.Capability : capability; + + if (activityId is null) + { + if (payload.Attachment is null && string.IsNullOrWhiteSpace(payload.Text)) { return EmitResult.Failed( - "attachment_unavailable", - "Telegram attachment content could not be resolved.", + "telegram_empty_message", + "Telegram text sends require non-empty message content.", capability: payload.Capability); } - try + TelegramSentActivity sent; + if (payload.Attachment is null) { - sent = payload.Attachment.Kind == AttachmentKind.Image - ? await _apiClient.SendPhotoAsync( - botToken, - target.ChatId, - attachment, - payload.Text, - payload.ReplyMarkup, - replyToMessageId: null, - ct) - : await _apiClient.SendDocumentAsync( - botToken, - target.ChatId, - attachment, - payload.Text, - payload.ReplyMarkup, - replyToMessageId: null, - ct); + sent = await SendMessageAsync(credential.BotToken, target.ChatId, payload.Text, payload.ReplyMarkupJson, ct); } - finally + else { - attachment.Content?.Dispose(); + var attachment = await ResolveAttachmentAsync(payload.Attachment, ct); + if (attachment is null) + { + return EmitResult.Failed( + "attachment_unavailable", + "Telegram attachment content could not be resolved.", + capability: payload.Capability); + } + + try + { + sent = payload.Attachment.Kind == AttachmentKind.Image + ? await SendMediaAsync("sendPhoto", "photo", credential.BotToken, target.ChatId, attachment, payload.Text, payload.ReplyMarkupJson, ct) + : await SendMediaAsync("sendDocument", "document", credential.BotToken, target.ChatId, attachment, payload.Text, payload.ReplyMarkupJson, ct); + } + finally + { + attachment.Content?.Dispose(); + } } - } - return EmitResult.Sent(TelegramConversationTarget.BuildActivityId(sent.ChatId, sent.MessageId), payload.Capability); - } - catch (InvalidOperationException ex) when (ex.Message.Contains("credential", StringComparison.OrdinalIgnoreCase)) - { - return EmitResult.Failed("credential_resolution_failed", ex.Message); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Telegram send failed."); - return EmitResult.Failed("telegram_send_failed", SanitizeError(ex.Message)); - } - } + return EmitResult.Sent( + TelegramConversationTarget.BuildOutboundActivityId(sent.ChatId, sent.MessageId), + reportedCapability); + } - /// - public async Task UpdateAsync( - ConversationReference to, - string activityId, - MessageContent content, - CancellationToken ct) - { - try - { - EnsureReady(); - var target = TelegramConversationTarget.Parse(to); - var adapterActivity = TelegramConversationTarget.ParseActivityId(activityId); - var payload = _composer.Compose(content, new ComposeContext - { - Conversation = to.Clone(), - Capabilities = Capabilities, - }); if (string.IsNullOrWhiteSpace(payload.Text)) { return EmitResult.Failed( "telegram_empty_message", "Telegram text updates require non-empty message content.", - capability: payload.Capability); + capability: reportedCapability); } if (payload.Attachment is not null) @@ -310,104 +346,76 @@ public async Task UpdateAsync( capability: ComposeCapability.Degraded); } - var botToken = await ResolveBotTokenAsync(ct); - await _apiClient.EditMessageTextAsync( - botToken, - target.ChatId, - adapterActivity.MessageId, + var outboundActivity = TelegramConversationTarget.ParseOutboundActivityId(activityId); + if (target.ChatId != outboundActivity.ChatId) + { + return EmitResult.Failed( + "telegram_activity_mismatch", + "Telegram activity id does not belong to the supplied conversation.", + capability: payload.Capability); + } + + await EditMessageTextAsync( + credential.BotToken, + outboundActivity.ChatId, + outboundActivity.MessageId, payload.Text, - payload.ReplyMarkup, + payload.ReplyMarkupJson, ct); - return EmitResult.Sent(TelegramConversationTarget.BuildActivityId(target.ChatId, adapterActivity.MessageId), payload.Capability); + + return EmitResult.Sent( + TelegramConversationTarget.BuildOutboundActivityId(outboundActivity.ChatId, outboundActivity.MessageId), + reportedCapability); } - catch (Exception ex) + catch (TelegramApiException ex) { - _logger.LogWarning(ex, "Telegram update failed."); - return EmitResult.Failed("telegram_update_failed", SanitizeError(ex.Message)); + _logger.LogWarning(ex, "Telegram request failed."); + return EmitResult.Failed(ex.FailureCode, ex.Message, capability: capability); } } - /// - public async Task DeleteAsync(ConversationReference to, string activityId, CancellationToken ct) - { - EnsureReady(); - var target = TelegramConversationTarget.Parse(to); - var adapterActivity = TelegramConversationTarget.ParseActivityId(activityId); - var botToken = await ResolveBotTokenAsync(ct); - await _apiClient.DeleteMessageAsync(botToken, target.ChatId, adapterActivity.MessageId, ct); - } - - /// - public async Task ContinueConversationAsync( - ConversationReference reference, - MessageContent content, - Aevatar.GAgents.Channel.Abstractions.AuthContext auth, - CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(reference); - ArgumentNullException.ThrowIfNull(content); - ArgumentNullException.ThrowIfNull(auth); - - if (auth.Kind == PrincipalKind.OnBehalfOfUser) - { - return EmitResult.Failed( - "principal_unsupported", - "Telegram Bot API does not support delegated user sends.", - capability: ComposeCapability.Unsupported); - } - - return await SendAsync(reference, content, ct); - } - private async Task RunLongPollingLoopAsync(CancellationToken ct) { - var offset = 0; + long? offset = null; while (!ct.IsCancellationRequested) { - string botToken; + TelegramCredentialSnapshot credential; try { - botToken = await ResolveBotTokenAsync(ct); + credential = await RefreshBotCredentialAsync(ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } - catch (TaskCanceledException) when (ct.IsCancellationRequested) - { - break; - } - catch (InvalidOperationException ex) + + if (string.IsNullOrWhiteSpace(credential.BotToken)) { - _logger.LogError(ex, "Telegram long polling stopped because bot credentials could not be resolved."); + _logger.LogError("Telegram long polling stopped because bot credentials could not be resolved."); break; } try { - var updates = await _apiClient.GetUpdatesAsync( - botToken, - offset <= 0 ? null : offset, - _options.LongPollingTimeoutSeconds, - ct); - - foreach (var update in updates.OrderBy(static update => update.Id)) + var updates = await FetchUpdatesAsync(credential.BotToken, offset, ct); + foreach (var update in updates.OrderBy(GetUpdateId)) { - offset = Math.Max(offset, update.Id + 1); - if (_binding is null) + var updateId = GetUpdateId(update); + if (updateId.HasValue) + offset = updateId.Value + 1; + + var activity = NormalizeUpdate(update, _binding!); + if (activity is null || !_captureInboundActivities) continue; - await ProcessUpdateAsync(update, _binding, enqueue: true, ct); + + await _inboundBuffer.Writer.WriteAsync(activity, ct); } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } - catch (TaskCanceledException) when (ct.IsCancellationRequested) - { - break; - } - catch (ApiRequestException ex) when (IsTerminalAuthFailure(ex)) + catch (TelegramApiException ex) when (IsTerminalAuthFailure(ex)) { _logger.LogError(ex, "Telegram long polling stopped because bot authorization failed."); break; @@ -420,167 +428,232 @@ private async Task RunLongPollingLoopAsync(CancellationToken ct) } } - private async Task ProcessUpdateAsync( - Update update, - ChannelTransportBinding binding, - bool enqueue, - CancellationToken ct) + private ChatActivity? NormalizeUpdate(JsonElement update, ChannelTransportBinding binding) { - var activity = NormalizeUpdate(update, binding); - if (activity is null) - return null; + var updateId = GetUpdateId(update); + if (update.TryGetProperty("message", out var message)) + return ParseMessage(updateId, message, binding); + if (update.TryGetProperty("edited_message", out var editedMessage)) + return ParseMessage(updateId, editedMessage, binding); + if (update.TryGetProperty("channel_post", out var channelPost)) + return ParseMessage(updateId, channelPost, binding); + if (update.TryGetProperty("edited_channel_post", out var editedChannelPost)) + return ParseMessage(updateId, editedChannelPost, binding); + if (update.TryGetProperty("callback_query", out var callback)) + return ParseCallback(updateId, callback, binding); - if (enqueue && _receiving) - await _inbound.Writer.WriteAsync(activity, ct); - - return activity; + return null; } - private ChatActivity? NormalizeUpdate(Update update, ChannelTransportBinding binding) + private ChatActivity? ParseMessage(long? updateId, JsonElement message, ChannelTransportBinding binding) { - if (TryNormalizeMessage(update.Id, update.Message, binding, out var messageActivity)) - return messageActivity; - if (TryNormalizeMessage(update.Id, update.ChannelPost, binding, out var channelActivity)) - return channelActivity; - if (TryNormalizeMessage(update.Id, update.EditedMessage, binding, out var editedActivity)) - return editedActivity; - if (TryNormalizeMessage(update.Id, update.EditedChannelPost, binding, out var editedChannelActivity)) - return editedChannelActivity; - if (TryNormalizeCallback(update.Id, update.CallbackQuery, binding, out var callbackActivity)) - return callbackActivity; + if (TryReadNestedBoolean(message, "from", "is_bot") == true) + return null; - return null; - } + var chatId = TryReadNestedInt64(message, "chat", "id"); + if (!chatId.HasValue) + return null; - private bool TryNormalizeMessage( - int updateId, - Message? message, - ChannelTransportBinding binding, - out ChatActivity? activity) - { - activity = null; - if (message is null) - return false; - if (message.From?.IsBot == true) - return false; + var text = TryReadString(message, "text") ?? TryReadString(message, "caption") ?? string.Empty; + var attachments = ExtractAttachments(message); + if (string.IsNullOrWhiteSpace(text) && attachments.Count == 0) + return null; - var text = !string.IsNullOrWhiteSpace(message.Text) - ? message.Text - : message.Caption; - if (string.IsNullOrWhiteSpace(text)) - return false; + var senderId = TryReadNestedInt64(message, "from", "id")?.ToString(CultureInfo.InvariantCulture) + ?? TryReadNestedInt64(message, "sender_chat", "id")?.ToString(CultureInfo.InvariantCulture) + ?? chatId.Value.ToString(CultureInfo.InvariantCulture); + var senderName = TryReadNestedString(message, "from", "username") + ?? TryReadNestedString(message, "from", "first_name") + ?? TryReadNestedString(message, "sender_chat", "title") + ?? TryReadNestedString(message, "chat", "title") + ?? senderId; - var conversation = CreateConversation(binding.Bot.Bot, message.Chat); - var senderId = message.From is not null - ? message.From.Id.ToString() - : message.SenderChat?.Id.ToString() ?? message.Chat.Id.ToString(); - var senderName = message.From?.Username - ?? message.From?.FirstName - ?? message.SenderChat?.Title - ?? message.Chat.Title - ?? senderId; - activity = new ChatActivity - { - Id = $"telegram:update:{updateId}", + var content = new MessageContent + { + Text = text, + Disposition = MessageDisposition.Normal, + }; + content.Attachments.AddRange(attachments); + + return new ChatActivity + { + Id = ChatActivity.BuildActivityId(Channel, BuildDeliveryKey(updateId, $"message:{chatId}:{TryReadInt32(message, "message_id")}")), Type = ActivityType.Message, ChannelId = Channel.Clone(), Bot = binding.Bot.Bot.Clone(), - Conversation = conversation, + Conversation = CreateConversation(binding.Bot.Bot, message), From = new ParticipantRef { CanonicalId = senderId, DisplayName = senderName, }, - Timestamp = Timestamp.FromDateTime(message.Date.ToUniversalTime()), - Content = new MessageContent - { - Text = text, - Disposition = MessageDisposition.Normal, - }, - ReplyToActivityId = message.ReplyToMessage is not null - ? TelegramConversationTarget.BuildActivityId(message.Chat.Id, message.ReplyToMessage.MessageId) - : string.Empty, - RawPayloadBlobRef = string.Empty, + Timestamp = Timestamp.FromDateTimeOffset(ParseTimestamp(message)), + Content = content, + ReplyToActivityId = BuildReplyToActivityId(message, chatId.Value), }; - return true; } - private bool TryNormalizeCallback( - int updateId, - CallbackQuery? callback, - ChannelTransportBinding binding, - out ChatActivity? activity) + private ChatActivity? ParseCallback(long? updateId, JsonElement callback, ChannelTransportBinding binding) { - activity = null; - if (callback?.Message is null) - return false; + if (!callback.TryGetProperty("message", out var message)) + return null; - var data = string.IsNullOrWhiteSpace(callback.Data) ? callback.GameShortName : callback.Data; + var chatId = TryReadNestedInt64(message, "chat", "id"); + var messageId = TryReadInt32(message, "message_id"); + if (!chatId.HasValue || !messageId.HasValue) + return null; + + var data = TryReadString(callback, "data") ?? TryReadString(callback, "game_short_name"); if (string.IsNullOrWhiteSpace(data)) - return false; + return null; - var conversation = CreateConversation(binding.Bot.Bot, callback.Message.Chat); - activity = new ChatActivity + var sourceMessageId = TelegramConversationTarget.BuildOutboundActivityId(chatId.Value, messageId.Value); + return new ChatActivity { - Id = $"telegram:update:{updateId}", + Id = ChatActivity.BuildActivityId(Channel, BuildDeliveryKey(updateId, $"callback:{TryReadString(callback, "id") ?? sourceMessageId}")), Type = ActivityType.CardAction, ChannelId = Channel.Clone(), Bot = binding.Bot.Bot.Clone(), - Conversation = conversation, + Conversation = CreateConversation(binding.Bot.Bot, message), From = new ParticipantRef { - CanonicalId = callback.From.Id.ToString(), - DisplayName = callback.From.Username ?? callback.From.FirstName ?? callback.From.Id.ToString(), + CanonicalId = TryReadNestedInt64(callback, "from", "id")?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + DisplayName = TryReadNestedString(callback, "from", "username") + ?? TryReadNestedString(callback, "from", "first_name") + ?? TryReadNestedInt64(callback, "from", "id")?.ToString(CultureInfo.InvariantCulture) + ?? string.Empty, }, - Timestamp = Timestamp.FromDateTime(callback.Message.Date.ToUniversalTime()), + Timestamp = Timestamp.FromDateTimeOffset(ParseTimestamp(message)), Content = new MessageContent { - Text = data, Disposition = MessageDisposition.Normal, + CardAction = new CardActionSubmission + { + ActionId = data, + SubmittedValue = data, + SourceMessageId = sourceMessageId, + }, }, - ReplyToActivityId = TelegramConversationTarget.BuildActivityId(callback.Message.Chat.Id, callback.Message.MessageId), - RawPayloadBlobRef = string.Empty, + ReplyToActivityId = sourceMessageId, }; - return true; } - private static ConversationReference CreateConversation(BotInstanceId bot, Chat chat) => - chat.Type switch + private static List ExtractAttachments(JsonElement message) + { + var attachments = new List(); + + if (message.TryGetProperty("photo", out var photos) && photos.ValueKind == JsonValueKind.Array) + { + var lastPhoto = photos.EnumerateArray().LastOrDefault(); + if (lastPhoto.ValueKind != JsonValueKind.Undefined && TryReadString(lastPhoto, "file_id") is { Length: > 0 } photoId) + { + attachments.Add(new AttachmentRef + { + AttachmentId = photoId, + Kind = AttachmentKind.Image, + Name = "photo.jpg", + ContentType = "image/jpeg", + SizeBytes = TryReadInt64(lastPhoto, "file_size") ?? 0, + }); + } + } + + if (message.TryGetProperty("document", out var document) && TryReadString(document, "file_id") is { Length: > 0 } documentId) + { + attachments.Add(new AttachmentRef + { + AttachmentId = documentId, + Kind = AttachmentKind.File, + Name = TryReadString(document, "file_name") ?? "document.bin", + ContentType = TryReadString(document, "mime_type") ?? "application/octet-stream", + SizeBytes = TryReadInt64(document, "file_size") ?? 0, + }); + } + + if (message.TryGetProperty("audio", out var audio) && TryReadString(audio, "file_id") is { Length: > 0 } audioId) + { + attachments.Add(new AttachmentRef + { + AttachmentId = audioId, + Kind = AttachmentKind.Audio, + Name = TryReadString(audio, "file_name") ?? "audio.bin", + ContentType = TryReadString(audio, "mime_type") ?? "audio/mpeg", + SizeBytes = TryReadInt64(audio, "file_size") ?? 0, + }); + } + + if (message.TryGetProperty("video", out var video) && TryReadString(video, "file_id") is { Length: > 0 } videoId) + { + attachments.Add(new AttachmentRef + { + AttachmentId = videoId, + Kind = AttachmentKind.Video, + Name = TryReadString(video, "file_name") ?? "video.mp4", + ContentType = TryReadString(video, "mime_type") ?? "video/mp4", + SizeBytes = TryReadInt64(video, "file_size") ?? 0, + }); + } + + if (message.TryGetProperty("voice", out var voice) && TryReadString(voice, "file_id") is { Length: > 0 } voiceId) + { + attachments.Add(new AttachmentRef + { + AttachmentId = voiceId, + Kind = AttachmentKind.Audio, + Name = "voice.ogg", + ContentType = TryReadString(voice, "mime_type") ?? "audio/ogg", + SizeBytes = TryReadInt64(voice, "file_size") ?? 0, + }); + } + + return attachments; + } + + private static ConversationReference CreateConversation(BotInstanceId bot, JsonElement message) + { + var chatType = TryReadNestedString(message, "chat", "type"); + var chatId = TryReadNestedInt64(message, "chat", "id")?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; + + return chatType switch { - ChatType.Private => ConversationReference.TelegramPrivate(bot, chat.Id.ToString()), - ChatType.Group => ConversationReference.TelegramGroup(bot, chat.Id.ToString()), - ChatType.Supergroup => ConversationReference.TelegramGroup(bot, chat.Id.ToString(), isSupergroup: true), - ChatType.Channel => ConversationReference.TelegramChannel(bot, chat.Id.ToString()), - _ => ConversationReference.TelegramPrivate(bot, chat.Id.ToString()), + "group" => ConversationReference.TelegramGroup(bot, chatId), + "supergroup" => ConversationReference.TelegramGroup(bot, chatId, isSupergroup: true), + "channel" => ConversationReference.TelegramChannel(bot, chatId), + _ => ConversationReference.TelegramPrivate(bot, chatId), }; + } - private bool ValidateWebhookSecret(string? secretToken, ChannelTransportBinding binding) + private bool ValidateWebhookSecret(IReadOnlyDictionary? headers) { - if (string.IsNullOrWhiteSpace(binding.VerificationToken)) + if (_binding is null || string.IsNullOrWhiteSpace(_binding.VerificationToken)) return true; - if (string.IsNullOrEmpty(secretToken)) + if (!TryGetHeader(headers, TelegramChannelDefaults.SecretHeaderName, out var providedSecret) || + string.IsNullOrWhiteSpace(providedSecret)) { _logger.LogWarning("Telegram webhook secret token mismatch."); return false; } - var expected = Encoding.UTF8.GetBytes(binding.VerificationToken); - var actual = Encoding.UTF8.GetBytes(secretToken); - if (expected.Length == actual.Length && CryptographicOperations.FixedTimeEquals(expected, actual)) + var expectedHash = SHA256.HashData(Encoding.UTF8.GetBytes(_binding.VerificationToken)); + var providedHash = SHA256.HashData(Encoding.UTF8.GetBytes(providedSecret.Trim())); + if (CryptographicOperations.FixedTimeEquals(expectedHash, providedHash)) return true; _logger.LogWarning("Telegram webhook secret token mismatch."); return false; } - private async Task ResolveBotTokenAsync(CancellationToken ct) + private async Task RefreshBotCredentialAsync(CancellationToken ct) { - if (_binding is null) - throw new InvalidOperationException("Adapter binding is unavailable."); + EnsureInitialized(); + var secret = await _credentialProvider.ResolveBotCredentialAsync(_binding!, ct); + var refreshed = TelegramCredentialSnapshot.Parse(secret); + if (string.IsNullOrWhiteSpace(refreshed.BotToken)) + return _botCredential; - return await _credentialProvider.ResolveBotCredentialAsync(_binding, ct) - ?? throw new InvalidOperationException("Telegram bot credential could not be resolved."); + _botCredential = refreshed; + return refreshed; } private async Task ResolveAttachmentAsync(AttachmentRef attachment, CancellationToken ct) @@ -591,205 +664,422 @@ private async Task ResolveBotTokenAsync(CancellationToken ct) return await _attachmentResolver.ResolveAsync(attachment, ct); } + private async Task> FetchUpdatesAsync( + string botToken, + long? offset, + CancellationToken ct) + { + var body = new JsonObject + { + ["timeout"] = _options.LongPollingTimeoutSeconds, + }; + if (offset.HasValue) + body["offset"] = offset.Value; + + body["allowed_updates"] = new JsonArray(TelegramChannelDefaults.AllowedUpdateTypes.Select(static updateType => (JsonNode?)updateType).ToArray()); + + var response = await SendTelegramRequestAsync(botToken, "getUpdates", BuildJsonContent(body), ct); + if (!response.TryGetProperty("result", out var result) || result.ValueKind != JsonValueKind.Array) + return Array.Empty(); + + return result.EnumerateArray().Select(static item => item.Clone()).ToArray(); + } + + private async Task SendMessageAsync( + string botToken, + long chatId, + string text, + string? replyMarkupJson, + CancellationToken ct) + { + var body = new JsonObject + { + ["chat_id"] = chatId, + ["text"] = text, + }; + AppendReplyMarkup(body, replyMarkupJson); + + var response = await SendTelegramRequestAsync(botToken, "sendMessage", BuildJsonContent(body), ct); + return ParseSentActivity(response, chatId); + } + + private async Task SendMediaAsync( + string methodName, + string mediaFieldName, + string botToken, + long chatId, + TelegramAttachmentContent attachment, + string? caption, + string? replyMarkupJson, + CancellationToken ct) + { + HttpContent content; + if (!string.IsNullOrWhiteSpace(attachment.ExternalUrl) || !string.IsNullOrWhiteSpace(attachment.TelegramFileId)) + { + var body = new JsonObject + { + ["chat_id"] = chatId, + [mediaFieldName] = !string.IsNullOrWhiteSpace(attachment.TelegramFileId) + ? attachment.TelegramFileId + : attachment.ExternalUrl, + }; + if (!string.IsNullOrWhiteSpace(caption)) + body["caption"] = caption; + AppendReplyMarkup(body, replyMarkupJson); + content = BuildJsonContent(body); + } + else + { + if (attachment.Content is null) + throw new InvalidOperationException("Telegram attachment must provide content, external URL, or file id."); + + var multipart = new MultipartFormDataContent(); + multipart.Add(new StringContent(chatId.ToString(CultureInfo.InvariantCulture)), "chat_id"); + if (!string.IsNullOrWhiteSpace(caption)) + multipart.Add(new StringContent(caption), "caption"); + if (!string.IsNullOrWhiteSpace(replyMarkupJson)) + multipart.Add(new StringContent(replyMarkupJson), "reply_markup"); + + var fileContent = new StreamContent(attachment.Content); + if (!string.IsNullOrWhiteSpace(attachment.ContentType)) + fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse(attachment.ContentType); + multipart.Add(fileContent, mediaFieldName, string.IsNullOrWhiteSpace(attachment.FileName) ? "attachment.bin" : attachment.FileName); + content = multipart; + } + + var response = await SendTelegramRequestAsync(botToken, methodName, content, ct); + return ParseSentActivity(response, chatId); + } + + private async Task EditMessageTextAsync( + string botToken, + long chatId, + int messageId, + string text, + string? replyMarkupJson, + CancellationToken ct) + { + var body = new JsonObject + { + ["chat_id"] = chatId, + ["message_id"] = messageId, + ["text"] = text, + }; + AppendReplyMarkup(body, replyMarkupJson); + + await SendTelegramRequestAsync(botToken, "editMessageText", BuildJsonContent(body), ct); + } + + private async Task DeleteMessageAsync( + string botToken, + long chatId, + int messageId, + CancellationToken ct) + { + var body = new JsonObject + { + ["chat_id"] = chatId, + ["message_id"] = messageId, + }; + + await SendTelegramRequestAsync(botToken, "deleteMessage", BuildJsonContent(body), ct); + } + + private async Task SendTelegramRequestAsync( + string botToken, + string methodName, + HttpContent content, + CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Post, $"/bot{botToken}/{methodName}") + { + Content = content, + }; + using var response = await _httpClient.SendAsync(request, ct); + var responseText = response.Content is null ? string.Empty : await response.Content.ReadAsStringAsync(ct); + if (!response.IsSuccessStatusCode) + throw CreateApiException((int)response.StatusCode, responseText); + + if (string.IsNullOrWhiteSpace(responseText)) + return default; + + using var document = JsonDocument.Parse(responseText); + var root = document.RootElement.Clone(); + if (root.TryGetProperty("ok", out var okProperty) && + okProperty.ValueKind == JsonValueKind.False) + { + throw CreateApiException(null, responseText); + } + + return root; + } + + private ComposeContext ComposeContextFor(ConversationReference to) => new() + { + Conversation = to.Clone(), + Capabilities = Capabilities.Clone(), + }; + private void EnsureInitialized() { if (!_initialized || _binding is null) - throw new InvalidOperationException("Adapter must be initialized before startup."); + throw new InvalidOperationException("TelegramChannelAdapter.InitializeAsync must run first."); } private void EnsureReady() { EnsureInitialized(); - if (!_receiving) - throw new InvalidOperationException("Adapter must start receiving before outbound operations."); + if (!_receiving || _stopped) + throw new InvalidOperationException("TelegramChannelAdapter must be started before use."); } - private static string SanitizeError(string message) => - string.IsNullOrWhiteSpace(message) ? "telegram_error" : message.Replace(Environment.NewLine, " ", StringComparison.Ordinal).Trim(); + private static void AppendReplyMarkup(JsonObject body, string? replyMarkupJson) + { + if (string.IsNullOrWhiteSpace(replyMarkupJson)) + return; + + body["reply_markup"] = JsonNode.Parse(replyMarkupJson); + } - private static bool IsTerminalAuthFailure(ApiRequestException ex) => ex.ErrorCode is 401 or 403; + private static HttpContent BuildJsonContent(JsonObject body) => + new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); - private readonly record struct TelegramConversationTarget(long ChatId, int MessageId) + private static TelegramSentActivity ParseSentActivity(JsonElement response, long fallbackChatId) { - public static TelegramConversationTarget Parse(ConversationReference reference) - { - ArgumentNullException.ThrowIfNull(reference); - if (!string.Equals(reference.Channel.Value, "telegram", StringComparison.Ordinal)) - throw new InvalidOperationException("Conversation reference does not target Telegram."); + var chatId = TryReadNestedInt64(response, "result", "chat", "id") ?? fallbackChatId; + var messageId = TryReadNestedInt32(response, "result", "message_id") + ?? throw new TelegramApiException("telegram_missing_message_id", "Telegram response did not include a message id.", null); + return new TelegramSentActivity(chatId, messageId); + } - var segments = reference.CanonicalKey.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (segments.Length < 3 || !long.TryParse(segments[^1], out var chatId)) - throw new InvalidOperationException("Telegram canonical key must end with the numeric chat id."); - return new TelegramConversationTarget(chatId, 0); + private static string BuildDeliveryKey(long? updateId, string fallback) => + updateId.HasValue ? $"update:{updateId.Value}" : fallback; + + private static string BuildReplyToActivityId(JsonElement message, long chatId) + { + var replyMessageId = TryReadNestedInt32(message, "reply_to_message", "message_id"); + return replyMessageId.HasValue + ? TelegramConversationTarget.BuildOutboundActivityId(chatId, replyMessageId.Value) + : string.Empty; + } + + private static DateTimeOffset ParseTimestamp(JsonElement message) + { + var unixSeconds = TryReadInt64(message, "date"); + return unixSeconds.HasValue + ? DateTimeOffset.FromUnixTimeSeconds(unixSeconds.Value) + : DateTimeOffset.UnixEpoch; + } + + private static long? GetUpdateId(JsonElement update) => TryReadInt64(update, "update_id"); + + private static bool IsTerminalAuthFailure(TelegramApiException ex) => ex.ErrorCode is 401 or 403; + + private static string BuildBlobRef(byte[] sanitizedPayload) + { + var hash = SHA256.HashData(sanitizedPayload); + return $"telegram-raw:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private static TelegramApiException CreateApiException(int? statusCode, string body) + { + var errorCode = ParsePlatformErrorCode(body) ?? statusCode; + return new TelegramApiException( + MapErrorCode(statusCode, body), + BuildSanitizedError(statusCode, body), + errorCode); + } + + private static int? ParsePlatformErrorCode(string body) + { + if (string.IsNullOrWhiteSpace(body)) + return null; + + try + { + using var document = JsonDocument.Parse(body); + return TryReadInt32(document.RootElement, "error_code"); } + catch (JsonException) + { + return null; + } + } + + private static string MapErrorCode(int? statusCode, string body) + { + var platformCode = ParsePlatformErrorCode(body); + if (platformCode.HasValue) + return $"telegram_{platformCode.Value}"; + + return statusCode.HasValue + ? $"http_{statusCode.Value}" + : "telegram_request_failed"; + } - public static TelegramConversationTarget ParseActivityId(string activityId) + private static string BuildSanitizedError(int? statusCode, string body) + { + if (!string.IsNullOrWhiteSpace(body)) { - ArgumentNullException.ThrowIfNull(activityId); - var parts = activityId.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length == 4 && - string.Equals(parts[0], "telegram", StringComparison.Ordinal) && - string.Equals(parts[1], "message", StringComparison.Ordinal) && - long.TryParse(parts[2], out var chatId) && - int.TryParse(parts[3], out var messageId)) + try + { + using var document = JsonDocument.Parse(body); + var description = TryReadString(document.RootElement, "description") ?? TryReadString(document.RootElement, "message"); + if (!string.IsNullOrWhiteSpace(description)) + return statusCode.HasValue ? $"{statusCode.Value} {description}" : description; + } + catch (JsonException) { - return new TelegramConversationTarget(chatId, messageId); } - - throw new InvalidOperationException("Telegram activity id is invalid."); } - public static string BuildActivityId(long chatId, int messageId) => $"telegram:message:{chatId}:{messageId}"; - } - - private sealed class TelegramStreamingHandle : StreamingHandle - { - private readonly TelegramChannelAdapter _adapter; - private readonly ConversationReference _reference; - private readonly string _activityId; - private readonly TimeProvider _timeProvider; - private readonly TimeSpan _debounce; - private readonly SemaphoreSlim _writeGate = new(1, 1); - private readonly HashSet _acceptedSequenceNumbers = []; - private readonly Dictionary _chunks = new(); - private string _currentText; - private bool _completed; - private CancellationTokenSource? _flushCts; - private long _flushGeneration; - - public TelegramStreamingHandle( - TelegramChannelAdapter adapter, - ConversationReference reference, - string activityId, - string currentText, - TimeProvider timeProvider, - TimeSpan debounce) - { - _adapter = adapter; - _reference = reference; - _activityId = activityId; - _currentText = currentText; - _timeProvider = timeProvider; - _debounce = debounce; - } + return statusCode.HasValue + ? $"{statusCode.Value} telegram request failed" + : "telegram request failed"; + } - public override async Task AppendAsync(StreamChunk chunk) - { - ArgumentNullException.ThrowIfNull(chunk); + private static bool TryGetHeader(IReadOnlyDictionary? headers, string headerName, out string value) + { + value = string.Empty; + if (headers is null) + return false; - CancellationTokenSource? previousFlush = null; - await _writeGate.WaitAsync(CancellationToken.None); - try - { - if (_completed || !_acceptedSequenceNumbers.Add(chunk.SequenceNumber)) - return; + if (headers.TryGetValue(headerName, out value!)) + return true; - _chunks[chunk.SequenceNumber] = chunk.Delta; - previousFlush = _flushCts; - _flushCts = new CancellationTokenSource(); - _ = FlushLaterAsync(++_flushGeneration, _flushCts.Token); - } - finally + foreach (var header in headers) + { + if (string.Equals(header.Key, headerName, StringComparison.OrdinalIgnoreCase)) { - _writeGate.Release(); + value = header.Value; + return true; } + } + + return false; + } + + private static string? TryReadString(JsonElement element, string propertyName) => + element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + private static int? TryReadInt32(JsonElement element, string propertyName) => + element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var value) + ? value + : null; - CancelPendingFlush(previousFlush); + private static long? TryReadInt64(JsonElement element, string propertyName) => + element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var value) + ? value + : null; + + private static bool? TryReadNestedBoolean(JsonElement element, params string[] segments) + { + var current = element; + foreach (var segment in segments) + { + if (!current.TryGetProperty(segment, out current)) + return null; } - public override async Task CompleteAsync(MessageContent final) + return current.ValueKind switch { - ArgumentNullException.ThrowIfNull(final); + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null, + }; + } - Task? finalWrite = null; - await _writeGate.WaitAsync(CancellationToken.None); - try - { - if (_completed) - return; - - _completed = true; - CancelPendingFlush(_flushCts); - _flushCts = null; - finalWrite = _adapter.UpdateAsync(_reference, _activityId, final, CancellationToken.None); - await finalWrite; - } - finally - { - _writeGate.Release(); - } + private static string? TryReadNestedString(JsonElement element, params string[] segments) + { + var current = element; + foreach (var segment in segments) + { + if (!current.TryGetProperty(segment, out current)) + return null; } - public override async ValueTask DisposeAsync() + return current.ValueKind == JsonValueKind.String + ? current.GetString() + : null; + } + + private static long? TryReadNestedInt64(JsonElement element, params string[] segments) + { + var current = element; + foreach (var segment in segments) { - Task? interruptedWrite = null; - await _writeGate.WaitAsync(CancellationToken.None); - try - { - if (_completed) - return; + if (!current.TryGetProperty(segment, out current)) + return null; + } - _completed = true; - CancelPendingFlush(_flushCts); - _flushCts = null; - var interrupted = new MessageContent - { - Text = string.IsNullOrWhiteSpace(_currentText) ? "(interrupted)" : $"{_currentText}\n\n(interrupted)", - Disposition = MessageDisposition.Normal, - }; - interruptedWrite = _adapter.UpdateAsync(_reference, _activityId, interrupted, CancellationToken.None); - await interruptedWrite; - } - catch - { - } - finally - { - _writeGate.Release(); - } + return current.ValueKind == JsonValueKind.Number && current.TryGetInt64(out var value) + ? value + : null; + } + + private static int? TryReadNestedInt32(JsonElement element, params string[] segments) + { + var current = element; + foreach (var segment in segments) + { + if (!current.TryGetProperty(segment, out current)) + return null; } - private async Task FlushLaterAsync(long generation, CancellationToken ct) + return current.ValueKind == JsonValueKind.Number && current.TryGetInt32(out var value) + ? value + : null; + } + + private readonly record struct TelegramConversationTarget(long ChatId, int MessageId) + { + public static TelegramConversationTarget ParseConversation(ConversationReference reference) { - try - { - if (_debounce > TimeSpan.Zero) - await Task.Delay(_debounce, _timeProvider, ct); + ArgumentNullException.ThrowIfNull(reference); + if (!string.Equals(reference.Channel.Value, TelegramChannel.Value, StringComparison.Ordinal)) + throw new InvalidOperationException("Conversation reference does not target Telegram."); - await _writeGate.WaitAsync(ct); - try - { - if (_completed || ct.IsCancellationRequested || generation != _flushGeneration || _chunks.Count == 0) - return; + var segments = reference.CanonicalKey.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length < 3 || !long.TryParse(segments[^1], out var chatId)) + throw new InvalidOperationException("Telegram canonical key must end with the numeric chat id."); - _currentText += string.Concat(_chunks.OrderBy(static pair => pair.Key).Select(static pair => pair.Value)); - _chunks.Clear(); - await _adapter.UpdateAsync(_reference, _activityId, new MessageContent - { - Text = _currentText, - Disposition = MessageDisposition.Normal, - }, ct); - } - finally - { - _writeGate.Release(); - } - } - catch (OperationCanceledException) - { - } - catch + return new TelegramConversationTarget(chatId, 0); + } + + public static TelegramConversationTarget ParseOutboundActivityId(string activityId) + { + ArgumentNullException.ThrowIfNull(activityId); + var parts = activityId.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 4 && + string.Equals(parts[0], "telegram", StringComparison.Ordinal) && + string.Equals(parts[1], "message", StringComparison.Ordinal) && + long.TryParse(parts[2], out var chatId) && + int.TryParse(parts[3], out var messageId)) { + return new TelegramConversationTarget(chatId, messageId); } + + throw new InvalidOperationException("Telegram activity id is invalid."); } - private static void CancelPendingFlush(CancellationTokenSource? flushCts) - { - if (flushCts is null) - return; + public static string BuildOutboundActivityId(long chatId, int messageId) => $"telegram:message:{chatId}:{messageId}"; + } + + private readonly record struct TelegramSentActivity(long ChatId, int MessageId); - flushCts.Cancel(); - flushCts.Dispose(); + private sealed class TelegramApiException : Exception + { + public TelegramApiException(string failureCode, string message, int? errorCode) + : base(message) + { + FailureCode = failureCode; + ErrorCode = errorCode; } + + public string FailureCode { get; } + + public int? ErrorCode { get; } } } diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelDefaults.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelDefaults.cs new file mode 100644 index 000000000..1d50eb3ac --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelDefaults.cs @@ -0,0 +1,19 @@ +namespace Aevatar.GAgents.Channel.Telegram; + +internal static class TelegramChannelDefaults +{ + public const string HttpClientName = "Aevatar.GAgents.Channel.Telegram"; + + public const string SecretHeaderName = "X-Telegram-Bot-Api-Secret-Token"; + + public static readonly Uri DefaultBaseAddress = new("https://api.telegram.org", UriKind.Absolute); + + public static readonly string[] AllowedUpdateTypes = + [ + "message", + "edited_message", + "channel_post", + "edited_channel_post", + "callback_query", + ]; +} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelServiceCollectionExtensions.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelServiceCollectionExtensions.cs new file mode 100644 index 000000000..8eb6a4d59 --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelServiceCollectionExtensions.cs @@ -0,0 +1,36 @@ +using Aevatar.GAgents.Channel.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.GAgents.Channel.Telegram; + +public static class TelegramChannelServiceCollectionExtensions +{ + public static IServiceCollection AddTelegramChannel(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddHttpClient(TelegramChannelDefaults.HttpClientName, client => + { + client.BaseAddress = TelegramChannelDefaults.DefaultBaseAddress; + }); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(sp => new TelegramChannelAdapter( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService().CreateClient(TelegramChannelDefaults.HttpClientName), + sp.GetService(), + sp.GetRequiredService())); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton>(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); + + return services; + } +} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramCredentialSnapshot.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramCredentialSnapshot.cs new file mode 100644 index 000000000..464489722 --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramCredentialSnapshot.cs @@ -0,0 +1,39 @@ +using System.Text.Json; + +namespace Aevatar.GAgents.Channel.Telegram; + +internal sealed record TelegramCredentialSnapshot(string BotToken) +{ + public static TelegramCredentialSnapshot Parse(string? rawSecret) + { + if (string.IsNullOrWhiteSpace(rawSecret)) + return new TelegramCredentialSnapshot(string.Empty); + + var trimmed = rawSecret.Trim(); + if (!trimmed.StartsWith("{", StringComparison.Ordinal)) + return new TelegramCredentialSnapshot(trimmed); + + try + { + using var document = JsonDocument.Parse(trimmed); + var root = document.RootElement; + return new TelegramCredentialSnapshot( + ReadFirst(root, "bot_token", "token", "access_token")); + } + catch (JsonException) + { + return new TelegramCredentialSnapshot(trimmed); + } + } + + private static string ReadFirst(JsonElement root, params string[] names) + { + foreach (var name in names) + { + if (root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String) + return property.GetString()?.Trim() ?? string.Empty; + } + + return string.Empty; + } +} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramMessageComposer.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramMessageComposer.cs index 2836f398b..21b1faf5c 100644 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramMessageComposer.cs +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramMessageComposer.cs @@ -1,22 +1,38 @@ +using System.Globalization; using System.Text; +using System.Text.Json; using Aevatar.GAgents.Channel.Abstractions; -using Telegram.Bot.Types.ReplyMarkups; namespace Aevatar.GAgents.Channel.Telegram; -/// -/// Composes channel-agnostic message intent into Telegram-native text, inline keyboards, and one optional upload target. -/// -public sealed class TelegramMessageComposer : IMessageComposer +public sealed class TelegramMessageComposer : IMessageComposer { private const int TelegramTextLimit = 4096; private const int TelegramCaptionLimit = 1024; + private const int CallbackDataMaxBytes = 64; + + public static readonly ChannelCapabilities DefaultCapabilities = new() + { + SupportsEphemeral = false, + SupportsEdit = true, + SupportsDelete = true, + SupportsThread = false, + Streaming = StreamingSupport.EditLoopRateLimited, + SupportsFiles = true, + MaxMessageLength = TelegramTextLimit, + SupportsActionButtons = true, + SupportsConfirmDialog = false, + SupportsModal = false, + SupportsMention = false, + SupportsTyping = false, + SupportsReactions = false, + RecommendedStreamDebounceMs = 3000, + Transport = TransportMode.Webhook, + }; - /// public ChannelId Channel { get; } = ChannelId.From("telegram"); - /// - public TelegramNativePayload Compose(MessageContent intent, ComposeContext context) + public TelegramOutboundMessage Compose(MessageContent intent, ComposeContext context) { ArgumentNullException.ThrowIfNull(intent); ArgumentNullException.ThrowIfNull(context); @@ -24,19 +40,18 @@ public TelegramNativePayload Compose(MessageContent intent, ComposeContext conte var capability = Evaluate(intent, context); var attachment = intent.Attachments.Count > 0 ? intent.Attachments[0].Clone() : null; var textLimit = attachment is null - ? ResolveTextLimit(context.Capabilities.MaxMessageLength, TelegramTextLimit) - : ResolveTextLimit(Math.Min(context.Capabilities.MaxMessageLength, TelegramCaptionLimit), TelegramCaptionLimit); + ? ResolveTextLimit(context.Capabilities?.MaxMessageLength ?? DefaultCapabilities.MaxMessageLength, TelegramTextLimit) + : ResolveTextLimit(Math.Min(context.Capabilities?.MaxMessageLength ?? DefaultCapabilities.MaxMessageLength, TelegramCaptionLimit), TelegramCaptionLimit); var text = BuildRenderedText(intent, textLimit); - var replyMarkup = intent.Actions.Count == 0 + var replyMarkupJson = intent.Actions.Count == 0 ? null - : BuildInlineKeyboard(intent.Actions, context.Capabilities.SupportsActionButtons); + : BuildInlineKeyboardJson(intent.Actions, context.Capabilities?.SupportsActionButtons ?? DefaultCapabilities.SupportsActionButtons); - return new TelegramNativePayload(text, replyMarkup, attachment, capability); + return new TelegramOutboundMessage(text, replyMarkupJson, attachment, capability); } object IMessageComposer.Compose(MessageContent intent, ComposeContext context) => Compose(intent, context); - /// public ComposeCapability Evaluate(MessageContent intent, ComposeContext context) { ArgumentNullException.ThrowIfNull(intent); @@ -44,24 +59,25 @@ public ComposeCapability Evaluate(MessageContent intent, ComposeContext context) var degraded = false; - if (intent.Disposition == MessageDisposition.Ephemeral && !context.Capabilities.SupportsEphemeral) + var capabilities = context.Capabilities ?? DefaultCapabilities; + if (intent.Disposition == MessageDisposition.Ephemeral && !capabilities.SupportsEphemeral) degraded = true; if (intent.Cards.Count > 0) degraded = true; - if (intent.Actions.Count > 0 && !context.Capabilities.SupportsActionButtons) + if (intent.Actions.Count > 0 && !capabilities.SupportsActionButtons) degraded = true; if (intent.Attachments.Count > 1) degraded = true; var attachmentLimit = intent.Attachments.Count > 0 ? TelegramCaptionLimit : TelegramTextLimit; - var maxLength = ResolveTextLimit(Math.Min(context.Capabilities.MaxMessageLength, attachmentLimit), attachmentLimit); + var maxLength = ResolveTextLimit(Math.Min(capabilities.MaxMessageLength, attachmentLimit), attachmentLimit); if (BuildRenderedText(intent, int.MaxValue).Length > maxLength) degraded = true; return degraded ? ComposeCapability.Degraded : ComposeCapability.Exact; } - private static InlineKeyboardMarkup? BuildInlineKeyboard( + private static string? BuildInlineKeyboardJson( IEnumerable actions, bool supportsActionButtons) { @@ -70,10 +86,22 @@ public ComposeCapability Evaluate(MessageContent intent, ComposeContext context) var rows = actions .Where(static action => action.Kind == ActionElementKind.Button && !string.IsNullOrWhiteSpace(action.Label)) - .Select(static action => new[] { new InlineKeyboardButton(action.Label, BuildCallbackData(action)) }) + .Select(static action => new[] + { + new + { + text = action.Label, + callback_data = BuildCallbackData(action), + }, + }) .ToArray(); - return rows.Length == 0 ? null : new InlineKeyboardMarkup(rows); + return rows.Length == 0 + ? null + : JsonSerializer.Serialize(new + { + inline_keyboard = rows, + }); } private static string BuildCallbackData(ActionElement action) @@ -83,7 +111,21 @@ private static string BuildCallbackData(ActionElement action) : !string.IsNullOrWhiteSpace(action.ActionId) ? action.ActionId : action.Label; - return raw.Length <= 64 ? raw : raw[..64]; + if (Encoding.UTF8.GetByteCount(raw) <= CallbackDataMaxBytes) + return raw; + + var textInfo = new StringInfo(raw); + var builder = new StringBuilder(); + for (var i = 0; i < textInfo.LengthInTextElements; i++) + { + var next = builder.ToString() + textInfo.SubstringByTextElements(i, 1); + if (Encoding.UTF8.GetByteCount(next) > CallbackDataMaxBytes) + break; + + builder.Append(textInfo.SubstringByTextElements(i, 1)); + } + + return builder.ToString(); } private static string BuildRenderedText(MessageContent intent, int maxLength) @@ -100,7 +142,14 @@ private static string BuildRenderedText(MessageContent intent, int maxLength) } var text = builder.ToString().Trim(); - return text.Length <= maxLength ? text : text[..maxLength]; + if (maxLength <= 0) + return text; + + var textInfo = new StringInfo(text); + if (textInfo.LengthInTextElements <= maxLength) + return text; + + return textInfo.SubstringByTextElements(0, maxLength); } private static void AppendParagraph(StringBuilder builder, string? value) diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramNativePayload.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramNativePayload.cs deleted file mode 100644 index 67f533bde..000000000 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramNativePayload.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Aevatar.GAgents.Channel.Abstractions; -using Telegram.Bot.Types.ReplyMarkups; - -namespace Aevatar.GAgents.Channel.Telegram; - -/// -/// Composer output for Telegram outbound calls. -/// -/// The text or caption rendered to the user. -/// The optional inline keyboard. -/// The optional attachment that drives sendPhoto or sendDocument. -/// The evaluated composition capability. -public sealed record TelegramNativePayload( - string Text, - InlineKeyboardMarkup? ReplyMarkup, - AttachmentRef? Attachment, - ComposeCapability Capability); diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramOutboundMessage.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramOutboundMessage.cs new file mode 100644 index 000000000..35a4e6f52 --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramOutboundMessage.cs @@ -0,0 +1,9 @@ +using Aevatar.GAgents.Channel.Abstractions; + +namespace Aevatar.GAgents.Channel.Telegram; + +public sealed record TelegramOutboundMessage( + string Text, + string? ReplyMarkupJson, + AttachmentRef? Attachment, + ComposeCapability Capability); diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramPayloadRedactor.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramPayloadRedactor.cs new file mode 100644 index 000000000..d269432cb --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramPayloadRedactor.cs @@ -0,0 +1,91 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Aevatar.GAgents.Channel.Abstractions; + +namespace Aevatar.GAgents.Channel.Telegram; + +public sealed class TelegramPayloadRedactor : IPayloadRedactor +{ + private static readonly ChannelId TelegramChannel = ChannelId.From("telegram"); + + public Task RedactAsync(ChannelId channel, byte[] rawPayload, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(channel); + ArgumentNullException.ThrowIfNull(rawPayload); + + if (!string.Equals(channel.Value, TelegramChannel.Value, StringComparison.Ordinal)) + return Task.FromResult(RedactionResult.Unchanged(rawPayload)); + + if (rawPayload.Length == 0) + return Task.FromResult(RedactionResult.Unchanged(rawPayload)); + + var text = Encoding.UTF8.GetString(rawPayload); + JsonNode? root; + try + { + root = JsonNode.Parse(text); + } + catch (JsonException) + { + return Task.FromResult(RedactionResult.Unchanged(rawPayload)); + } + + if (root is null) + return Task.FromResult(RedactionResult.Unchanged(rawPayload)); + + var modified = false; + StripSensitiveValues(root, ref modified); + if (!modified) + return Task.FromResult(RedactionResult.Unchanged(rawPayload)); + + var sanitized = Encoding.UTF8.GetBytes(root.ToJsonString()); + return Task.FromResult(RedactionResult.Modified(sanitized)); + } + + public Task HealthCheckAsync(CancellationToken ct) => + Task.FromResult(HealthStatus.Healthy); + + private static void StripSensitiveValues(JsonNode node, ref bool modified) + { + if (node is JsonObject obj) + { + foreach (var name in obj.Select(static kvp => kvp.Key).ToArray()) + { + if (ShouldRemove(name)) + { + obj.Remove(name); + modified = true; + continue; + } + + if (ShouldRedact(name)) + { + obj[name] = "[redacted]"; + modified = true; + continue; + } + + if (obj[name] is { } child) + StripSensitiveValues(child, ref modified); + } + + return; + } + + if (node is JsonArray array) + { + foreach (var child in array) + { + if (child is not null) + StripSensitiveValues(child, ref modified); + } + } + } + + private static bool ShouldRemove(string propertyName) => + propertyName is "phone_number" or "email" or "language_code" or "photo_url"; + + private static bool ShouldRedact(string propertyName) => + propertyName is "text" or "caption" or "data" or "query" or "first_name" or "last_name" or "username"; +} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramStreamingHandle.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramStreamingHandle.cs new file mode 100644 index 000000000..76350ec28 --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramStreamingHandle.cs @@ -0,0 +1,166 @@ +using Aevatar.GAgents.Channel.Abstractions; + +namespace Aevatar.GAgents.Channel.Telegram; + +internal sealed class TelegramStreamingHandle : StreamingHandle +{ + private readonly TelegramChannelAdapter _adapter; + private readonly ConversationReference _conversation; + private readonly string _activityId; + private readonly MessageContent _template; + private readonly TimeProvider _timeProvider; + private readonly TimeSpan _debounce; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly HashSet _acceptedSequenceNumbers = []; + private readonly SortedDictionary _deltasBySequence = []; + private long _flushGeneration; + private bool _completed; + private string _currentText; + private CancellationTokenSource? _flushCts; + + public TelegramStreamingHandle( + TelegramChannelAdapter adapter, + ConversationReference conversation, + string activityId, + MessageContent template, + TimeProvider timeProvider, + TimeSpan debounce) + { + _adapter = adapter; + _conversation = conversation; + _activityId = activityId; + _template = template; + _timeProvider = timeProvider; + _debounce = debounce; + _currentText = template.Text ?? string.Empty; + } + + public override async Task AppendAsync(StreamChunk chunk) + { + ArgumentNullException.ThrowIfNull(chunk); + + CancellationTokenSource? previousFlush = null; + await _gate.WaitAsync(CancellationToken.None); + try + { + if (_completed || !_acceptedSequenceNumbers.Add(chunk.SequenceNumber)) + return; + + _deltasBySequence[chunk.SequenceNumber] = chunk.Delta ?? string.Empty; + previousFlush = _flushCts; + _flushCts = new CancellationTokenSource(); + _ = FlushLaterAsync(++_flushGeneration, _flushCts.Token); + } + finally + { + _gate.Release(); + } + + CancelPendingFlush(previousFlush); + } + + public override async Task CompleteAsync(MessageContent final) + { + ArgumentNullException.ThrowIfNull(final); + + await _gate.WaitAsync(CancellationToken.None); + try + { + if (_completed) + return; + + _completed = true; + CancelPendingFlush(_flushCts); + _flushCts = null; + await _adapter.UpdateAsync(_conversation, _activityId, final, CancellationToken.None); + } + finally + { + _gate.Release(); + } + } + + public override async ValueTask DisposeAsync() + { + await _gate.WaitAsync(CancellationToken.None); + try + { + if (_completed) + return; + + _completed = true; + CancelPendingFlush(_flushCts); + _flushCts = null; + var interruptedText = string.IsNullOrWhiteSpace(_currentText) + ? "(reply interrupted)" + : $"{_currentText} (reply interrupted)"; + + try + { + await _adapter.UpdateAsync( + _conversation, + _activityId, + BuildStreamingMessage(interruptedText), + CancellationToken.None); + } + catch + { + } + } + finally + { + _gate.Release(); + } + } + + private async Task FlushLaterAsync(long generation, CancellationToken ct) + { + try + { + if (_debounce > TimeSpan.Zero) + await Task.Delay(_debounce, _timeProvider, ct); + + await _gate.WaitAsync(ct); + try + { + if (_completed || ct.IsCancellationRequested || generation != _flushGeneration || _deltasBySequence.Count == 0) + return; + + _currentText += string.Concat(_deltasBySequence.OrderBy(static pair => pair.Key).Select(static pair => pair.Value)); + _deltasBySequence.Clear(); + await _adapter.UpdateAsync( + _conversation, + _activityId, + BuildStreamingMessage(_currentText), + ct); + } + finally + { + _gate.Release(); + } + } + catch (OperationCanceledException) + { + } + catch (Exception) + { + } + } + + private MessageContent BuildStreamingMessage(string text) + { + var content = _template.Clone(); + content.Text = text; + content.Attachments.Clear(); + return content; + } + + private static void CancelPendingFlush(CancellationTokenSource? flushCts) + { + if (flushCts is null) + return; + + flushCts.Cancel(); + flushCts.Dispose(); + } +} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookRequest.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookRequest.cs new file mode 100644 index 000000000..e35fd277f --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookRequest.cs @@ -0,0 +1,5 @@ +namespace Aevatar.GAgents.Channel.Telegram; + +public sealed record TelegramWebhookRequest( + byte[] Body, + IReadOnlyDictionary? Headers = null); diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookResponse.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookResponse.cs new file mode 100644 index 000000000..7f4c0f6e6 --- /dev/null +++ b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookResponse.cs @@ -0,0 +1,9 @@ +using Aevatar.GAgents.Channel.Abstractions; + +namespace Aevatar.GAgents.Channel.Telegram; + +public sealed record TelegramWebhookResponse( + int StatusCode, + string? ResponseBody, + ChatActivity? Activity, + byte[]? SanitizedPayload); diff --git a/docs/canon/aevatar-channel-architecture.md b/docs/canon/aevatar-channel-architecture.md index fb85d33be..09f8e1294 100644 --- a/docs/canon/aevatar-channel-architecture.md +++ b/docs/canon/aevatar-channel-architecture.md @@ -1931,7 +1931,7 @@ Lark 同时提供长连接模式(WebSocket,SDK 主动建连到 Lark 服务 ### 10.2 Telegram(`agents/channels/Aevatar.GAgents.Channel.Telegram`) -- **底层 SDK**:[Telegram.Bot](https://github.com/TelegramBots/Telegram.Bot)(11k+ stars,最成熟的 .NET Telegram library) +- **底层传输**:直接调用 Telegram Bot API HTTP endpoint;adapter 内只保留轻量 DTO / composer / redactor / credential snapshot,不再包一层专用 SDK 客户端 - **Transport**:Webhook 优先;long-polling 作为 fallback(开发模式) - **迁移**: - `TelegramPlatformAdapter` → `TelegramChannelAdapter` diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs index d499d5b1b..c22bd9b6b 100644 --- a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs +++ b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Text; using System.Text.Json; using Aevatar.Foundation.Abstractions.Credentials; @@ -5,96 +6,353 @@ using Aevatar.GAgents.Channel.Telegram; using Aevatar.GAgents.Channel.Testing; using Microsoft.Extensions.Logging.Abstractions; -using global::Telegram.Bot; -using global::Telegram.Bot.Exceptions; -using global::Telegram.Bot.Types; -using TelegramInlineKeyboardMarkup = global::Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup; namespace Aevatar.GAgents.Channel.Telegram.Tests; internal sealed class TelegramAdapterHarness { - private TelegramChannelAdapter _adapter; - private TelegramWebhookFixture _webhook; + private const string PrimaryCredentialRef = "vault://telegram/primary"; + private const string SecondaryCredentialRef = "vault://telegram/secondary"; - public TelegramAdapterHarness(TransportMode transportMode = TransportMode.Webhook) + public ChannelTransportBinding DefaultBinding { get; } = ChannelTransportBinding.Create( + ChannelBotDescriptor.Create( + registrationId: "telegram-primary", + channel: ChannelId.From("telegram"), + bot: BotInstanceId.From("telegram-primary-bot")), + credentialRef: PrimaryCredentialRef, + verificationToken: "secret-primary"); + + public ChannelTransportBinding SecondaryBinding { get; } = ChannelTransportBinding.Create( + ChannelBotDescriptor.Create( + registrationId: "telegram-secondary", + channel: ChannelId.From("telegram"), + bot: BotInstanceId.From("telegram-secondary-bot")), + credentialRef: SecondaryCredentialRef, + verificationToken: "secret-secondary"); + + public TestCredentialProvider CredentialProvider { get; private set; } = null!; + + public RecordingTelegramHttpHandler HttpHandler { get; private set; } = null!; + + public FakeTelegramAttachmentContentResolver AttachmentResolver { get; private set; } = null!; + + public TelegramWebhookFixture Webhook { get; private set; } = null!; + + public TelegramPayloadRedactor Redactor { get; private set; } = null!; + + public TelegramStreamingProbe StreamingProbe { get; private set; } = null!; + + public TelegramChannelAdapter Reset(TransportMode transportMode = TransportMode.Webhook) { - Credentials = new FakeCredentialProvider(new Dictionary(StringComparer.Ordinal) - { - ["vault://telegram/primary"] = "bot-token-primary", - ["vault://telegram/secondary"] = "bot-token-secondary", - }); - Api = new FakeTelegramApiClient(); - Attachments = new FakeTelegramAttachmentContentResolver(); - _adapter = CreateAdapter(transportMode); - _webhook = new TelegramWebhookFixture(_adapter); + HttpHandler = new RecordingTelegramHttpHandler(); + CredentialProvider = new TestCredentialProvider(); + CredentialProvider.Set(PrimaryCredentialRef, "bot-token-primary"); + CredentialProvider.Set(SecondaryCredentialRef, "bot-token-secondary"); + AttachmentResolver = new FakeTelegramAttachmentContentResolver(); + Redactor = new TelegramPayloadRedactor(); + + var adapter = new TelegramChannelAdapter( + CredentialProvider, + new TelegramMessageComposer(), + Redactor, + NullLogger.Instance, + new HttpClient(HttpHandler) + { + BaseAddress = TelegramChannelDefaults.DefaultBaseAddress, + }, + AttachmentResolver, + new TelegramChannelAdapterOptions + { + TransportMode = transportMode, + RecommendedStreamDebounceMs = 10, + }); + + Webhook = new TelegramWebhookFixture(adapter, DefaultBinding); + StreamingProbe = new TelegramStreamingProbe(adapter, HttpHandler); + return adapter; } +} - public FakeCredentialProvider Credentials { get; } +internal sealed class RecordingTelegramHttpHandler : HttpMessageHandler +{ + private readonly Dictionary<(long ChatId, int MessageId), RecordedTelegramMessage> _messages = new(); + private readonly Queue _pollOutcomes = new(); + private int _nextMessageId = 100; + private TaskCompletionSource _firstPollCall = CreateSignal(); + private TaskCompletionSource _firstEditCall = CreateSignal(); - public FakeTelegramApiClient Api { get; } + public IReadOnlyDictionary<(long ChatId, int MessageId), RecordedTelegramMessage> Messages => _messages; - public FakeTelegramAttachmentContentResolver Attachments { get; } + public string? LastBotToken { get; private set; } - public ChannelTransportBinding DefaultBinding { get; } = ChannelTransportBinding.Create( - ChannelBotDescriptor.Create("telegram-primary", ChannelId.From("telegram"), BotInstanceId.From("telegram-primary-bot")), - "vault://telegram/primary", - "secret-primary"); + public string? LastMethodName { get; private set; } - public ChannelTransportBinding SecondaryBinding { get; } = ChannelTransportBinding.Create( - ChannelBotDescriptor.Create("telegram-secondary", ChannelId.From("telegram"), BotInstanceId.From("telegram-secondary-bot")), - "vault://telegram/secondary", - "secret-secondary"); + public string? LastPath { get; private set; } - public TelegramChannelAdapter Reset(TransportMode transportMode = TransportMode.Webhook) + public string? LastMessageId { get; private set; } + + public int PollCallCount { get; private set; } + + public int EditCallCount { get; private set; } + + public Task FirstPollCallAsync => _firstPollCall.Task; + + public Task FirstEditCallAsync => _firstEditCall.Task; + + public TaskCompletionSource? BlockNextEditCompletion { get; set; } + + public void EnqueuePollResponse(params byte[][] updates) => + _pollOutcomes.Enqueue(TelegramPollOutcome.Success(updates.Select(Encoding.UTF8.GetString).ToArray())); + + public void EnqueuePollFailure(int statusCode, string description) => + _pollOutcomes.Enqueue(TelegramPollOutcome.Failure(statusCode, description)); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + (LastBotToken, LastMethodName) = ParseRoute(request.RequestUri); + LastPath = request.RequestUri?.PathAndQuery; + + return LastMethodName switch + { + "getUpdates" => HandleGetUpdates(), + "sendMessage" => await HandleSendAsync(request, "sendMessage", cancellationToken), + "sendPhoto" => await HandleSendAsync(request, "sendPhoto", cancellationToken), + "sendDocument" => await HandleSendAsync(request, "sendDocument", cancellationToken), + "editMessageText" => await HandleEditAsync(request, cancellationToken), + "deleteMessage" => await HandleDeleteAsync(request, cancellationToken), + _ => new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent( + JsonSerializer.Serialize(new + { + ok = false, + error_code = 400, + description = $"unsupported method {LastMethodName}", + }), + Encoding.UTF8, + "application/json"), + }, + }; + } + + public string ReadText(string activityId) + { + var (chatId, messageId) = ParseActivityId(activityId); + return _messages[(chatId, messageId)].Text; + } + + private HttpResponseMessage HandleGetUpdates() { - Api.Clear(); - _adapter = CreateAdapter(transportMode); - _webhook = new TelegramWebhookFixture(_adapter); - return _adapter; + PollCallCount++; + _firstPollCall.TrySetResult(true); + + if (_pollOutcomes.Count == 0) + return Ok(new { ok = true, result = Array.Empty() }); + + var outcome = _pollOutcomes.Dequeue(); + if (outcome.StatusCode.HasValue) + { + return new HttpResponseMessage((HttpStatusCode)outcome.StatusCode.Value) + { + Content = new StringContent( + JsonSerializer.Serialize(new + { + ok = false, + error_code = outcome.StatusCode.Value, + description = outcome.Description, + }), + Encoding.UTF8, + "application/json"), + }; + } + + var updates = outcome.UpdateJsonPayloads!.Select(static payload => JsonDocument.Parse(payload).RootElement.Clone()).ToArray(); + return Ok(new + { + ok = true, + result = updates, + }); } - public WebhookFixture Webhook => _webhook; + private async Task HandleSendAsync( + HttpRequestMessage request, + string methodName, + CancellationToken cancellationToken) + { + var root = await ReadJsonAsync(request, cancellationToken); + var chatId = root.GetProperty("chat_id").GetInt64(); + var text = TryReadString(root, "text") ?? TryReadString(root, "caption") ?? string.Empty; + var replyMarkupJson = root.TryGetProperty("reply_markup", out var replyMarkup) + ? replyMarkup.GetRawText() + : null; + var messageId = Interlocked.Increment(ref _nextMessageId); + _messages[(chatId, messageId)] = new RecordedTelegramMessage( + ChatId: chatId, + MessageId: messageId, + MethodName: methodName, + Text: text, + ReplyMarkupJson: replyMarkupJson, + Deleted: false); + LastMessageId = BuildActivityId(chatId, messageId); + + return Ok(new + { + ok = true, + result = new + { + message_id = messageId, + chat = new + { + id = chatId, + }, + }, + }); + } - public TelegramChannelAdapter Adapter => _adapter; + private async Task HandleEditAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + EditCallCount++; + _firstEditCall.TrySetResult(true); - private TelegramChannelAdapter CreateAdapter(TransportMode transportMode) => - new( - Credentials, - Api, - Attachments, - logger: NullLogger.Instance, - options: new TelegramChannelAdapterOptions + var blocker = BlockNextEditCompletion; + BlockNextEditCompletion = null; + if (blocker is not null) + await blocker.Task.WaitAsync(cancellationToken); + + var root = await ReadJsonAsync(request, cancellationToken); + var chatId = root.GetProperty("chat_id").GetInt64(); + var messageId = root.GetProperty("message_id").GetInt32(); + var text = root.GetProperty("text").GetString() ?? string.Empty; + var replyMarkupJson = root.TryGetProperty("reply_markup", out var replyMarkup) + ? replyMarkup.GetRawText() + : null; + _messages[(chatId, messageId)] = new RecordedTelegramMessage( + ChatId: chatId, + MessageId: messageId, + MethodName: "editMessageText", + Text: text, + ReplyMarkupJson: replyMarkupJson, + Deleted: false); + LastMessageId = BuildActivityId(chatId, messageId); + + return Ok(new + { + ok = true, + result = new { - TransportMode = transportMode, - RecommendedStreamDebounceMs = 3000, - }); -} + message_id = messageId, + chat = new + { + id = chatId, + }, + }, + }); + } -internal sealed class TelegramWebhookFixture : WebhookFixture -{ - private readonly TelegramChannelAdapter _adapter; - private byte[]? _lastRaw; + private async Task HandleDeleteAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var root = await ReadJsonAsync(request, cancellationToken); + var chatId = root.GetProperty("chat_id").GetInt64(); + var messageId = root.GetProperty("message_id").GetInt32(); + if (_messages.TryGetValue((chatId, messageId), out var existing)) + _messages[(chatId, messageId)] = existing with { Deleted = true }; + LastMessageId = BuildActivityId(chatId, messageId); + + return Ok(new + { + ok = true, + result = true, + }); + } + + private static async Task ReadJsonAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken); + using var document = JsonDocument.Parse(body); + return document.RootElement.Clone(); + } + + private static string? TryReadString(JsonElement element, string propertyName) => + element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; - public TelegramWebhookFixture(TelegramChannelAdapter adapter) + private static (string BotToken, string MethodName) ParseRoute(Uri? uri) { - _adapter = adapter; + var segments = uri?.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + ?? Array.Empty(); + if (segments.Length < 2 || !segments[0].StartsWith("bot", StringComparison.Ordinal)) + throw new InvalidOperationException($"Unexpected Telegram request path: {uri}"); + + return (segments[0]["bot".Length..], segments[1]); } - public override async Task DispatchInboundAsync(InboundActivitySeed seed, CancellationToken ct = default) + private static (long ChatId, int MessageId) ParseActivityId(string activityId) { - _lastRaw = BuildPayload(seed, out _); - using var stream = new MemoryStream(_lastRaw, writable: false); - return await _adapter.AcceptWebhookAsync(stream, secretToken: "secret-primary", ct: ct) - ?? throw new InvalidOperationException("Synthetic Telegram webhook did not produce an activity."); + var parts = activityId.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 4 && + string.Equals(parts[0], "telegram", StringComparison.Ordinal) && + string.Equals(parts[1], "message", StringComparison.Ordinal) && + long.TryParse(parts[2], out var chatId) && + int.TryParse(parts[3], out var messageId)) + { + return (chatId, messageId); + } + + throw new InvalidOperationException($"Unexpected Telegram activity id: {activityId}"); } + private static string BuildActivityId(long chatId, int messageId) => $"telegram:message:{chatId}:{messageId}"; + + private static HttpResponseMessage Ok(object payload) => new(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"), + }; + + private static TaskCompletionSource CreateSignal() => + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private sealed record TelegramPollOutcome(string[]? UpdateJsonPayloads, int? StatusCode, string? Description) + { + public static TelegramPollOutcome Success(string[] payloads) => new(payloads, null, null); + + public static TelegramPollOutcome Failure(int statusCode, string description) => new(null, statusCode, description); + } +} + +internal sealed record RecordedTelegramMessage( + long ChatId, + int MessageId, + string MethodName, + string Text, + string? ReplyMarkupJson, + bool Deleted); + +internal sealed class TelegramWebhookFixture( + TelegramChannelAdapter adapter, + ChannelTransportBinding defaultBinding) : WebhookFixture +{ + private byte[]? _lastBody; + private Dictionary? _lastHeaders; + private string? _lastPersistedBlobRef; + private byte[]? _lastRawPayloadBytes; + + public override string? LastPersistedBlobRef => _lastPersistedBlobRef; + + public override byte[]? LastRawPayloadBytes => _lastRawPayloadBytes; + + public override Task DispatchInboundAsync(InboundActivitySeed seed, CancellationToken ct = default) => + DispatchInboundToBindingAsync(defaultBinding, seed, ct); + public override async Task ReplayLastInboundAsync(CancellationToken ct = default) { - if (_lastRaw is null) + if (_lastBody is null || _lastHeaders is null) return null; - using var stream = new MemoryStream(_lastRaw, writable: false); - return await _adapter.AcceptWebhookAsync(stream, secretToken: "secret-primary", ct: ct); + + var response = await adapter.HandleWebhookAsync(new TelegramWebhookRequest(_lastBody, _lastHeaders), ct); + _lastPersistedBlobRef = response.Activity?.RawPayloadBlobRef; + _lastRawPayloadBytes = response.SanitizedPayload; + return response.Activity; } public override async Task DispatchInboundToBindingAsync( @@ -102,19 +360,17 @@ public override async Task DispatchInboundToBindingAsync( InboundActivitySeed seed, CancellationToken ct = default) { - _lastRaw = BuildPayload(seed, out _); - using var stream = new MemoryStream(_lastRaw, writable: false); - return await _adapter.AcceptWebhookAsync( - stream, - secretToken: binding.VerificationToken, - binding: binding, - ct: ct) - ?? throw new InvalidOperationException("Synthetic Telegram webhook did not produce an activity."); - } - - public override string? LastPersistedBlobRef => null; + _lastBody = BuildPayload(seed, out _); + _lastHeaders = new Dictionary(StringComparer.Ordinal) + { + [TelegramChannelDefaults.SecretHeaderName] = binding.VerificationToken, + }; - public override byte[]? LastRawPayloadBytes => _lastRaw; + var response = await adapter.HandleWebhookAsync(new TelegramWebhookRequest(_lastBody, _lastHeaders), ct); + _lastPersistedBlobRef = response.Activity?.RawPayloadBlobRef; + _lastRawPayloadBytes = response.SanitizedPayload; + return response.Activity ?? throw new InvalidOperationException("Expected one ChatActivity."); + } internal static byte[] BuildPayload(InboundActivitySeed seed, out long chatId) { @@ -126,7 +382,7 @@ internal static byte[] BuildPayload(InboundActivitySeed seed, out long chatId) var messageId = PositiveDeterministicInt(seed.PlatformMessageId ?? $"{seed.Text}:{seed.SenderCanonicalId}"); var chatType = seed.Scope == ConversationScope.DirectMessage ? "private" : "group"; - var payload = new + return JsonSerializer.SerializeToUtf8Bytes(new { update_id = updateId, message = new @@ -147,21 +403,7 @@ internal static byte[] BuildPayload(InboundActivitySeed seed, out long chatId) }, text = seed.Text, }, - }; - - return JsonSerializer.SerializeToUtf8Bytes(payload); - } - - internal static Update BuildUpdate(Action write) - { - using var stream = new MemoryStream(); - using (var writer = new Utf8JsonWriter(stream)) - { - write(writer); - } - - return JsonSerializer.Deserialize(stream.ToArray(), JsonBotAPI.Options) - ?? throw new InvalidOperationException("Unable to deserialize synthetic Telegram update."); + }); } internal static long PositiveDeterministicId(string value) @@ -192,193 +434,94 @@ private static ulong Fnv1a64(string value) } } -internal sealed class FakeTelegramApiClient : ITelegramApiClient +internal sealed class TelegramStreamingProbe( + TelegramChannelAdapter adapter, + RecordingTelegramHttpHandler handler) : StreamingFaultProbe { - private readonly Queue> _pollResponses = new(); - private readonly Queue _pollExceptions = new(); - private readonly Dictionary<(long ChatId, int MessageId), FakeTelegramMessage> _messages = new(); - private int _nextMessageId = 100; - private TaskCompletionSource _firstPollCall = CreateSignal(); - private TaskCompletionSource _firstEditCall = CreateSignal(); - - public List SendCalls { get; } = []; - - public IReadOnlyDictionary<(long ChatId, int MessageId), FakeTelegramMessage> Messages => _messages; - - public int PollCallCount { get; private set; } - - public int EditCallCount { get; private set; } - - public Task FirstPollCallAsync => _firstPollCall.Task; - - public Task FirstEditCallAsync => _firstEditCall.Task; - - public TaskCompletionSource? BlockNextEditCompletion { get; set; } - - public void EnqueuePollResponse(params Update[] updates) => _pollResponses.Enqueue(updates); + private static readonly ConversationReference Reference = + ConversationReference.TelegramPrivate(BotInstanceId.From("telegram-primary-bot"), "42"); - public void EnqueuePollException(Exception exception) => _pollExceptions.Enqueue(exception); - - public void Clear() - { - _pollResponses.Clear(); - _pollExceptions.Clear(); - _messages.Clear(); - SendCalls.Clear(); - _nextMessageId = 100; - PollCallCount = 0; - EditCallCount = 0; - BlockNextEditCompletion = null; - _firstPollCall = CreateSignal(); - _firstEditCall = CreateSignal(); - } - - public Task> GetUpdatesAsync(string botToken, int? offset, int timeoutSeconds, CancellationToken ct) - { - PollCallCount++; - _firstPollCall.TrySetResult(true); - if (_pollExceptions.Count > 0) - throw _pollExceptions.Dequeue(); - if (_pollResponses.Count == 0) - return Task.FromResult>([]); - return Task.FromResult(_pollResponses.Dequeue()); - } - - public Task SendMessageAsync( - string botToken, - long chatId, - string text, - TelegramInlineKeyboardMarkup? replyMarkup, - int? replyToMessageId, - CancellationToken ct) - { - var sent = Save(chatId, text, replyMarkup, "text", null); - return Task.FromResult(sent); - } - - public Task SendPhotoAsync( - string botToken, - long chatId, - TelegramAttachmentContent attachment, - string? caption, - TelegramInlineKeyboardMarkup? replyMarkup, - int? replyToMessageId, - CancellationToken ct) + public override async Task DisposeWithoutCompleteMarksInterruptedAsync(CancellationToken ct = default) { - var sent = Save(chatId, caption ?? string.Empty, replyMarkup, "photo", attachment.FileName); - return Task.FromResult(sent); - } + var handle = await adapter.BeginStreamingReplyAsync(Reference, SampleMessageContent.SimpleText("seed"), ct); + await handle.AppendAsync(new StreamChunk + { + Delta = " partial", + SequenceNumber = 1, + }); + await handle.DisposeAsync(); - public Task SendDocumentAsync( - string botToken, - long chatId, - TelegramAttachmentContent attachment, - string? caption, - TelegramInlineKeyboardMarkup? replyMarkup, - int? replyToMessageId, - CancellationToken ct) - { - var sent = Save(chatId, caption ?? string.Empty, replyMarkup, "document", attachment.FileName); - return Task.FromResult(sent); + var lastId = handler.LastMessageId ?? throw new InvalidOperationException("No message recorded."); + return handler.ReadText(lastId).Contains("reply interrupted", StringComparison.Ordinal); } - public Task EditMessageTextAsync( - string botToken, - long chatId, - int messageId, - string text, - TelegramInlineKeyboardMarkup? replyMarkup, - CancellationToken ct) + public override async Task IntentDegradesMidwayReachesTerminalStateAsync(CancellationToken ct = default) { - EditCallCount++; - _firstEditCall.TrySetResult(true); - if (BlockNextEditCompletion is not null) + var handle = await adapter.BeginStreamingReplyAsync(Reference, SampleMessageContent.TextWithCard("seed"), ct); + await handle.AppendAsync(new StreamChunk { - var release = BlockNextEditCompletion; - BlockNextEditCompletion = null; - return WaitAndEditAsync(release, chatId, messageId, text, replyMarkup, ct); - } - - _messages[(chatId, messageId)] = new FakeTelegramMessage(chatId, messageId, text, replyMarkup, _messages[(chatId, messageId)].Kind, _messages[(chatId, messageId)].AttachmentName); - SendCalls.Add(new FakeTelegramSendCall("edit", chatId, messageId, text, replyMarkup, null)); - return Task.FromResult(new TelegramSentActivity(chatId, messageId)); - } + Delta = " later", + SequenceNumber = 1, + }); + await handle.CompleteAsync(SampleMessageContent.SimpleText("done")); - public Task DeleteMessageAsync(string botToken, long chatId, int messageId, CancellationToken ct) - { - _messages.Remove((chatId, messageId)); - SendCalls.Add(new FakeTelegramSendCall("delete", chatId, messageId, string.Empty, null, null)); - return Task.CompletedTask; + var lastId = handler.LastMessageId ?? throw new InvalidOperationException("No message recorded."); + return string.Equals(handler.ReadText(lastId), "done", StringComparison.Ordinal); } - private TelegramSentActivity Save( - long chatId, - string text, - TelegramInlineKeyboardMarkup? replyMarkup, - string kind, - string? attachmentName) + public override async Task AppendIdempotentBySequenceNumberAsync(CancellationToken ct = default) { - var messageId = Interlocked.Increment(ref _nextMessageId); - _messages[(chatId, messageId)] = new FakeTelegramMessage(chatId, messageId, text, replyMarkup, kind, attachmentName); - SendCalls.Add(new FakeTelegramSendCall(kind, chatId, messageId, text, replyMarkup, attachmentName)); - return new TelegramSentActivity(chatId, messageId); - } + var handle = await adapter.BeginStreamingReplyAsync(Reference, SampleMessageContent.SimpleText("seed"), ct); + await handle.AppendAsync(new StreamChunk + { + Delta = "A", + SequenceNumber = 1, + }); + await handle.AppendAsync(new StreamChunk + { + Delta = "A", + SequenceNumber = 1, + }); + await handle.AppendAsync(new StreamChunk + { + Delta = "A", + SequenceNumber = 2, + }); + await handle.CompleteAsync(SampleMessageContent.SimpleText("seedAA")); - private async Task WaitAndEditAsync( - TaskCompletionSource release, - long chatId, - int messageId, - string text, - TelegramInlineKeyboardMarkup? replyMarkup, - CancellationToken ct) - { - await release.Task.WaitAsync(ct); - _messages[(chatId, messageId)] = new FakeTelegramMessage(chatId, messageId, text, replyMarkup, _messages[(chatId, messageId)].Kind, _messages[(chatId, messageId)].AttachmentName); - SendCalls.Add(new FakeTelegramSendCall("edit", chatId, messageId, text, replyMarkup, null)); - return new TelegramSentActivity(chatId, messageId); + var lastId = handler.LastMessageId ?? throw new InvalidOperationException("No message recorded."); + return string.Equals(handler.ReadText(lastId), "seedAA", StringComparison.Ordinal); } - - private static TaskCompletionSource CreateSignal() => - new(TaskCreationOptions.RunContinuationsAsynchronously); } -internal sealed record FakeTelegramSendCall( - string Kind, - long ChatId, - int MessageId, - string Text, - TelegramInlineKeyboardMarkup? ReplyMarkup, - string? AttachmentName); - -internal sealed record FakeTelegramMessage( - long ChatId, - int MessageId, - string Text, - TelegramInlineKeyboardMarkup? ReplyMarkup, - string Kind, - string? AttachmentName); - internal sealed class FakeTelegramAttachmentContentResolver : ITelegramAttachmentContentResolver { public Task ResolveAsync(AttachmentRef attachment, CancellationToken ct) { - var bytes = Encoding.UTF8.GetBytes(attachment.BlobRef); + ArgumentNullException.ThrowIfNull(attachment); return Task.FromResult(new TelegramAttachmentContent( FileName: string.IsNullOrWhiteSpace(attachment.Name) ? "attachment.bin" : attachment.Name, ContentType: string.IsNullOrWhiteSpace(attachment.ContentType) ? "application/octet-stream" : attachment.ContentType, - Content: new MemoryStream(bytes, writable: false))); + Content: null, + ExternalUrl: $"https://cdn.example.com/{Uri.EscapeDataString(attachment.AttachmentId)}")); } } -internal sealed class FakeCredentialProvider : ICredentialProvider +internal sealed class TestCredentialProvider : ICredentialProvider { - private readonly IReadOnlyDictionary _credentials; + private readonly Dictionary _values = new(StringComparer.Ordinal); - public FakeCredentialProvider(IReadOnlyDictionary credentials) - { - _credentials = credentials; - } + public void Set(string credentialRef, string secret) => _values[credentialRef] = secret; public Task ResolveAsync(string credentialRef, CancellationToken ct = default) => - Task.FromResult(_credentials.TryGetValue(credentialRef, out var value) ? value : null); + Task.FromResult(_values.TryGetValue(credentialRef, out var value) ? value : null); +} + +internal sealed class ThrowingRedactor : IPayloadRedactor +{ + public Task RedactAsync(ChannelId channel, byte[] rawPayload, CancellationToken ct) => + throw new InvalidOperationException("redactor boom"); + + public Task HealthCheckAsync(CancellationToken ct) => + Task.FromResult(HealthStatus.Unhealthy); } diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterFaultTests.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterFaultTests.cs new file mode 100644 index 000000000..2e28f0138 --- /dev/null +++ b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterFaultTests.cs @@ -0,0 +1,22 @@ +using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.Telegram; +using Aevatar.GAgents.Channel.Testing; + +namespace Aevatar.GAgents.Channel.Telegram.Tests; + +public sealed class TelegramChannelAdapterFaultTests : ChannelAdapterFaultTests +{ + private readonly TelegramAdapterHarness _harness = new(); + + protected override TelegramChannelAdapter CreateAdapter() => _harness.Reset(); + + protected override WebhookFixture? WebhookFixture => _harness.Webhook; + + protected override GatewayFixture? GatewayFixture => null; + + protected override ChannelTransportBinding CreateBinding() => _harness.DefaultBinding; + + protected override IPayloadRedactor? Redactor => _harness.Redactor; + + protected override StreamingFaultProbe? StreamingProbe => _harness.StreamingProbe; +} diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs index f43ad5e5a..8e2a5e141 100644 --- a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs +++ b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs @@ -2,10 +2,8 @@ using Aevatar.GAgents.Channel.Abstractions; using Aevatar.GAgents.Channel.Telegram; using Aevatar.GAgents.Channel.Testing; +using Microsoft.Extensions.Logging.Abstractions; using Shouldly; -using global::Telegram.Bot; -using global::Telegram.Bot.Exceptions; -using global::Telegram.Bot.Types; namespace Aevatar.GAgents.Channel.Telegram.Tests; @@ -14,12 +12,10 @@ public sealed class TelegramChannelAdapterModeTests [Fact] public async Task StartReceiving_LongPollingMode_PublishesInboundMessage() { - var harness = new TelegramAdapterHarness(TransportMode.LongPolling); + var harness = new TelegramAdapterHarness(); var adapter = harness.Reset(TransportMode.LongPolling); - var update = JsonSerializer.Deserialize( - TelegramWebhookFixture.BuildPayload(InboundActivitySeed.DirectMessage("hello long polling"), out _), - JsonBotAPI.Options)!; - harness.Api.EnqueuePollResponse(update); + harness.HttpHandler.EnqueuePollResponse( + TelegramWebhookFixture.BuildPayload(InboundActivitySeed.DirectMessage("hello long polling"), out _)); await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); await adapter.StartReceivingAsync(CancellationToken.None); @@ -39,7 +35,7 @@ public async Task StartReceiving_LongPollingMode_PublishesInboundMessage() } [Fact] - public async Task AcceptWebhookAsync_ShouldDifferentiateSupergroupAndChannelPosts() + public async Task HandleWebhookAsync_ShouldDifferentiateSupergroupAndChannelPosts() { var harness = new TelegramAdapterHarness(); var adapter = harness.Reset(); @@ -48,61 +44,167 @@ public async Task AcceptWebhookAsync_ShouldDifferentiateSupergroupAndChannelPost try { - var supergroupUpdate = TelegramWebhookFixture.BuildUpdate(writer => + var supergroupPayload = JsonSerializer.SerializeToUtf8Bytes(new + { + update_id = 8001, + message = new + { + message_id = 501, + date = 1_714_000_000, + chat = new + { + id = -100200300400L, + type = "supergroup", + }, + from = new + { + id = 77, + is_bot = false, + first_name = "Alice", + }, + text = "supergroup message", + }, + }); + var channelPayload = JsonSerializer.SerializeToUtf8Bytes(new { - writer.WriteStartObject(); - writer.WriteNumber("update_id", 8001); - writer.WritePropertyName("message"); - writer.WriteStartObject(); - writer.WriteNumber("message_id", 501); - writer.WriteNumber("date", 1_714_000_000); - writer.WritePropertyName("chat"); - writer.WriteStartObject(); - writer.WriteNumber("id", -100200300400L); - writer.WriteString("type", "supergroup"); - writer.WriteEndObject(); - writer.WritePropertyName("from"); - writer.WriteStartObject(); - writer.WriteNumber("id", 77); - writer.WriteBoolean("is_bot", false); - writer.WriteString("first_name", "Alice"); - writer.WriteEndObject(); - writer.WriteString("text", "supergroup message"); - writer.WriteEndObject(); - writer.WriteEndObject(); + update_id = 8002, + channel_post = new + { + message_id = 601, + date = 1_714_000_010, + chat = new + { + id = -100500600700L, + type = "channel", + title = "ops-channel", + }, + text = "channel post", + }, }); - var channelUpdate = TelegramWebhookFixture.BuildUpdate(writer => + + var supergroupResponse = await adapter.HandleWebhookAsync(new TelegramWebhookRequest(supergroupPayload, SecretHeaders("secret-primary"))); + var channelResponse = await adapter.HandleWebhookAsync(new TelegramWebhookRequest(channelPayload, SecretHeaders("secret-primary"))); + + supergroupResponse.StatusCode.ShouldBe(200); + supergroupResponse.Activity.ShouldNotBeNull(); + supergroupResponse.Activity.Conversation.Scope.ShouldBe(ConversationScope.Group); + supergroupResponse.Activity.Conversation.CanonicalKey.ShouldContain("supergroup"); + supergroupResponse.Activity.RawPayloadBlobRef.ShouldStartWith("telegram-raw:"); + + channelResponse.StatusCode.ShouldBe(200); + channelResponse.Activity.ShouldNotBeNull(); + channelResponse.Activity.Conversation.Scope.ShouldBe(ConversationScope.Channel); + channelResponse.Activity.Conversation.CanonicalKey.ShouldContain("channel"); + channelResponse.Activity.RawPayloadBlobRef.ShouldStartWith("telegram-raw:"); + } + finally + { + await adapter.StopReceivingAsync(CancellationToken.None); + } + } + + [Fact] + public async Task HandleWebhookAsync_AttachmentOnlyMessage_PreservesAttachment() + { + var harness = new TelegramAdapterHarness(); + var adapter = harness.Reset(); + await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); + await adapter.StartReceivingAsync(CancellationToken.None); + + try + { + var payload = JsonSerializer.SerializeToUtf8Bytes(new { - writer.WriteStartObject(); - writer.WriteNumber("update_id", 8002); - writer.WritePropertyName("channel_post"); - writer.WriteStartObject(); - writer.WriteNumber("message_id", 601); - writer.WriteNumber("date", 1_714_000_010); - writer.WritePropertyName("chat"); - writer.WriteStartObject(); - writer.WriteNumber("id", -100500600700L); - writer.WriteString("type", "channel"); - writer.WriteString("title", "ops-channel"); - writer.WriteEndObject(); - writer.WriteString("text", "channel post"); - writer.WriteEndObject(); - writer.WriteEndObject(); + update_id = 9101, + message = new + { + message_id = 701, + date = 1_714_000_000, + chat = new + { + id = 42, + type = "private", + }, + from = new + { + id = 77, + is_bot = false, + first_name = "Alice", + }, + photo = new object[] + { + new + { + file_id = "photo-small", + file_size = 32, + }, + new + { + file_id = "photo-large", + file_size = 128, + }, + }, + }, }); - await using var supergroupStream = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(supergroupUpdate, JsonBotAPI.Options)); - await using var channelStream = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(channelUpdate, JsonBotAPI.Options)); - var supergroupActivity = await adapter.AcceptWebhookAsync(supergroupStream, secretToken: "secret-primary"); - var channelActivity = await adapter.AcceptWebhookAsync(channelStream, secretToken: "secret-primary"); + var response = await adapter.HandleWebhookAsync(new TelegramWebhookRequest(payload, SecretHeaders("secret-primary"))); - supergroupActivity.ShouldNotBeNull(); - supergroupActivity.Conversation.Scope.ShouldBe(ConversationScope.Group); - supergroupActivity.Conversation.CanonicalKey.ShouldContain("supergroup"); - supergroupActivity.RawPayloadBlobRef.ShouldBeEmpty(); - channelActivity.ShouldNotBeNull(); - channelActivity.Conversation.Scope.ShouldBe(ConversationScope.Channel); - channelActivity.Conversation.CanonicalKey.ShouldContain("channel"); - channelActivity.RawPayloadBlobRef.ShouldBeEmpty(); + response.StatusCode.ShouldBe(200); + response.Activity.ShouldNotBeNull(); + response.Activity.Content.Text.ShouldBeEmpty(); + response.Activity.Content.Attachments.Count.ShouldBe(1); + response.Activity.Content.Attachments[0].AttachmentId.ShouldBe("photo-large"); + response.Activity.Content.Attachments[0].Kind.ShouldBe(AttachmentKind.Image); + } + finally + { + await adapter.StopReceivingAsync(CancellationToken.None); + } + } + + [Fact] + public async Task HandleWebhookAsync_CallbackQuery_BecomesCardActionSubmission() + { + var harness = new TelegramAdapterHarness(); + var adapter = harness.Reset(); + await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); + await adapter.StartReceivingAsync(CancellationToken.None); + + try + { + var payload = JsonSerializer.SerializeToUtf8Bytes(new + { + update_id = 9201, + callback_query = new + { + id = "callback-1", + data = "confirm", + from = new + { + id = 91, + username = "alice", + }, + message = new + { + message_id = 333, + date = 1_714_000_000, + chat = new + { + id = 42, + type = "private", + }, + }, + }, + }); + + var response = await adapter.HandleWebhookAsync(new TelegramWebhookRequest(payload, SecretHeaders("secret-primary"))); + + response.StatusCode.ShouldBe(200); + response.Activity.ShouldNotBeNull(); + response.Activity.Type.ShouldBe(ActivityType.CardAction); + response.Activity.Content.CardAction.ShouldNotBeNull(); + response.Activity.Content.CardAction.ActionId.ShouldBe("confirm"); + response.Activity.Content.CardAction.SourceMessageId.ShouldBe("telegram:message:42:333"); } finally { @@ -127,13 +229,13 @@ public async Task BeginStreamingReplyAsync_ShouldFinalizeByEditingMessage() CancellationToken.None); var releaseEdit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - harness.Api.BlockNextEditCompletion = releaseEdit; + harness.HttpHandler.BlockNextEditCompletion = releaseEdit; await handle.AppendAsync(new StreamChunk { SequenceNumber = 1, Delta = " plus", }); - await harness.Api.FirstEditCallAsync.WaitAsync(TimeSpan.FromSeconds(5)); + await harness.HttpHandler.FirstEditCallAsync.WaitAsync(TimeSpan.FromSeconds(5)); var completeTask = handle.CompleteAsync(SampleMessageContent.SimpleText("seed plus final")); completeTask.IsCompleted.ShouldBeFalse(); @@ -141,9 +243,8 @@ await handle.AppendAsync(new StreamChunk releaseEdit.TrySetResult(true); await completeTask; - harness.Api.SendCalls.Count(call => call.Kind == "edit").ShouldBe(2); - harness.Api.SendCalls[^1].Kind.ShouldBe("edit"); - harness.Api.SendCalls[^1].Text.ShouldBe("seed plus final"); + harness.HttpHandler.EditCallCount.ShouldBe(2); + harness.HttpHandler.ReadText(harness.HttpHandler.LastMessageId!).ShouldBe("seed plus final"); } finally { @@ -154,49 +255,144 @@ await handle.AppendAsync(new StreamChunk [Fact] public async Task StartReceiving_LongPollingAuthFailure_StopsRetryLoop() { - var harness = new TelegramAdapterHarness(TransportMode.LongPolling); + var harness = new TelegramAdapterHarness(); var adapter = harness.Reset(TransportMode.LongPolling); - harness.Api.EnqueuePollException(new ApiRequestException("unauthorized", 401)); + harness.HttpHandler.EnqueuePollFailure(401, "unauthorized"); await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); await adapter.StartReceivingAsync(CancellationToken.None); try { - await harness.Api.FirstPollCallAsync.WaitAsync(TimeSpan.FromSeconds(5)); + await harness.HttpHandler.FirstPollCallAsync.WaitAsync(TimeSpan.FromSeconds(5)); } finally { await adapter.StopReceivingAsync(CancellationToken.None); } - harness.Api.PollCallCount.ShouldBe(1); + harness.HttpHandler.PollCallCount.ShouldBe(1); } [Fact] - public async Task SendAsync_WhitespaceOnlyTextWithoutAttachment_ReturnsFailure() + public async Task SendAsync_RefreshesBotCredentialBeforeOutboundRequest() + { + var harness = new TelegramAdapterHarness(); + var adapter = harness.Reset(); + await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); + await adapter.StartReceivingAsync(CancellationToken.None); + harness.CredentialProvider.Set("vault://telegram/primary", "rotated-bot-token"); + + try + { + var emit = await adapter.SendAsync( + ConversationReference.TelegramPrivate(BotInstanceId.From("telegram-primary-bot"), "42"), + SampleMessageContent.SimpleText("hello"), + CancellationToken.None); + + emit.Success.ShouldBeTrue(); + harness.HttpHandler.LastBotToken.ShouldBe("rotated-bot-token"); + } + finally + { + await adapter.StopReceivingAsync(CancellationToken.None); + } + } + + [Fact] + public async Task ContinueConversationAsync_OnBehalfOfUser_ReturnsUnsupported() { var harness = new TelegramAdapterHarness(); var adapter = harness.Reset(); await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); await adapter.StartReceivingAsync(CancellationToken.None); - var reference = ConversationReference.TelegramPrivate(BotInstanceId.From("telegram-primary-bot"), "42"); try { - var emit = await adapter.SendAsync(reference, new MessageContent + var emit = await adapter.ContinueConversationAsync( + ConversationReference.TelegramPrivate(BotInstanceId.From("telegram-primary-bot"), "42"), + SampleMessageContent.SimpleText("hello"), + AuthContext.OnBehalfOfUser("vault://users/delegate", "delegate-user"), + CancellationToken.None); + + emit.Success.ShouldBeFalse(); + emit.ErrorCode.ShouldBe("principal_unsupported"); + emit.Capability.ShouldBe(ComposeCapability.Unsupported); + } + finally + { + await adapter.StopReceivingAsync(CancellationToken.None); + } + } + + [Fact] + public async Task HandleWebhookAsync_WhenRedactorThrows_FailsClosed() + { + var credentialProvider = new TestCredentialProvider(); + credentialProvider.Set("vault://telegram/test", "bot-token"); + var adapter = new TelegramChannelAdapter( + credentialProvider, + new TelegramMessageComposer(), + new ThrowingRedactor(), + NullLogger.Instance, + new HttpClient(new RecordingTelegramHttpHandler()) { - Text = " ", - Disposition = MessageDisposition.Normal, - }, CancellationToken.None); + BaseAddress = TelegramChannelDefaults.DefaultBaseAddress, + }); + var binding = ChannelTransportBinding.Create( + ChannelBotDescriptor.Create("telegram-test", ChannelId.From("telegram"), BotInstanceId.From("telegram-test-bot")), + "vault://telegram/test", + "secret-primary"); + await adapter.InitializeAsync(binding, CancellationToken.None); + await adapter.StartReceivingAsync(CancellationToken.None); + + try + { + var payload = TelegramWebhookFixture.BuildPayload(InboundActivitySeed.DirectMessage("hello"), out _); + + var response = await adapter.HandleWebhookAsync(new TelegramWebhookRequest(payload, SecretHeaders("secret-primary"))); + + response.StatusCode.ShouldBe(503); + response.Activity.ShouldBeNull(); + response.SanitizedPayload.ShouldBeNull(); + } + finally + { + await adapter.StopReceivingAsync(CancellationToken.None); + } + } + + [Fact] + public async Task SendAsync_WhitespaceOnlyTextWithoutAttachment_ReturnsFailure() + { + var harness = new TelegramAdapterHarness(); + var adapter = harness.Reset(); + await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); + await adapter.StartReceivingAsync(CancellationToken.None); + + try + { + var emit = await adapter.SendAsync( + ConversationReference.TelegramPrivate(BotInstanceId.From("telegram-primary-bot"), "42"), + new MessageContent + { + Text = " ", + Disposition = MessageDisposition.Normal, + }, + CancellationToken.None); emit.Success.ShouldBeFalse(); emit.ErrorCode.ShouldBe("telegram_empty_message"); - harness.Api.SendCalls.ShouldBeEmpty(); } finally { await adapter.StopReceivingAsync(CancellationToken.None); } } + + private static IReadOnlyDictionary SecretHeaders(string secret) => + new Dictionary(StringComparer.Ordinal) + { + [TelegramChannelDefaults.SecretHeaderName] = secret, + }; } diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs index a680cdbfe..d3f6c564e 100644 --- a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs +++ b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Aevatar.GAgents.Channel.Abstractions; using Aevatar.GAgents.Channel.Telegram; using Aevatar.GAgents.Channel.Testing; @@ -30,28 +31,32 @@ public sealed class TelegramMessageComposerTests : MessageComposerUnitTests(); + var native = payload.ShouldBeOfType(); native.Text.ShouldBe(intent.Text); native.Attachment.ShouldBeNull(); } protected override void AssertActionsPayload(object payload, MessageContent intent, ComposeContext context, ComposeCapability capability) { - var native = payload.ShouldBeOfType(); + var native = payload.ShouldBeOfType(); if (capability == ComposeCapability.Exact) - native.ReplyMarkup.ShouldNotBeNull(); + { + native.ReplyMarkupJson.ShouldNotBeNull(); + using var document = JsonDocument.Parse(native.ReplyMarkupJson); + document.RootElement.GetProperty("inline_keyboard").GetArrayLength().ShouldBe(2); + } } protected override void AssertCardPayload(object payload, MessageContent intent, ComposeContext context, ComposeCapability capability) { - var native = payload.ShouldBeOfType(); + var native = payload.ShouldBeOfType(); native.Text.ShouldContain("Hero"); capability.ShouldBe(ComposeCapability.Degraded); } protected override void AssertOverflowTruncation(object payload, int maxLength) { - payload.ShouldBeOfType().Text.Length.ShouldBeLessThanOrEqualTo(maxLength); + payload.ShouldBeOfType().Text.Length.ShouldBeLessThanOrEqualTo(maxLength); } [Fact] From 5081bb795c637fe7084055fc18f83964fc69fca1 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 27 Apr 2026 11:49:22 +0800 Subject: [PATCH 04/15] Restructure Telegram to platform-rendering package Replace the per-platform Aevatar.GAgents.Channel.Telegram adapter project with a renderer-only Aevatar.GAgents.Platform.Telegram package matching the new ADR-0013 Channel.NyxIdRelay backbone. The composer, native producer, outbound message, and payload redactor mirror the Lark platform package. Delete the legacy adapter project, its options/credentials/streaming/webhook support files, and the old conformance/mode test project that targeted the direct-callback contract no longer in the supported production path. Wire Platform.Telegram + the new test project into aevatar.slnx and aevatar.platforms.slnf, and remove the deleted Channel.Telegram entries. Co-Authored-By: Claude Opus 4.7 (1M context) --- aevatar.platforms.slnf | 4 +- aevatar.slnx | 4 +- .../ConversationReference.Telegram.Partial.cs | 48 - .../ITelegramAttachmentContentResolver.cs | 29 - .../TelegramChannelAdapter.cs | 1085 ----------------- .../TelegramChannelAdapterOptions.cs | 29 - .../TelegramChannelDefaults.cs | 19 - ...egramChannelServiceCollectionExtensions.cs | 36 - .../TelegramCredentialSnapshot.cs | 39 - .../TelegramOutboundMessage.cs | 9 - .../TelegramStreamingHandle.cs | 166 --- .../TelegramWebhookRequest.cs | 5 - .../TelegramWebhookResponse.cs | 9 - .../Aevatar.GAgents.Platform.Telegram.csproj} | 6 +- .../TelegramChannelNativeMessageProducer.cs | 57 + .../TelegramMessageComposer.cs | 80 +- .../TelegramOutboundMessage.cs | 9 + .../TelegramPayloadRedactor.cs | 2 +- .../TelegramAdapterTestSupport.cs | 527 -------- .../TelegramChannelAdapterConformanceTests.cs | 24 - .../TelegramChannelAdapterFaultTests.cs | 22 - .../TelegramChannelAdapterModeTests.cs | 398 ------ .../TelegramMessageComposerTests.cs | 80 -- ...ar.GAgents.Platform.Telegram.Tests.csproj} | 17 +- ...legramChannelNativeMessageProducerTests.cs | 65 + .../TelegramMessageComposerTests.cs | 146 +++ 26 files changed, 342 insertions(+), 2573 deletions(-) delete mode 100644 agents/Aevatar.GAgents.Channel.Abstractions/Transport/ConversationReference.Telegram.Partial.cs delete mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramAttachmentContentResolver.cs delete mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs delete mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapterOptions.cs delete mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelDefaults.cs delete mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelServiceCollectionExtensions.cs delete mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramCredentialSnapshot.cs delete mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramOutboundMessage.cs delete mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramStreamingHandle.cs delete mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookRequest.cs delete mode 100644 agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookResponse.cs rename agents/{channels/Aevatar.GAgents.Channel.Telegram/Aevatar.GAgents.Channel.Telegram.csproj => platforms/Aevatar.GAgents.Platform.Telegram/Aevatar.GAgents.Platform.Telegram.csproj} (79%) create mode 100644 agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramChannelNativeMessageProducer.cs rename agents/{channels/Aevatar.GAgents.Channel.Telegram => platforms/Aevatar.GAgents.Platform.Telegram}/TelegramMessageComposer.cs (70%) create mode 100644 agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramOutboundMessage.cs rename agents/{channels/Aevatar.GAgents.Channel.Telegram => platforms/Aevatar.GAgents.Platform.Telegram}/TelegramPayloadRedactor.cs (98%) delete mode 100644 test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs delete mode 100644 test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterConformanceTests.cs delete mode 100644 test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterFaultTests.cs delete mode 100644 test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs delete mode 100644 test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs rename test/{Aevatar.GAgents.Channel.Telegram.Tests/Aevatar.GAgents.Channel.Telegram.Tests.csproj => Aevatar.GAgents.Platform.Telegram.Tests/Aevatar.GAgents.Platform.Telegram.Tests.csproj} (73%) create mode 100644 test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramChannelNativeMessageProducerTests.cs create mode 100644 test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramMessageComposerTests.cs diff --git a/aevatar.platforms.slnf b/aevatar.platforms.slnf index 073b443d5..353a87573 100644 --- a/aevatar.platforms.slnf +++ b/aevatar.platforms.slnf @@ -3,8 +3,10 @@ "path": "aevatar.slnx", "projects": [ "agents\\platforms\\Aevatar.GAgents.Platform.Lark\\Aevatar.GAgents.Platform.Lark.csproj", + "agents\\platforms\\Aevatar.GAgents.Platform.Telegram\\Aevatar.GAgents.Platform.Telegram.csproj", "test\\Aevatar.GAgents.Channel.Testing\\Aevatar.GAgents.Channel.Testing.csproj", - "test\\Aevatar.GAgents.Platform.Lark.Tests\\Aevatar.GAgents.Platform.Lark.Tests.csproj" + "test\\Aevatar.GAgents.Platform.Lark.Tests\\Aevatar.GAgents.Platform.Lark.Tests.csproj", + "test\\Aevatar.GAgents.Platform.Telegram.Tests\\Aevatar.GAgents.Platform.Telegram.Tests.csproj" ] } } diff --git a/aevatar.slnx b/aevatar.slnx index 92865ba64..33ba37c62 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -13,8 +13,8 @@ + - @@ -176,7 +176,7 @@ - + diff --git a/agents/Aevatar.GAgents.Channel.Abstractions/Transport/ConversationReference.Telegram.Partial.cs b/agents/Aevatar.GAgents.Channel.Abstractions/Transport/ConversationReference.Telegram.Partial.cs deleted file mode 100644 index 8f9381875..000000000 --- a/agents/Aevatar.GAgents.Channel.Abstractions/Transport/ConversationReference.Telegram.Partial.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace Aevatar.GAgents.Channel.Abstractions; - -/// -/// Telegram-specific conversation helpers that keep canonical keys deterministic across private chats, groups, and channels. -/// -public sealed partial class ConversationReference -{ - private static readonly ChannelId TelegramChannelId = ChannelId.From("telegram"); - - /// - /// Creates one Telegram private-chat conversation reference. - /// - public static ConversationReference TelegramPrivate(BotInstanceId bot, string chatId) => - Create( - TelegramChannelId, - bot, - ConversationScope.DirectMessage, - partition: null, - "private", - NormalizeTelegramSegment(chatId, nameof(chatId))); - - /// - /// Creates one Telegram group or supergroup conversation reference. - /// - public static ConversationReference TelegramGroup(BotInstanceId bot, string chatId, bool isSupergroup = false) => - Create( - TelegramChannelId, - bot, - ConversationScope.Group, - partition: null, - isSupergroup ? "supergroup" : "group", - NormalizeTelegramSegment(chatId, nameof(chatId))); - - /// - /// Creates one Telegram channel-post conversation reference. - /// - public static ConversationReference TelegramChannel(BotInstanceId bot, string chatId) => - Create( - TelegramChannelId, - bot, - ConversationScope.Channel, - partition: null, - "channel", - NormalizeTelegramSegment(chatId, nameof(chatId))); - - private static string NormalizeTelegramSegment(string value, string paramName) => - NormalizeSegment(value, paramName); -} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramAttachmentContentResolver.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramAttachmentContentResolver.cs deleted file mode 100644 index aa15511ef..000000000 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/ITelegramAttachmentContentResolver.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Aevatar.GAgents.Channel.Abstractions; - -namespace Aevatar.GAgents.Channel.Telegram; - -/// -/// Resolves one channel attachment reference into content Telegram can upload. -/// -public interface ITelegramAttachmentContentResolver -{ - /// - /// Resolves one attachment for upload. - /// - Task ResolveAsync(AttachmentRef attachment, CancellationToken ct); -} - -/// -/// Carries one resolved Telegram attachment upload. -/// -/// The filename exposed to Telegram. -/// The MIME content type. -/// The stream to upload. The caller owns disposal. -/// An optional public URL Telegram can fetch directly. -/// An optional existing Telegram file id. -public sealed record TelegramAttachmentContent( - string FileName, - string ContentType, - Stream? Content = null, - string? ExternalUrl = null, - string? TelegramFileId = null); diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs deleted file mode 100644 index 048e9d1e1..000000000 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapter.cs +++ /dev/null @@ -1,1085 +0,0 @@ -using System.Globalization; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using Aevatar.GAgents.Channel.Abstractions; -using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.Logging; -using FoundationCredentialProvider = Aevatar.Foundation.Abstractions.Credentials.ICredentialProvider; - -namespace Aevatar.GAgents.Channel.Telegram; - -public sealed class TelegramChannelAdapter : IChannelTransport, IChannelOutboundPort -{ - private static readonly ChannelId TelegramChannel = ChannelId.From("telegram"); - - private readonly HttpClient _httpClient; - private readonly FoundationCredentialProvider _credentialProvider; - private readonly IMessageComposer _composer; - private readonly IPayloadRedactor _payloadRedactor; - private readonly ILogger _logger; - private readonly System.Threading.Channels.Channel _inboundBuffer; - private readonly ChannelCapabilities _capabilities; - private readonly ITelegramAttachmentContentResolver? _attachmentResolver; - private readonly TelegramChannelAdapterOptions _options; - private readonly bool _captureInboundActivities; - - private ChannelTransportBinding? _binding; - private TelegramCredentialSnapshot _botCredential = new(string.Empty); - private bool _initialized; - private bool _receiving; - private bool _stopped; - private CancellationTokenSource? _receiveLoopCts; - private Task? _receiveLoop; - - public TelegramChannelAdapter( - FoundationCredentialProvider credentialProvider, - TelegramMessageComposer composer, - IPayloadRedactor payloadRedactor, - ILogger logger, - HttpClient? httpClient = null, - ITelegramAttachmentContentResolver? attachmentResolver = null, - TelegramChannelAdapterOptions? options = null, - bool captureInboundActivities = true) - { - _credentialProvider = credentialProvider ?? throw new ArgumentNullException(nameof(credentialProvider)); - _composer = composer ?? throw new ArgumentNullException(nameof(composer)); - _payloadRedactor = payloadRedactor ?? throw new ArgumentNullException(nameof(payloadRedactor)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _attachmentResolver = attachmentResolver; - _options = options ?? new TelegramChannelAdapterOptions(); - _captureInboundActivities = captureInboundActivities; - _httpClient = httpClient ?? new HttpClient - { - BaseAddress = TelegramChannelDefaults.DefaultBaseAddress, - }; - _capabilities = TelegramMessageComposer.DefaultCapabilities.Clone(); - _capabilities.RecommendedStreamDebounceMs = _options.RecommendedStreamDebounceMs; - _capabilities.Transport = _options.TransportMode; - _inboundBuffer = System.Threading.Channels.Channel.CreateBounded( - new System.Threading.Channels.BoundedChannelOptions(256) - { - SingleReader = true, - SingleWriter = false, - FullMode = System.Threading.Channels.BoundedChannelFullMode.Wait, - }); - } - - public ChannelId Channel => TelegramChannel; - - public TransportMode TransportMode => _options.TransportMode; - - public ChannelCapabilities Capabilities => _capabilities.Clone(); - - public System.Threading.Channels.ChannelReader InboundStream => _inboundBuffer.Reader; - - public async Task InitializeAsync(ChannelTransportBinding binding, CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(binding); - - if (_initialized || _receiving) - throw new InvalidOperationException("TelegramChannelAdapter is already initialized."); - - var secret = await _credentialProvider.ResolveBotCredentialAsync(binding, ct); - _binding = binding.Clone(); - _botCredential = TelegramCredentialSnapshot.Parse(secret); - _initialized = true; - _stopped = false; - } - - public Task StartReceivingAsync(CancellationToken ct) - { - EnsureInitialized(); - if (_receiving) - throw new InvalidOperationException("TelegramChannelAdapter has already started receiving."); - - _receiving = true; - if (_options.TransportMode == TransportMode.LongPolling) - { - _receiveLoopCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - _receiveLoop = Task.Run(() => RunLongPollingLoopAsync(_receiveLoopCts.Token)); - } - - return Task.CompletedTask; - } - - public async Task StopReceivingAsync(CancellationToken ct) - { - if (_stopped) - return; - - _stopped = true; - _receiving = false; - if (_receiveLoopCts is not null) - { - await _receiveLoopCts.CancelAsync(); - _receiveLoopCts.Dispose(); - _receiveLoopCts = null; - } - - if (_receiveLoop is not null) - { - try - { - await _receiveLoop.WaitAsync(ct); - } - catch (OperationCanceledException) - { - } - - _receiveLoop = null; - } - - _inboundBuffer.Writer.TryComplete(); - } - - public async Task SendAsync(ConversationReference to, MessageContent content, CancellationToken ct) => - await SendCoreAsync(to, content, activityId: null, await RefreshBotCredentialAsync(ct), ct); - - public async Task UpdateAsync( - ConversationReference to, - string activityId, - MessageContent content, - CancellationToken ct) => - await SendCoreAsync(to, content, activityId, await RefreshBotCredentialAsync(ct), ct); - - public async Task DeleteAsync(ConversationReference to, string activityId, CancellationToken ct) - { - EnsureReady(); - ArgumentNullException.ThrowIfNull(to); - if (string.IsNullOrWhiteSpace(activityId)) - throw new ArgumentException("Activity id cannot be empty.", nameof(activityId)); - - var target = TelegramConversationTarget.ParseConversation(to); - var outboundActivity = TelegramConversationTarget.ParseOutboundActivityId(activityId); - if (target.ChatId != outboundActivity.ChatId) - throw new InvalidOperationException("Telegram activity id does not belong to the supplied conversation."); - - var credential = await RefreshBotCredentialAsync(ct); - if (string.IsNullOrWhiteSpace(credential.BotToken)) - throw new InvalidOperationException("Telegram bot credential could not be resolved."); - - await DeleteMessageAsync(credential.BotToken, outboundActivity.ChatId, outboundActivity.MessageId, ct); - } - - public async Task ContinueConversationAsync( - ConversationReference reference, - MessageContent content, - AuthContext auth, - CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(reference); - ArgumentNullException.ThrowIfNull(content); - ArgumentNullException.ThrowIfNull(auth); - - if (auth.Kind == PrincipalKind.OnBehalfOfUser) - { - return EmitResult.Failed( - "principal_unsupported", - "Telegram Bot API does not support delegated user sends.", - capability: ComposeCapability.Unsupported); - } - - return await SendCoreAsync(reference, content, activityId: null, await RefreshBotCredentialAsync(ct), ct); - } - - public async Task BeginStreamingReplyAsync( - ConversationReference to, - MessageContent initial, - CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(to); - ArgumentNullException.ThrowIfNull(initial); - - var sent = await SendAsync(to, initial, ct); - if (!sent.Success) - throw new InvalidOperationException(sent.ErrorMessage); - - return new TelegramStreamingHandle( - this, - to.Clone(), - sent.SentActivityId, - initial.Clone(), - _options.TimeProvider, - TimeSpan.FromMilliseconds(Math.Max(_options.RecommendedStreamDebounceMs, 0))); - } - - public async Task HandleWebhookAsync(TelegramWebhookRequest request, CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(request); - EnsureReady(); - - if (!ValidateWebhookSecret(request.Headers)) - return new TelegramWebhookResponse(401, null, null, null); - - JsonDocument document; - try - { - document = JsonDocument.Parse(request.Body ?? Array.Empty()); - } - catch (JsonException) - { - return new TelegramWebhookResponse(400, null, null, null); - } - - using (document) - { - var activity = NormalizeUpdate(document.RootElement, _binding!); - if (activity is null) - return new TelegramWebhookResponse(200, null, null, null); - - byte[] sanitizedPayload; - try - { - sanitizedPayload = (await _payloadRedactor.RedactAsync(Channel, request.Body ?? Array.Empty(), ct)).SanitizedPayload; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Telegram payload redaction failed closed."); - return new TelegramWebhookResponse(503, null, null, null); - } - - activity.RawPayloadBlobRef = BuildBlobRef(sanitizedPayload); - if (_captureInboundActivities) - await _inboundBuffer.Writer.WriteAsync(activity, ct); - - return new TelegramWebhookResponse(200, null, activity, sanitizedPayload); - } - } - - private async Task SendCoreAsync( - ConversationReference to, - MessageContent content, - string? activityId, - TelegramCredentialSnapshot credential, - CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(to); - ArgumentNullException.ThrowIfNull(content); - EnsureReady(); - - var capability = _composer.Evaluate(content, ComposeContextFor(to)); - if (capability == ComposeCapability.Unsupported) - return EmitResult.Failed("unsupported_content", "Telegram composer rejected the message.", capability: capability); - - if (string.IsNullOrWhiteSpace(credential.BotToken)) - return EmitResult.Failed("credential_resolution_failed", "Telegram bot credential could not be resolved.", capability: capability); - - try - { - var effectiveContent = content.Clone(); - if (effectiveContent.Disposition == MessageDisposition.Ephemeral) - effectiveContent.Disposition = MessageDisposition.Normal; - - var payload = _composer.Compose(effectiveContent, ComposeContextFor(to)); - TelegramConversationTarget target; - try - { - target = TelegramConversationTarget.ParseConversation(to); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Telegram conversation reference is invalid."); - return EmitResult.Failed("telegram_invalid_conversation", ex.Message, capability: capability); - } - - var reportedCapability = capability == ComposeCapability.Exact ? payload.Capability : capability; - - if (activityId is null) - { - if (payload.Attachment is null && string.IsNullOrWhiteSpace(payload.Text)) - { - return EmitResult.Failed( - "telegram_empty_message", - "Telegram text sends require non-empty message content.", - capability: payload.Capability); - } - - TelegramSentActivity sent; - if (payload.Attachment is null) - { - sent = await SendMessageAsync(credential.BotToken, target.ChatId, payload.Text, payload.ReplyMarkupJson, ct); - } - else - { - var attachment = await ResolveAttachmentAsync(payload.Attachment, ct); - if (attachment is null) - { - return EmitResult.Failed( - "attachment_unavailable", - "Telegram attachment content could not be resolved.", - capability: payload.Capability); - } - - try - { - sent = payload.Attachment.Kind == AttachmentKind.Image - ? await SendMediaAsync("sendPhoto", "photo", credential.BotToken, target.ChatId, attachment, payload.Text, payload.ReplyMarkupJson, ct) - : await SendMediaAsync("sendDocument", "document", credential.BotToken, target.ChatId, attachment, payload.Text, payload.ReplyMarkupJson, ct); - } - finally - { - attachment.Content?.Dispose(); - } - } - - return EmitResult.Sent( - TelegramConversationTarget.BuildOutboundActivityId(sent.ChatId, sent.MessageId), - reportedCapability); - } - - if (string.IsNullOrWhiteSpace(payload.Text)) - { - return EmitResult.Failed( - "telegram_empty_message", - "Telegram text updates require non-empty message content.", - capability: reportedCapability); - } - - if (payload.Attachment is not null) - { - return EmitResult.Failed( - "telegram_edit_text_only", - "Telegram edits are limited to text payloads in this adapter.", - capability: ComposeCapability.Degraded); - } - - var outboundActivity = TelegramConversationTarget.ParseOutboundActivityId(activityId); - if (target.ChatId != outboundActivity.ChatId) - { - return EmitResult.Failed( - "telegram_activity_mismatch", - "Telegram activity id does not belong to the supplied conversation.", - capability: payload.Capability); - } - - await EditMessageTextAsync( - credential.BotToken, - outboundActivity.ChatId, - outboundActivity.MessageId, - payload.Text, - payload.ReplyMarkupJson, - ct); - - return EmitResult.Sent( - TelegramConversationTarget.BuildOutboundActivityId(outboundActivity.ChatId, outboundActivity.MessageId), - reportedCapability); - } - catch (TelegramApiException ex) - { - _logger.LogWarning(ex, "Telegram request failed."); - return EmitResult.Failed(ex.FailureCode, ex.Message, capability: capability); - } - } - - private async Task RunLongPollingLoopAsync(CancellationToken ct) - { - long? offset = null; - while (!ct.IsCancellationRequested) - { - TelegramCredentialSnapshot credential; - try - { - credential = await RefreshBotCredentialAsync(ct); - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - break; - } - - if (string.IsNullOrWhiteSpace(credential.BotToken)) - { - _logger.LogError("Telegram long polling stopped because bot credentials could not be resolved."); - break; - } - - try - { - var updates = await FetchUpdatesAsync(credential.BotToken, offset, ct); - foreach (var update in updates.OrderBy(GetUpdateId)) - { - var updateId = GetUpdateId(update); - if (updateId.HasValue) - offset = updateId.Value + 1; - - var activity = NormalizeUpdate(update, _binding!); - if (activity is null || !_captureInboundActivities) - continue; - - await _inboundBuffer.Writer.WriteAsync(activity, ct); - } - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - break; - } - catch (TelegramApiException ex) when (IsTerminalAuthFailure(ex)) - { - _logger.LogError(ex, "Telegram long polling stopped because bot authorization failed."); - break; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Telegram long polling iteration failed."); - await Task.Delay(TimeSpan.FromSeconds(1), _options.TimeProvider, ct); - } - } - } - - private ChatActivity? NormalizeUpdate(JsonElement update, ChannelTransportBinding binding) - { - var updateId = GetUpdateId(update); - if (update.TryGetProperty("message", out var message)) - return ParseMessage(updateId, message, binding); - if (update.TryGetProperty("edited_message", out var editedMessage)) - return ParseMessage(updateId, editedMessage, binding); - if (update.TryGetProperty("channel_post", out var channelPost)) - return ParseMessage(updateId, channelPost, binding); - if (update.TryGetProperty("edited_channel_post", out var editedChannelPost)) - return ParseMessage(updateId, editedChannelPost, binding); - if (update.TryGetProperty("callback_query", out var callback)) - return ParseCallback(updateId, callback, binding); - - return null; - } - - private ChatActivity? ParseMessage(long? updateId, JsonElement message, ChannelTransportBinding binding) - { - if (TryReadNestedBoolean(message, "from", "is_bot") == true) - return null; - - var chatId = TryReadNestedInt64(message, "chat", "id"); - if (!chatId.HasValue) - return null; - - var text = TryReadString(message, "text") ?? TryReadString(message, "caption") ?? string.Empty; - var attachments = ExtractAttachments(message); - if (string.IsNullOrWhiteSpace(text) && attachments.Count == 0) - return null; - - var senderId = TryReadNestedInt64(message, "from", "id")?.ToString(CultureInfo.InvariantCulture) - ?? TryReadNestedInt64(message, "sender_chat", "id")?.ToString(CultureInfo.InvariantCulture) - ?? chatId.Value.ToString(CultureInfo.InvariantCulture); - var senderName = TryReadNestedString(message, "from", "username") - ?? TryReadNestedString(message, "from", "first_name") - ?? TryReadNestedString(message, "sender_chat", "title") - ?? TryReadNestedString(message, "chat", "title") - ?? senderId; - - var content = new MessageContent - { - Text = text, - Disposition = MessageDisposition.Normal, - }; - content.Attachments.AddRange(attachments); - - return new ChatActivity - { - Id = ChatActivity.BuildActivityId(Channel, BuildDeliveryKey(updateId, $"message:{chatId}:{TryReadInt32(message, "message_id")}")), - Type = ActivityType.Message, - ChannelId = Channel.Clone(), - Bot = binding.Bot.Bot.Clone(), - Conversation = CreateConversation(binding.Bot.Bot, message), - From = new ParticipantRef - { - CanonicalId = senderId, - DisplayName = senderName, - }, - Timestamp = Timestamp.FromDateTimeOffset(ParseTimestamp(message)), - Content = content, - ReplyToActivityId = BuildReplyToActivityId(message, chatId.Value), - }; - } - - private ChatActivity? ParseCallback(long? updateId, JsonElement callback, ChannelTransportBinding binding) - { - if (!callback.TryGetProperty("message", out var message)) - return null; - - var chatId = TryReadNestedInt64(message, "chat", "id"); - var messageId = TryReadInt32(message, "message_id"); - if (!chatId.HasValue || !messageId.HasValue) - return null; - - var data = TryReadString(callback, "data") ?? TryReadString(callback, "game_short_name"); - if (string.IsNullOrWhiteSpace(data)) - return null; - - var sourceMessageId = TelegramConversationTarget.BuildOutboundActivityId(chatId.Value, messageId.Value); - return new ChatActivity - { - Id = ChatActivity.BuildActivityId(Channel, BuildDeliveryKey(updateId, $"callback:{TryReadString(callback, "id") ?? sourceMessageId}")), - Type = ActivityType.CardAction, - ChannelId = Channel.Clone(), - Bot = binding.Bot.Bot.Clone(), - Conversation = CreateConversation(binding.Bot.Bot, message), - From = new ParticipantRef - { - CanonicalId = TryReadNestedInt64(callback, "from", "id")?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, - DisplayName = TryReadNestedString(callback, "from", "username") - ?? TryReadNestedString(callback, "from", "first_name") - ?? TryReadNestedInt64(callback, "from", "id")?.ToString(CultureInfo.InvariantCulture) - ?? string.Empty, - }, - Timestamp = Timestamp.FromDateTimeOffset(ParseTimestamp(message)), - Content = new MessageContent - { - Disposition = MessageDisposition.Normal, - CardAction = new CardActionSubmission - { - ActionId = data, - SubmittedValue = data, - SourceMessageId = sourceMessageId, - }, - }, - ReplyToActivityId = sourceMessageId, - }; - } - - private static List ExtractAttachments(JsonElement message) - { - var attachments = new List(); - - if (message.TryGetProperty("photo", out var photos) && photos.ValueKind == JsonValueKind.Array) - { - var lastPhoto = photos.EnumerateArray().LastOrDefault(); - if (lastPhoto.ValueKind != JsonValueKind.Undefined && TryReadString(lastPhoto, "file_id") is { Length: > 0 } photoId) - { - attachments.Add(new AttachmentRef - { - AttachmentId = photoId, - Kind = AttachmentKind.Image, - Name = "photo.jpg", - ContentType = "image/jpeg", - SizeBytes = TryReadInt64(lastPhoto, "file_size") ?? 0, - }); - } - } - - if (message.TryGetProperty("document", out var document) && TryReadString(document, "file_id") is { Length: > 0 } documentId) - { - attachments.Add(new AttachmentRef - { - AttachmentId = documentId, - Kind = AttachmentKind.File, - Name = TryReadString(document, "file_name") ?? "document.bin", - ContentType = TryReadString(document, "mime_type") ?? "application/octet-stream", - SizeBytes = TryReadInt64(document, "file_size") ?? 0, - }); - } - - if (message.TryGetProperty("audio", out var audio) && TryReadString(audio, "file_id") is { Length: > 0 } audioId) - { - attachments.Add(new AttachmentRef - { - AttachmentId = audioId, - Kind = AttachmentKind.Audio, - Name = TryReadString(audio, "file_name") ?? "audio.bin", - ContentType = TryReadString(audio, "mime_type") ?? "audio/mpeg", - SizeBytes = TryReadInt64(audio, "file_size") ?? 0, - }); - } - - if (message.TryGetProperty("video", out var video) && TryReadString(video, "file_id") is { Length: > 0 } videoId) - { - attachments.Add(new AttachmentRef - { - AttachmentId = videoId, - Kind = AttachmentKind.Video, - Name = TryReadString(video, "file_name") ?? "video.mp4", - ContentType = TryReadString(video, "mime_type") ?? "video/mp4", - SizeBytes = TryReadInt64(video, "file_size") ?? 0, - }); - } - - if (message.TryGetProperty("voice", out var voice) && TryReadString(voice, "file_id") is { Length: > 0 } voiceId) - { - attachments.Add(new AttachmentRef - { - AttachmentId = voiceId, - Kind = AttachmentKind.Audio, - Name = "voice.ogg", - ContentType = TryReadString(voice, "mime_type") ?? "audio/ogg", - SizeBytes = TryReadInt64(voice, "file_size") ?? 0, - }); - } - - return attachments; - } - - private static ConversationReference CreateConversation(BotInstanceId bot, JsonElement message) - { - var chatType = TryReadNestedString(message, "chat", "type"); - var chatId = TryReadNestedInt64(message, "chat", "id")?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; - - return chatType switch - { - "group" => ConversationReference.TelegramGroup(bot, chatId), - "supergroup" => ConversationReference.TelegramGroup(bot, chatId, isSupergroup: true), - "channel" => ConversationReference.TelegramChannel(bot, chatId), - _ => ConversationReference.TelegramPrivate(bot, chatId), - }; - } - - private bool ValidateWebhookSecret(IReadOnlyDictionary? headers) - { - if (_binding is null || string.IsNullOrWhiteSpace(_binding.VerificationToken)) - return true; - - if (!TryGetHeader(headers, TelegramChannelDefaults.SecretHeaderName, out var providedSecret) || - string.IsNullOrWhiteSpace(providedSecret)) - { - _logger.LogWarning("Telegram webhook secret token mismatch."); - return false; - } - - var expectedHash = SHA256.HashData(Encoding.UTF8.GetBytes(_binding.VerificationToken)); - var providedHash = SHA256.HashData(Encoding.UTF8.GetBytes(providedSecret.Trim())); - if (CryptographicOperations.FixedTimeEquals(expectedHash, providedHash)) - return true; - - _logger.LogWarning("Telegram webhook secret token mismatch."); - return false; - } - - private async Task RefreshBotCredentialAsync(CancellationToken ct) - { - EnsureInitialized(); - var secret = await _credentialProvider.ResolveBotCredentialAsync(_binding!, ct); - var refreshed = TelegramCredentialSnapshot.Parse(secret); - if (string.IsNullOrWhiteSpace(refreshed.BotToken)) - return _botCredential; - - _botCredential = refreshed; - return refreshed; - } - - private async Task ResolveAttachmentAsync(AttachmentRef attachment, CancellationToken ct) - { - if (_attachmentResolver is null) - return null; - - return await _attachmentResolver.ResolveAsync(attachment, ct); - } - - private async Task> FetchUpdatesAsync( - string botToken, - long? offset, - CancellationToken ct) - { - var body = new JsonObject - { - ["timeout"] = _options.LongPollingTimeoutSeconds, - }; - if (offset.HasValue) - body["offset"] = offset.Value; - - body["allowed_updates"] = new JsonArray(TelegramChannelDefaults.AllowedUpdateTypes.Select(static updateType => (JsonNode?)updateType).ToArray()); - - var response = await SendTelegramRequestAsync(botToken, "getUpdates", BuildJsonContent(body), ct); - if (!response.TryGetProperty("result", out var result) || result.ValueKind != JsonValueKind.Array) - return Array.Empty(); - - return result.EnumerateArray().Select(static item => item.Clone()).ToArray(); - } - - private async Task SendMessageAsync( - string botToken, - long chatId, - string text, - string? replyMarkupJson, - CancellationToken ct) - { - var body = new JsonObject - { - ["chat_id"] = chatId, - ["text"] = text, - }; - AppendReplyMarkup(body, replyMarkupJson); - - var response = await SendTelegramRequestAsync(botToken, "sendMessage", BuildJsonContent(body), ct); - return ParseSentActivity(response, chatId); - } - - private async Task SendMediaAsync( - string methodName, - string mediaFieldName, - string botToken, - long chatId, - TelegramAttachmentContent attachment, - string? caption, - string? replyMarkupJson, - CancellationToken ct) - { - HttpContent content; - if (!string.IsNullOrWhiteSpace(attachment.ExternalUrl) || !string.IsNullOrWhiteSpace(attachment.TelegramFileId)) - { - var body = new JsonObject - { - ["chat_id"] = chatId, - [mediaFieldName] = !string.IsNullOrWhiteSpace(attachment.TelegramFileId) - ? attachment.TelegramFileId - : attachment.ExternalUrl, - }; - if (!string.IsNullOrWhiteSpace(caption)) - body["caption"] = caption; - AppendReplyMarkup(body, replyMarkupJson); - content = BuildJsonContent(body); - } - else - { - if (attachment.Content is null) - throw new InvalidOperationException("Telegram attachment must provide content, external URL, or file id."); - - var multipart = new MultipartFormDataContent(); - multipart.Add(new StringContent(chatId.ToString(CultureInfo.InvariantCulture)), "chat_id"); - if (!string.IsNullOrWhiteSpace(caption)) - multipart.Add(new StringContent(caption), "caption"); - if (!string.IsNullOrWhiteSpace(replyMarkupJson)) - multipart.Add(new StringContent(replyMarkupJson), "reply_markup"); - - var fileContent = new StreamContent(attachment.Content); - if (!string.IsNullOrWhiteSpace(attachment.ContentType)) - fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse(attachment.ContentType); - multipart.Add(fileContent, mediaFieldName, string.IsNullOrWhiteSpace(attachment.FileName) ? "attachment.bin" : attachment.FileName); - content = multipart; - } - - var response = await SendTelegramRequestAsync(botToken, methodName, content, ct); - return ParseSentActivity(response, chatId); - } - - private async Task EditMessageTextAsync( - string botToken, - long chatId, - int messageId, - string text, - string? replyMarkupJson, - CancellationToken ct) - { - var body = new JsonObject - { - ["chat_id"] = chatId, - ["message_id"] = messageId, - ["text"] = text, - }; - AppendReplyMarkup(body, replyMarkupJson); - - await SendTelegramRequestAsync(botToken, "editMessageText", BuildJsonContent(body), ct); - } - - private async Task DeleteMessageAsync( - string botToken, - long chatId, - int messageId, - CancellationToken ct) - { - var body = new JsonObject - { - ["chat_id"] = chatId, - ["message_id"] = messageId, - }; - - await SendTelegramRequestAsync(botToken, "deleteMessage", BuildJsonContent(body), ct); - } - - private async Task SendTelegramRequestAsync( - string botToken, - string methodName, - HttpContent content, - CancellationToken ct) - { - using var request = new HttpRequestMessage(HttpMethod.Post, $"/bot{botToken}/{methodName}") - { - Content = content, - }; - using var response = await _httpClient.SendAsync(request, ct); - var responseText = response.Content is null ? string.Empty : await response.Content.ReadAsStringAsync(ct); - if (!response.IsSuccessStatusCode) - throw CreateApiException((int)response.StatusCode, responseText); - - if (string.IsNullOrWhiteSpace(responseText)) - return default; - - using var document = JsonDocument.Parse(responseText); - var root = document.RootElement.Clone(); - if (root.TryGetProperty("ok", out var okProperty) && - okProperty.ValueKind == JsonValueKind.False) - { - throw CreateApiException(null, responseText); - } - - return root; - } - - private ComposeContext ComposeContextFor(ConversationReference to) => new() - { - Conversation = to.Clone(), - Capabilities = Capabilities.Clone(), - }; - - private void EnsureInitialized() - { - if (!_initialized || _binding is null) - throw new InvalidOperationException("TelegramChannelAdapter.InitializeAsync must run first."); - } - - private void EnsureReady() - { - EnsureInitialized(); - if (!_receiving || _stopped) - throw new InvalidOperationException("TelegramChannelAdapter must be started before use."); - } - - private static void AppendReplyMarkup(JsonObject body, string? replyMarkupJson) - { - if (string.IsNullOrWhiteSpace(replyMarkupJson)) - return; - - body["reply_markup"] = JsonNode.Parse(replyMarkupJson); - } - - private static HttpContent BuildJsonContent(JsonObject body) => - new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); - - private static TelegramSentActivity ParseSentActivity(JsonElement response, long fallbackChatId) - { - var chatId = TryReadNestedInt64(response, "result", "chat", "id") ?? fallbackChatId; - var messageId = TryReadNestedInt32(response, "result", "message_id") - ?? throw new TelegramApiException("telegram_missing_message_id", "Telegram response did not include a message id.", null); - return new TelegramSentActivity(chatId, messageId); - } - - private static string BuildDeliveryKey(long? updateId, string fallback) => - updateId.HasValue ? $"update:{updateId.Value}" : fallback; - - private static string BuildReplyToActivityId(JsonElement message, long chatId) - { - var replyMessageId = TryReadNestedInt32(message, "reply_to_message", "message_id"); - return replyMessageId.HasValue - ? TelegramConversationTarget.BuildOutboundActivityId(chatId, replyMessageId.Value) - : string.Empty; - } - - private static DateTimeOffset ParseTimestamp(JsonElement message) - { - var unixSeconds = TryReadInt64(message, "date"); - return unixSeconds.HasValue - ? DateTimeOffset.FromUnixTimeSeconds(unixSeconds.Value) - : DateTimeOffset.UnixEpoch; - } - - private static long? GetUpdateId(JsonElement update) => TryReadInt64(update, "update_id"); - - private static bool IsTerminalAuthFailure(TelegramApiException ex) => ex.ErrorCode is 401 or 403; - - private static string BuildBlobRef(byte[] sanitizedPayload) - { - var hash = SHA256.HashData(sanitizedPayload); - return $"telegram-raw:{Convert.ToHexString(hash).ToLowerInvariant()}"; - } - - private static TelegramApiException CreateApiException(int? statusCode, string body) - { - var errorCode = ParsePlatformErrorCode(body) ?? statusCode; - return new TelegramApiException( - MapErrorCode(statusCode, body), - BuildSanitizedError(statusCode, body), - errorCode); - } - - private static int? ParsePlatformErrorCode(string body) - { - if (string.IsNullOrWhiteSpace(body)) - return null; - - try - { - using var document = JsonDocument.Parse(body); - return TryReadInt32(document.RootElement, "error_code"); - } - catch (JsonException) - { - return null; - } - } - - private static string MapErrorCode(int? statusCode, string body) - { - var platformCode = ParsePlatformErrorCode(body); - if (platformCode.HasValue) - return $"telegram_{platformCode.Value}"; - - return statusCode.HasValue - ? $"http_{statusCode.Value}" - : "telegram_request_failed"; - } - - private static string BuildSanitizedError(int? statusCode, string body) - { - if (!string.IsNullOrWhiteSpace(body)) - { - try - { - using var document = JsonDocument.Parse(body); - var description = TryReadString(document.RootElement, "description") ?? TryReadString(document.RootElement, "message"); - if (!string.IsNullOrWhiteSpace(description)) - return statusCode.HasValue ? $"{statusCode.Value} {description}" : description; - } - catch (JsonException) - { - } - } - - return statusCode.HasValue - ? $"{statusCode.Value} telegram request failed" - : "telegram request failed"; - } - - private static bool TryGetHeader(IReadOnlyDictionary? headers, string headerName, out string value) - { - value = string.Empty; - if (headers is null) - return false; - - if (headers.TryGetValue(headerName, out value!)) - return true; - - foreach (var header in headers) - { - if (string.Equals(header.Key, headerName, StringComparison.OrdinalIgnoreCase)) - { - value = header.Value; - return true; - } - } - - return false; - } - - private static string? TryReadString(JsonElement element, string propertyName) => - element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String - ? property.GetString() - : null; - - private static int? TryReadInt32(JsonElement element, string propertyName) => - element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var value) - ? value - : null; - - private static long? TryReadInt64(JsonElement element, string propertyName) => - element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var value) - ? value - : null; - - private static bool? TryReadNestedBoolean(JsonElement element, params string[] segments) - { - var current = element; - foreach (var segment in segments) - { - if (!current.TryGetProperty(segment, out current)) - return null; - } - - return current.ValueKind switch - { - JsonValueKind.True => true, - JsonValueKind.False => false, - _ => null, - }; - } - - private static string? TryReadNestedString(JsonElement element, params string[] segments) - { - var current = element; - foreach (var segment in segments) - { - if (!current.TryGetProperty(segment, out current)) - return null; - } - - return current.ValueKind == JsonValueKind.String - ? current.GetString() - : null; - } - - private static long? TryReadNestedInt64(JsonElement element, params string[] segments) - { - var current = element; - foreach (var segment in segments) - { - if (!current.TryGetProperty(segment, out current)) - return null; - } - - return current.ValueKind == JsonValueKind.Number && current.TryGetInt64(out var value) - ? value - : null; - } - - private static int? TryReadNestedInt32(JsonElement element, params string[] segments) - { - var current = element; - foreach (var segment in segments) - { - if (!current.TryGetProperty(segment, out current)) - return null; - } - - return current.ValueKind == JsonValueKind.Number && current.TryGetInt32(out var value) - ? value - : null; - } - - private readonly record struct TelegramConversationTarget(long ChatId, int MessageId) - { - public static TelegramConversationTarget ParseConversation(ConversationReference reference) - { - ArgumentNullException.ThrowIfNull(reference); - if (!string.Equals(reference.Channel.Value, TelegramChannel.Value, StringComparison.Ordinal)) - throw new InvalidOperationException("Conversation reference does not target Telegram."); - - var segments = reference.CanonicalKey.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (segments.Length < 3 || !long.TryParse(segments[^1], out var chatId)) - throw new InvalidOperationException("Telegram canonical key must end with the numeric chat id."); - - return new TelegramConversationTarget(chatId, 0); - } - - public static TelegramConversationTarget ParseOutboundActivityId(string activityId) - { - ArgumentNullException.ThrowIfNull(activityId); - var parts = activityId.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length == 4 && - string.Equals(parts[0], "telegram", StringComparison.Ordinal) && - string.Equals(parts[1], "message", StringComparison.Ordinal) && - long.TryParse(parts[2], out var chatId) && - int.TryParse(parts[3], out var messageId)) - { - return new TelegramConversationTarget(chatId, messageId); - } - - throw new InvalidOperationException("Telegram activity id is invalid."); - } - - public static string BuildOutboundActivityId(long chatId, int messageId) => $"telegram:message:{chatId}:{messageId}"; - } - - private readonly record struct TelegramSentActivity(long ChatId, int MessageId); - - private sealed class TelegramApiException : Exception - { - public TelegramApiException(string failureCode, string message, int? errorCode) - : base(message) - { - FailureCode = failureCode; - ErrorCode = errorCode; - } - - public string FailureCode { get; } - - public int? ErrorCode { get; } - } -} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapterOptions.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapterOptions.cs deleted file mode 100644 index f970bdd1e..000000000 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelAdapterOptions.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Aevatar.GAgents.Channel.Abstractions; - -namespace Aevatar.GAgents.Channel.Telegram; - -/// -/// Configures one Telegram adapter instance. -/// -public sealed class TelegramChannelAdapterOptions -{ - /// - /// Gets or sets the inbound transport mode enabled for this adapter instance. - /// - public TransportMode TransportMode { get; init; } = TransportMode.Webhook; - - /// - /// Gets or sets the long-polling timeout in seconds. - /// - public int LongPollingTimeoutSeconds { get; init; } = 30; - - /// - /// Gets or sets the debounce window used by streaming edit loops. - /// - public int RecommendedStreamDebounceMs { get; init; } = 3000; - - /// - /// Gets or sets the time provider used by long-polling and streaming timers. - /// - public TimeProvider TimeProvider { get; init; } = TimeProvider.System; -} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelDefaults.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelDefaults.cs deleted file mode 100644 index 1d50eb3ac..000000000 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelDefaults.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Aevatar.GAgents.Channel.Telegram; - -internal static class TelegramChannelDefaults -{ - public const string HttpClientName = "Aevatar.GAgents.Channel.Telegram"; - - public const string SecretHeaderName = "X-Telegram-Bot-Api-Secret-Token"; - - public static readonly Uri DefaultBaseAddress = new("https://api.telegram.org", UriKind.Absolute); - - public static readonly string[] AllowedUpdateTypes = - [ - "message", - "edited_message", - "channel_post", - "edited_channel_post", - "callback_query", - ]; -} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelServiceCollectionExtensions.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelServiceCollectionExtensions.cs deleted file mode 100644 index 8eb6a4d59..000000000 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramChannelServiceCollectionExtensions.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Aevatar.GAgents.Channel.Abstractions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace Aevatar.GAgents.Channel.Telegram; - -public static class TelegramChannelServiceCollectionExtensions -{ - public static IServiceCollection AddTelegramChannel(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddHttpClient(TelegramChannelDefaults.HttpClientName, client => - { - client.BaseAddress = TelegramChannelDefaults.DefaultBaseAddress; - }); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(sp => new TelegramChannelAdapter( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService>(), - sp.GetRequiredService().CreateClient(TelegramChannelDefaults.HttpClientName), - sp.GetService(), - sp.GetRequiredService())); - services.TryAddSingleton(sp => sp.GetRequiredService()); - services.TryAddSingleton>(sp => sp.GetRequiredService()); - services.TryAddSingleton(sp => sp.GetRequiredService()); - services.TryAddSingleton(sp => sp.GetRequiredService()); - services.TryAddSingleton(sp => sp.GetRequiredService()); - - return services; - } -} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramCredentialSnapshot.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramCredentialSnapshot.cs deleted file mode 100644 index 464489722..000000000 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramCredentialSnapshot.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Text.Json; - -namespace Aevatar.GAgents.Channel.Telegram; - -internal sealed record TelegramCredentialSnapshot(string BotToken) -{ - public static TelegramCredentialSnapshot Parse(string? rawSecret) - { - if (string.IsNullOrWhiteSpace(rawSecret)) - return new TelegramCredentialSnapshot(string.Empty); - - var trimmed = rawSecret.Trim(); - if (!trimmed.StartsWith("{", StringComparison.Ordinal)) - return new TelegramCredentialSnapshot(trimmed); - - try - { - using var document = JsonDocument.Parse(trimmed); - var root = document.RootElement; - return new TelegramCredentialSnapshot( - ReadFirst(root, "bot_token", "token", "access_token")); - } - catch (JsonException) - { - return new TelegramCredentialSnapshot(trimmed); - } - } - - private static string ReadFirst(JsonElement root, params string[] names) - { - foreach (var name in names) - { - if (root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String) - return property.GetString()?.Trim() ?? string.Empty; - } - - return string.Empty; - } -} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramOutboundMessage.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramOutboundMessage.cs deleted file mode 100644 index 35a4e6f52..000000000 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramOutboundMessage.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Aevatar.GAgents.Channel.Abstractions; - -namespace Aevatar.GAgents.Channel.Telegram; - -public sealed record TelegramOutboundMessage( - string Text, - string? ReplyMarkupJson, - AttachmentRef? Attachment, - ComposeCapability Capability); diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramStreamingHandle.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramStreamingHandle.cs deleted file mode 100644 index 76350ec28..000000000 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramStreamingHandle.cs +++ /dev/null @@ -1,166 +0,0 @@ -using Aevatar.GAgents.Channel.Abstractions; - -namespace Aevatar.GAgents.Channel.Telegram; - -internal sealed class TelegramStreamingHandle : StreamingHandle -{ - private readonly TelegramChannelAdapter _adapter; - private readonly ConversationReference _conversation; - private readonly string _activityId; - private readonly MessageContent _template; - private readonly TimeProvider _timeProvider; - private readonly TimeSpan _debounce; - private readonly SemaphoreSlim _gate = new(1, 1); - private readonly HashSet _acceptedSequenceNumbers = []; - private readonly SortedDictionary _deltasBySequence = []; - private long _flushGeneration; - private bool _completed; - private string _currentText; - private CancellationTokenSource? _flushCts; - - public TelegramStreamingHandle( - TelegramChannelAdapter adapter, - ConversationReference conversation, - string activityId, - MessageContent template, - TimeProvider timeProvider, - TimeSpan debounce) - { - _adapter = adapter; - _conversation = conversation; - _activityId = activityId; - _template = template; - _timeProvider = timeProvider; - _debounce = debounce; - _currentText = template.Text ?? string.Empty; - } - - public override async Task AppendAsync(StreamChunk chunk) - { - ArgumentNullException.ThrowIfNull(chunk); - - CancellationTokenSource? previousFlush = null; - await _gate.WaitAsync(CancellationToken.None); - try - { - if (_completed || !_acceptedSequenceNumbers.Add(chunk.SequenceNumber)) - return; - - _deltasBySequence[chunk.SequenceNumber] = chunk.Delta ?? string.Empty; - previousFlush = _flushCts; - _flushCts = new CancellationTokenSource(); - _ = FlushLaterAsync(++_flushGeneration, _flushCts.Token); - } - finally - { - _gate.Release(); - } - - CancelPendingFlush(previousFlush); - } - - public override async Task CompleteAsync(MessageContent final) - { - ArgumentNullException.ThrowIfNull(final); - - await _gate.WaitAsync(CancellationToken.None); - try - { - if (_completed) - return; - - _completed = true; - CancelPendingFlush(_flushCts); - _flushCts = null; - await _adapter.UpdateAsync(_conversation, _activityId, final, CancellationToken.None); - } - finally - { - _gate.Release(); - } - } - - public override async ValueTask DisposeAsync() - { - await _gate.WaitAsync(CancellationToken.None); - try - { - if (_completed) - return; - - _completed = true; - CancelPendingFlush(_flushCts); - _flushCts = null; - var interruptedText = string.IsNullOrWhiteSpace(_currentText) - ? "(reply interrupted)" - : $"{_currentText} (reply interrupted)"; - - try - { - await _adapter.UpdateAsync( - _conversation, - _activityId, - BuildStreamingMessage(interruptedText), - CancellationToken.None); - } - catch - { - } - } - finally - { - _gate.Release(); - } - } - - private async Task FlushLaterAsync(long generation, CancellationToken ct) - { - try - { - if (_debounce > TimeSpan.Zero) - await Task.Delay(_debounce, _timeProvider, ct); - - await _gate.WaitAsync(ct); - try - { - if (_completed || ct.IsCancellationRequested || generation != _flushGeneration || _deltasBySequence.Count == 0) - return; - - _currentText += string.Concat(_deltasBySequence.OrderBy(static pair => pair.Key).Select(static pair => pair.Value)); - _deltasBySequence.Clear(); - await _adapter.UpdateAsync( - _conversation, - _activityId, - BuildStreamingMessage(_currentText), - ct); - } - finally - { - _gate.Release(); - } - } - catch (OperationCanceledException) - { - } - catch (Exception) - { - } - } - - private MessageContent BuildStreamingMessage(string text) - { - var content = _template.Clone(); - content.Text = text; - content.Attachments.Clear(); - return content; - } - - private static void CancelPendingFlush(CancellationTokenSource? flushCts) - { - if (flushCts is null) - return; - - flushCts.Cancel(); - flushCts.Dispose(); - } -} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookRequest.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookRequest.cs deleted file mode 100644 index e35fd277f..000000000 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookRequest.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Aevatar.GAgents.Channel.Telegram; - -public sealed record TelegramWebhookRequest( - byte[] Body, - IReadOnlyDictionary? Headers = null); diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookResponse.cs b/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookResponse.cs deleted file mode 100644 index 7f4c0f6e6..000000000 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramWebhookResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Aevatar.GAgents.Channel.Abstractions; - -namespace Aevatar.GAgents.Channel.Telegram; - -public sealed record TelegramWebhookResponse( - int StatusCode, - string? ResponseBody, - ChatActivity? Activity, - byte[]? SanitizedPayload); diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/Aevatar.GAgents.Channel.Telegram.csproj b/agents/platforms/Aevatar.GAgents.Platform.Telegram/Aevatar.GAgents.Platform.Telegram.csproj similarity index 79% rename from agents/channels/Aevatar.GAgents.Channel.Telegram/Aevatar.GAgents.Channel.Telegram.csproj rename to agents/platforms/Aevatar.GAgents.Platform.Telegram/Aevatar.GAgents.Platform.Telegram.csproj index b751ce724..ec401b343 100644 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/Aevatar.GAgents.Channel.Telegram.csproj +++ b/agents/platforms/Aevatar.GAgents.Platform.Telegram/Aevatar.GAgents.Platform.Telegram.csproj @@ -3,11 +3,11 @@ net10.0 enable enable - Aevatar.GAgents.Channel.Telegram - Aevatar.GAgents.Channel.Telegram + Aevatar.GAgents.Platform.Telegram + Aevatar.GAgents.Platform.Telegram - + diff --git a/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramChannelNativeMessageProducer.cs b/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramChannelNativeMessageProducer.cs new file mode 100644 index 000000000..de79ed8fc --- /dev/null +++ b/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramChannelNativeMessageProducer.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using Aevatar.GAgents.Channel.Abstractions; + +namespace Aevatar.GAgents.Platform.Telegram; + +/// +/// Produces a for the Telegram channel by delegating to +/// and mapping its shape onto the +/// platform-neutral DTO consumed by cross-platform dispatchers. +/// +public sealed class TelegramChannelNativeMessageProducer : IChannelNativeMessageProducer +{ + private readonly TelegramMessageComposer _composer; + + public TelegramChannelNativeMessageProducer(TelegramMessageComposer composer) + { + _composer = composer ?? throw new ArgumentNullException(nameof(composer)); + } + + public ChannelId Channel => _composer.Channel; + + public ComposeCapability Evaluate(MessageContent intent, ComposeContext context) => + _composer.Evaluate(intent, context); + + public ChannelNativeMessage Produce(MessageContent intent, ComposeContext context) + { + var capability = _composer.Evaluate(intent, context); + var payload = _composer.Compose(intent, context); + + if (!payload.IsInteractive) + return new ChannelNativeMessage( + Text: payload.PlainText, + CardPayload: null, + MessageType: payload.MessageType, + Capability: capability); + + object card = TryParseCard(payload.ContentJson); + return new ChannelNativeMessage( + Text: payload.PlainText, + CardPayload: card, + MessageType: payload.MessageType, + Capability: capability); + } + + private static object TryParseCard(string contentJson) + { + try + { + using var document = JsonDocument.Parse(contentJson); + return document.RootElement.Clone(); + } + catch (JsonException) + { + return contentJson; + } + } +} diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramMessageComposer.cs b/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramMessageComposer.cs similarity index 70% rename from agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramMessageComposer.cs rename to agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramMessageComposer.cs index 21b1faf5c..91e02aa66 100644 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramMessageComposer.cs +++ b/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramMessageComposer.cs @@ -3,7 +3,7 @@ using System.Text.Json; using Aevatar.GAgents.Channel.Abstractions; -namespace Aevatar.GAgents.Channel.Telegram; +namespace Aevatar.GAgents.Platform.Telegram; public sealed class TelegramMessageComposer : IMessageComposer { @@ -18,7 +18,7 @@ public sealed class TelegramMessageComposer : IMessageComposer 0 ? intent.Attachments[0].Clone() : null; - var textLimit = attachment is null - ? ResolveTextLimit(context.Capabilities?.MaxMessageLength ?? DefaultCapabilities.MaxMessageLength, TelegramTextLimit) - : ResolveTextLimit(Math.Min(context.Capabilities?.MaxMessageLength ?? DefaultCapabilities.MaxMessageLength, TelegramCaptionLimit), TelegramCaptionLimit); - var text = BuildRenderedText(intent, textLimit); - var replyMarkupJson = intent.Actions.Count == 0 - ? null - : BuildInlineKeyboardJson(intent.Actions, context.Capabilities?.SupportsActionButtons ?? DefaultCapabilities.SupportsActionButtons); - - return new TelegramOutboundMessage(text, replyMarkupJson, attachment, capability); + var capabilities = context.Capabilities ?? DefaultCapabilities; + var maxLength = ResolveTextLimit(capabilities.MaxMessageLength, TelegramTextLimit); + var effectiveText = BuildRenderedText(intent, maxLength); + + if (intent.Actions.Count == 0) + { + return new TelegramOutboundMessage( + MessageType: "text", + ContentJson: JsonSerializer.Serialize(new { text = effectiveText }), + PlainText: effectiveText, + IsInteractive: false); + } + + var supportsButtons = capabilities.SupportsActionButtons; + var keyboard = supportsButtons ? BuildInlineKeyboard(intent.Actions) : null; + if (keyboard is null) + { + return new TelegramOutboundMessage( + MessageType: "text", + ContentJson: JsonSerializer.Serialize(new { text = effectiveText }), + PlainText: effectiveText, + IsInteractive: false); + } + + var contentJson = JsonSerializer.Serialize(new + { + text = effectiveText, + reply_markup = new + { + inline_keyboard = keyboard, + }, + }); + + return new TelegramOutboundMessage( + MessageType: "interactive", + ContentJson: contentJson, + PlainText: effectiveText, + IsInteractive: true); } object IMessageComposer.Compose(MessageContent intent, ComposeContext context) => Compose(intent, context); @@ -58,35 +85,27 @@ public ComposeCapability Evaluate(MessageContent intent, ComposeContext context) ArgumentNullException.ThrowIfNull(context); var degraded = false; - var capabilities = context.Capabilities ?? DefaultCapabilities; + if (intent.Disposition == MessageDisposition.Ephemeral && !capabilities.SupportsEphemeral) degraded = true; - if (intent.Cards.Count > 0) - degraded = true; + if (intent.Attachments.Count > 0 && !capabilities.SupportsFiles) + return ComposeCapability.Unsupported; if (intent.Actions.Count > 0 && !capabilities.SupportsActionButtons) degraded = true; - if (intent.Attachments.Count > 1) - degraded = true; - var attachmentLimit = intent.Attachments.Count > 0 ? TelegramCaptionLimit : TelegramTextLimit; - var maxLength = ResolveTextLimit(Math.Min(capabilities.MaxMessageLength, attachmentLimit), attachmentLimit); + var maxLength = ResolveTextLimit(capabilities.MaxMessageLength, TelegramTextLimit); if (BuildRenderedText(intent, int.MaxValue).Length > maxLength) degraded = true; return degraded ? ComposeCapability.Degraded : ComposeCapability.Exact; } - private static string? BuildInlineKeyboardJson( - IEnumerable actions, - bool supportsActionButtons) + private static object[][]? BuildInlineKeyboard(IEnumerable actions) { - if (!supportsActionButtons) - return null; - var rows = actions .Where(static action => action.Kind == ActionElementKind.Button && !string.IsNullOrWhiteSpace(action.Label)) - .Select(static action => new[] + .Select(static action => new object[] { new { @@ -96,12 +115,7 @@ public ComposeCapability Evaluate(MessageContent intent, ComposeContext context) }) .ToArray(); - return rows.Length == 0 - ? null - : JsonSerializer.Serialize(new - { - inline_keyboard = rows, - }); + return rows.Length == 0 ? null : rows; } private static string BuildCallbackData(ActionElement action) diff --git a/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramOutboundMessage.cs b/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramOutboundMessage.cs new file mode 100644 index 000000000..e2e24148d --- /dev/null +++ b/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramOutboundMessage.cs @@ -0,0 +1,9 @@ +using Aevatar.GAgents.Channel.Abstractions; + +namespace Aevatar.GAgents.Platform.Telegram; + +public sealed record TelegramOutboundMessage( + string MessageType, + string ContentJson, + string PlainText, + bool IsInteractive) : IPlainTextComposedMessage; diff --git a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramPayloadRedactor.cs b/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramPayloadRedactor.cs similarity index 98% rename from agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramPayloadRedactor.cs rename to agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramPayloadRedactor.cs index d269432cb..ebe8ea341 100644 --- a/agents/channels/Aevatar.GAgents.Channel.Telegram/TelegramPayloadRedactor.cs +++ b/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramPayloadRedactor.cs @@ -3,7 +3,7 @@ using System.Text.Json.Nodes; using Aevatar.GAgents.Channel.Abstractions; -namespace Aevatar.GAgents.Channel.Telegram; +namespace Aevatar.GAgents.Platform.Telegram; public sealed class TelegramPayloadRedactor : IPayloadRedactor { diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs deleted file mode 100644 index c22bd9b6b..000000000 --- a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramAdapterTestSupport.cs +++ /dev/null @@ -1,527 +0,0 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Aevatar.Foundation.Abstractions.Credentials; -using Aevatar.GAgents.Channel.Abstractions; -using Aevatar.GAgents.Channel.Telegram; -using Aevatar.GAgents.Channel.Testing; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Aevatar.GAgents.Channel.Telegram.Tests; - -internal sealed class TelegramAdapterHarness -{ - private const string PrimaryCredentialRef = "vault://telegram/primary"; - private const string SecondaryCredentialRef = "vault://telegram/secondary"; - - public ChannelTransportBinding DefaultBinding { get; } = ChannelTransportBinding.Create( - ChannelBotDescriptor.Create( - registrationId: "telegram-primary", - channel: ChannelId.From("telegram"), - bot: BotInstanceId.From("telegram-primary-bot")), - credentialRef: PrimaryCredentialRef, - verificationToken: "secret-primary"); - - public ChannelTransportBinding SecondaryBinding { get; } = ChannelTransportBinding.Create( - ChannelBotDescriptor.Create( - registrationId: "telegram-secondary", - channel: ChannelId.From("telegram"), - bot: BotInstanceId.From("telegram-secondary-bot")), - credentialRef: SecondaryCredentialRef, - verificationToken: "secret-secondary"); - - public TestCredentialProvider CredentialProvider { get; private set; } = null!; - - public RecordingTelegramHttpHandler HttpHandler { get; private set; } = null!; - - public FakeTelegramAttachmentContentResolver AttachmentResolver { get; private set; } = null!; - - public TelegramWebhookFixture Webhook { get; private set; } = null!; - - public TelegramPayloadRedactor Redactor { get; private set; } = null!; - - public TelegramStreamingProbe StreamingProbe { get; private set; } = null!; - - public TelegramChannelAdapter Reset(TransportMode transportMode = TransportMode.Webhook) - { - HttpHandler = new RecordingTelegramHttpHandler(); - CredentialProvider = new TestCredentialProvider(); - CredentialProvider.Set(PrimaryCredentialRef, "bot-token-primary"); - CredentialProvider.Set(SecondaryCredentialRef, "bot-token-secondary"); - AttachmentResolver = new FakeTelegramAttachmentContentResolver(); - Redactor = new TelegramPayloadRedactor(); - - var adapter = new TelegramChannelAdapter( - CredentialProvider, - new TelegramMessageComposer(), - Redactor, - NullLogger.Instance, - new HttpClient(HttpHandler) - { - BaseAddress = TelegramChannelDefaults.DefaultBaseAddress, - }, - AttachmentResolver, - new TelegramChannelAdapterOptions - { - TransportMode = transportMode, - RecommendedStreamDebounceMs = 10, - }); - - Webhook = new TelegramWebhookFixture(adapter, DefaultBinding); - StreamingProbe = new TelegramStreamingProbe(adapter, HttpHandler); - return adapter; - } -} - -internal sealed class RecordingTelegramHttpHandler : HttpMessageHandler -{ - private readonly Dictionary<(long ChatId, int MessageId), RecordedTelegramMessage> _messages = new(); - private readonly Queue _pollOutcomes = new(); - private int _nextMessageId = 100; - private TaskCompletionSource _firstPollCall = CreateSignal(); - private TaskCompletionSource _firstEditCall = CreateSignal(); - - public IReadOnlyDictionary<(long ChatId, int MessageId), RecordedTelegramMessage> Messages => _messages; - - public string? LastBotToken { get; private set; } - - public string? LastMethodName { get; private set; } - - public string? LastPath { get; private set; } - - public string? LastMessageId { get; private set; } - - public int PollCallCount { get; private set; } - - public int EditCallCount { get; private set; } - - public Task FirstPollCallAsync => _firstPollCall.Task; - - public Task FirstEditCallAsync => _firstEditCall.Task; - - public TaskCompletionSource? BlockNextEditCompletion { get; set; } - - public void EnqueuePollResponse(params byte[][] updates) => - _pollOutcomes.Enqueue(TelegramPollOutcome.Success(updates.Select(Encoding.UTF8.GetString).ToArray())); - - public void EnqueuePollFailure(int statusCode, string description) => - _pollOutcomes.Enqueue(TelegramPollOutcome.Failure(statusCode, description)); - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - (LastBotToken, LastMethodName) = ParseRoute(request.RequestUri); - LastPath = request.RequestUri?.PathAndQuery; - - return LastMethodName switch - { - "getUpdates" => HandleGetUpdates(), - "sendMessage" => await HandleSendAsync(request, "sendMessage", cancellationToken), - "sendPhoto" => await HandleSendAsync(request, "sendPhoto", cancellationToken), - "sendDocument" => await HandleSendAsync(request, "sendDocument", cancellationToken), - "editMessageText" => await HandleEditAsync(request, cancellationToken), - "deleteMessage" => await HandleDeleteAsync(request, cancellationToken), - _ => new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent( - JsonSerializer.Serialize(new - { - ok = false, - error_code = 400, - description = $"unsupported method {LastMethodName}", - }), - Encoding.UTF8, - "application/json"), - }, - }; - } - - public string ReadText(string activityId) - { - var (chatId, messageId) = ParseActivityId(activityId); - return _messages[(chatId, messageId)].Text; - } - - private HttpResponseMessage HandleGetUpdates() - { - PollCallCount++; - _firstPollCall.TrySetResult(true); - - if (_pollOutcomes.Count == 0) - return Ok(new { ok = true, result = Array.Empty() }); - - var outcome = _pollOutcomes.Dequeue(); - if (outcome.StatusCode.HasValue) - { - return new HttpResponseMessage((HttpStatusCode)outcome.StatusCode.Value) - { - Content = new StringContent( - JsonSerializer.Serialize(new - { - ok = false, - error_code = outcome.StatusCode.Value, - description = outcome.Description, - }), - Encoding.UTF8, - "application/json"), - }; - } - - var updates = outcome.UpdateJsonPayloads!.Select(static payload => JsonDocument.Parse(payload).RootElement.Clone()).ToArray(); - return Ok(new - { - ok = true, - result = updates, - }); - } - - private async Task HandleSendAsync( - HttpRequestMessage request, - string methodName, - CancellationToken cancellationToken) - { - var root = await ReadJsonAsync(request, cancellationToken); - var chatId = root.GetProperty("chat_id").GetInt64(); - var text = TryReadString(root, "text") ?? TryReadString(root, "caption") ?? string.Empty; - var replyMarkupJson = root.TryGetProperty("reply_markup", out var replyMarkup) - ? replyMarkup.GetRawText() - : null; - var messageId = Interlocked.Increment(ref _nextMessageId); - _messages[(chatId, messageId)] = new RecordedTelegramMessage( - ChatId: chatId, - MessageId: messageId, - MethodName: methodName, - Text: text, - ReplyMarkupJson: replyMarkupJson, - Deleted: false); - LastMessageId = BuildActivityId(chatId, messageId); - - return Ok(new - { - ok = true, - result = new - { - message_id = messageId, - chat = new - { - id = chatId, - }, - }, - }); - } - - private async Task HandleEditAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - EditCallCount++; - _firstEditCall.TrySetResult(true); - - var blocker = BlockNextEditCompletion; - BlockNextEditCompletion = null; - if (blocker is not null) - await blocker.Task.WaitAsync(cancellationToken); - - var root = await ReadJsonAsync(request, cancellationToken); - var chatId = root.GetProperty("chat_id").GetInt64(); - var messageId = root.GetProperty("message_id").GetInt32(); - var text = root.GetProperty("text").GetString() ?? string.Empty; - var replyMarkupJson = root.TryGetProperty("reply_markup", out var replyMarkup) - ? replyMarkup.GetRawText() - : null; - _messages[(chatId, messageId)] = new RecordedTelegramMessage( - ChatId: chatId, - MessageId: messageId, - MethodName: "editMessageText", - Text: text, - ReplyMarkupJson: replyMarkupJson, - Deleted: false); - LastMessageId = BuildActivityId(chatId, messageId); - - return Ok(new - { - ok = true, - result = new - { - message_id = messageId, - chat = new - { - id = chatId, - }, - }, - }); - } - - private async Task HandleDeleteAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var root = await ReadJsonAsync(request, cancellationToken); - var chatId = root.GetProperty("chat_id").GetInt64(); - var messageId = root.GetProperty("message_id").GetInt32(); - if (_messages.TryGetValue((chatId, messageId), out var existing)) - _messages[(chatId, messageId)] = existing with { Deleted = true }; - LastMessageId = BuildActivityId(chatId, messageId); - - return Ok(new - { - ok = true, - result = true, - }); - } - - private static async Task ReadJsonAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var body = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken); - using var document = JsonDocument.Parse(body); - return document.RootElement.Clone(); - } - - private static string? TryReadString(JsonElement element, string propertyName) => - element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String - ? property.GetString() - : null; - - private static (string BotToken, string MethodName) ParseRoute(Uri? uri) - { - var segments = uri?.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - ?? Array.Empty(); - if (segments.Length < 2 || !segments[0].StartsWith("bot", StringComparison.Ordinal)) - throw new InvalidOperationException($"Unexpected Telegram request path: {uri}"); - - return (segments[0]["bot".Length..], segments[1]); - } - - private static (long ChatId, int MessageId) ParseActivityId(string activityId) - { - var parts = activityId.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length == 4 && - string.Equals(parts[0], "telegram", StringComparison.Ordinal) && - string.Equals(parts[1], "message", StringComparison.Ordinal) && - long.TryParse(parts[2], out var chatId) && - int.TryParse(parts[3], out var messageId)) - { - return (chatId, messageId); - } - - throw new InvalidOperationException($"Unexpected Telegram activity id: {activityId}"); - } - - private static string BuildActivityId(long chatId, int messageId) => $"telegram:message:{chatId}:{messageId}"; - - private static HttpResponseMessage Ok(object payload) => new(HttpStatusCode.OK) - { - Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"), - }; - - private static TaskCompletionSource CreateSignal() => - new(TaskCreationOptions.RunContinuationsAsynchronously); - - private sealed record TelegramPollOutcome(string[]? UpdateJsonPayloads, int? StatusCode, string? Description) - { - public static TelegramPollOutcome Success(string[] payloads) => new(payloads, null, null); - - public static TelegramPollOutcome Failure(int statusCode, string description) => new(null, statusCode, description); - } -} - -internal sealed record RecordedTelegramMessage( - long ChatId, - int MessageId, - string MethodName, - string Text, - string? ReplyMarkupJson, - bool Deleted); - -internal sealed class TelegramWebhookFixture( - TelegramChannelAdapter adapter, - ChannelTransportBinding defaultBinding) : WebhookFixture -{ - private byte[]? _lastBody; - private Dictionary? _lastHeaders; - private string? _lastPersistedBlobRef; - private byte[]? _lastRawPayloadBytes; - - public override string? LastPersistedBlobRef => _lastPersistedBlobRef; - - public override byte[]? LastRawPayloadBytes => _lastRawPayloadBytes; - - public override Task DispatchInboundAsync(InboundActivitySeed seed, CancellationToken ct = default) => - DispatchInboundToBindingAsync(defaultBinding, seed, ct); - - public override async Task ReplayLastInboundAsync(CancellationToken ct = default) - { - if (_lastBody is null || _lastHeaders is null) - return null; - - var response = await adapter.HandleWebhookAsync(new TelegramWebhookRequest(_lastBody, _lastHeaders), ct); - _lastPersistedBlobRef = response.Activity?.RawPayloadBlobRef; - _lastRawPayloadBytes = response.SanitizedPayload; - return response.Activity; - } - - public override async Task DispatchInboundToBindingAsync( - ChannelTransportBinding binding, - InboundActivitySeed seed, - CancellationToken ct = default) - { - _lastBody = BuildPayload(seed, out _); - _lastHeaders = new Dictionary(StringComparer.Ordinal) - { - [TelegramChannelDefaults.SecretHeaderName] = binding.VerificationToken, - }; - - var response = await adapter.HandleWebhookAsync(new TelegramWebhookRequest(_lastBody, _lastHeaders), ct); - _lastPersistedBlobRef = response.Activity?.RawPayloadBlobRef; - _lastRawPayloadBytes = response.SanitizedPayload; - return response.Activity ?? throw new InvalidOperationException("Expected one ChatActivity."); - } - - internal static byte[] BuildPayload(InboundActivitySeed seed, out long chatId) - { - chatId = seed.Scope == ConversationScope.DirectMessage - ? PositiveDeterministicId(seed.ConversationKey) - : -PositiveDeterministicId(seed.ConversationKey); - var senderId = PositiveDeterministicId(seed.SenderCanonicalId); - var updateId = PositiveDeterministicInt(seed.PlatformMessageId ?? $"{seed.ConversationKey}:{seed.Text}"); - var messageId = PositiveDeterministicInt(seed.PlatformMessageId ?? $"{seed.Text}:{seed.SenderCanonicalId}"); - var chatType = seed.Scope == ConversationScope.DirectMessage ? "private" : "group"; - - return JsonSerializer.SerializeToUtf8Bytes(new - { - update_id = updateId, - message = new - { - message_id = messageId, - date = 1_714_000_000, - chat = new - { - id = chatId, - type = chatType, - }, - from = new - { - id = senderId, - is_bot = false, - username = seed.SenderDisplayName.Replace(' ', '_'), - first_name = seed.SenderDisplayName, - }, - text = seed.Text, - }, - }); - } - - internal static long PositiveDeterministicId(string value) - { - var hash = Fnv1a64(value); - return (long)(hash % 9_000_000_000_000UL) + 1000; - } - - internal static int PositiveDeterministicInt(string value) - { - var hash = Fnv1a64(value); - return (int)(hash % int.MaxValue) + 1; - } - - private static ulong Fnv1a64(string value) - { - const ulong offset = 14695981039346656037; - const ulong prime = 1099511628211; - - var hash = offset; - foreach (var b in Encoding.UTF8.GetBytes(value)) - { - hash ^= b; - hash *= prime; - } - - return hash; - } -} - -internal sealed class TelegramStreamingProbe( - TelegramChannelAdapter adapter, - RecordingTelegramHttpHandler handler) : StreamingFaultProbe -{ - private static readonly ConversationReference Reference = - ConversationReference.TelegramPrivate(BotInstanceId.From("telegram-primary-bot"), "42"); - - public override async Task DisposeWithoutCompleteMarksInterruptedAsync(CancellationToken ct = default) - { - var handle = await adapter.BeginStreamingReplyAsync(Reference, SampleMessageContent.SimpleText("seed"), ct); - await handle.AppendAsync(new StreamChunk - { - Delta = " partial", - SequenceNumber = 1, - }); - await handle.DisposeAsync(); - - var lastId = handler.LastMessageId ?? throw new InvalidOperationException("No message recorded."); - return handler.ReadText(lastId).Contains("reply interrupted", StringComparison.Ordinal); - } - - public override async Task IntentDegradesMidwayReachesTerminalStateAsync(CancellationToken ct = default) - { - var handle = await adapter.BeginStreamingReplyAsync(Reference, SampleMessageContent.TextWithCard("seed"), ct); - await handle.AppendAsync(new StreamChunk - { - Delta = " later", - SequenceNumber = 1, - }); - await handle.CompleteAsync(SampleMessageContent.SimpleText("done")); - - var lastId = handler.LastMessageId ?? throw new InvalidOperationException("No message recorded."); - return string.Equals(handler.ReadText(lastId), "done", StringComparison.Ordinal); - } - - public override async Task AppendIdempotentBySequenceNumberAsync(CancellationToken ct = default) - { - var handle = await adapter.BeginStreamingReplyAsync(Reference, SampleMessageContent.SimpleText("seed"), ct); - await handle.AppendAsync(new StreamChunk - { - Delta = "A", - SequenceNumber = 1, - }); - await handle.AppendAsync(new StreamChunk - { - Delta = "A", - SequenceNumber = 1, - }); - await handle.AppendAsync(new StreamChunk - { - Delta = "A", - SequenceNumber = 2, - }); - await handle.CompleteAsync(SampleMessageContent.SimpleText("seedAA")); - - var lastId = handler.LastMessageId ?? throw new InvalidOperationException("No message recorded."); - return string.Equals(handler.ReadText(lastId), "seedAA", StringComparison.Ordinal); - } -} - -internal sealed class FakeTelegramAttachmentContentResolver : ITelegramAttachmentContentResolver -{ - public Task ResolveAsync(AttachmentRef attachment, CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(attachment); - return Task.FromResult(new TelegramAttachmentContent( - FileName: string.IsNullOrWhiteSpace(attachment.Name) ? "attachment.bin" : attachment.Name, - ContentType: string.IsNullOrWhiteSpace(attachment.ContentType) ? "application/octet-stream" : attachment.ContentType, - Content: null, - ExternalUrl: $"https://cdn.example.com/{Uri.EscapeDataString(attachment.AttachmentId)}")); - } -} - -internal sealed class TestCredentialProvider : ICredentialProvider -{ - private readonly Dictionary _values = new(StringComparer.Ordinal); - - public void Set(string credentialRef, string secret) => _values[credentialRef] = secret; - - public Task ResolveAsync(string credentialRef, CancellationToken ct = default) => - Task.FromResult(_values.TryGetValue(credentialRef, out var value) ? value : null); -} - -internal sealed class ThrowingRedactor : IPayloadRedactor -{ - public Task RedactAsync(ChannelId channel, byte[] rawPayload, CancellationToken ct) => - throw new InvalidOperationException("redactor boom"); - - public Task HealthCheckAsync(CancellationToken ct) => - Task.FromResult(HealthStatus.Unhealthy); -} diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterConformanceTests.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterConformanceTests.cs deleted file mode 100644 index 1f504ee31..000000000 --- a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterConformanceTests.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Aevatar.GAgents.Channel.Abstractions; -using Aevatar.GAgents.Channel.Telegram; -using Aevatar.GAgents.Channel.Testing; - -namespace Aevatar.GAgents.Channel.Telegram.Tests; - -public sealed class TelegramChannelAdapterConformanceTests - : ChannelAdapterConformanceTests -{ - private readonly TelegramAdapterHarness _harness = new(); - - protected override TelegramChannelAdapter CreateAdapter() => _harness.Reset(); - - protected override WebhookFixture? WebhookFixture => _harness.Webhook; - - protected override GatewayFixture? GatewayFixture => null; - - protected override ChannelTransportBinding CreateBinding() => _harness.DefaultBinding; - - protected override ChannelTransportBinding? CreateSecondaryBinding() => _harness.SecondaryBinding; - - protected override ConversationReference BuildDirectMessageReference(TelegramChannelAdapter adapter) => - ConversationReference.TelegramPrivate(BotInstanceId.From("telegram-primary-bot"), "42"); -} diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterFaultTests.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterFaultTests.cs deleted file mode 100644 index 2e28f0138..000000000 --- a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterFaultTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Aevatar.GAgents.Channel.Abstractions; -using Aevatar.GAgents.Channel.Telegram; -using Aevatar.GAgents.Channel.Testing; - -namespace Aevatar.GAgents.Channel.Telegram.Tests; - -public sealed class TelegramChannelAdapterFaultTests : ChannelAdapterFaultTests -{ - private readonly TelegramAdapterHarness _harness = new(); - - protected override TelegramChannelAdapter CreateAdapter() => _harness.Reset(); - - protected override WebhookFixture? WebhookFixture => _harness.Webhook; - - protected override GatewayFixture? GatewayFixture => null; - - protected override ChannelTransportBinding CreateBinding() => _harness.DefaultBinding; - - protected override IPayloadRedactor? Redactor => _harness.Redactor; - - protected override StreamingFaultProbe? StreamingProbe => _harness.StreamingProbe; -} diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs deleted file mode 100644 index 8e2a5e141..000000000 --- a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramChannelAdapterModeTests.cs +++ /dev/null @@ -1,398 +0,0 @@ -using System.Text.Json; -using Aevatar.GAgents.Channel.Abstractions; -using Aevatar.GAgents.Channel.Telegram; -using Aevatar.GAgents.Channel.Testing; -using Microsoft.Extensions.Logging.Abstractions; -using Shouldly; - -namespace Aevatar.GAgents.Channel.Telegram.Tests; - -public sealed class TelegramChannelAdapterModeTests -{ - [Fact] - public async Task StartReceiving_LongPollingMode_PublishesInboundMessage() - { - var harness = new TelegramAdapterHarness(); - var adapter = harness.Reset(TransportMode.LongPolling); - harness.HttpHandler.EnqueuePollResponse( - TelegramWebhookFixture.BuildPayload(InboundActivitySeed.DirectMessage("hello long polling"), out _)); - - await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); - await adapter.StartReceivingAsync(CancellationToken.None); - - try - { - using var readTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - var activity = await adapter.InboundStream.ReadAsync(readTimeout.Token); - - activity.Content.Text.ShouldBe("hello long polling"); - activity.Conversation.Scope.ShouldBe(ConversationScope.DirectMessage); - } - finally - { - await adapter.StopReceivingAsync(CancellationToken.None); - } - } - - [Fact] - public async Task HandleWebhookAsync_ShouldDifferentiateSupergroupAndChannelPosts() - { - var harness = new TelegramAdapterHarness(); - var adapter = harness.Reset(); - await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); - await adapter.StartReceivingAsync(CancellationToken.None); - - try - { - var supergroupPayload = JsonSerializer.SerializeToUtf8Bytes(new - { - update_id = 8001, - message = new - { - message_id = 501, - date = 1_714_000_000, - chat = new - { - id = -100200300400L, - type = "supergroup", - }, - from = new - { - id = 77, - is_bot = false, - first_name = "Alice", - }, - text = "supergroup message", - }, - }); - var channelPayload = JsonSerializer.SerializeToUtf8Bytes(new - { - update_id = 8002, - channel_post = new - { - message_id = 601, - date = 1_714_000_010, - chat = new - { - id = -100500600700L, - type = "channel", - title = "ops-channel", - }, - text = "channel post", - }, - }); - - var supergroupResponse = await adapter.HandleWebhookAsync(new TelegramWebhookRequest(supergroupPayload, SecretHeaders("secret-primary"))); - var channelResponse = await adapter.HandleWebhookAsync(new TelegramWebhookRequest(channelPayload, SecretHeaders("secret-primary"))); - - supergroupResponse.StatusCode.ShouldBe(200); - supergroupResponse.Activity.ShouldNotBeNull(); - supergroupResponse.Activity.Conversation.Scope.ShouldBe(ConversationScope.Group); - supergroupResponse.Activity.Conversation.CanonicalKey.ShouldContain("supergroup"); - supergroupResponse.Activity.RawPayloadBlobRef.ShouldStartWith("telegram-raw:"); - - channelResponse.StatusCode.ShouldBe(200); - channelResponse.Activity.ShouldNotBeNull(); - channelResponse.Activity.Conversation.Scope.ShouldBe(ConversationScope.Channel); - channelResponse.Activity.Conversation.CanonicalKey.ShouldContain("channel"); - channelResponse.Activity.RawPayloadBlobRef.ShouldStartWith("telegram-raw:"); - } - finally - { - await adapter.StopReceivingAsync(CancellationToken.None); - } - } - - [Fact] - public async Task HandleWebhookAsync_AttachmentOnlyMessage_PreservesAttachment() - { - var harness = new TelegramAdapterHarness(); - var adapter = harness.Reset(); - await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); - await adapter.StartReceivingAsync(CancellationToken.None); - - try - { - var payload = JsonSerializer.SerializeToUtf8Bytes(new - { - update_id = 9101, - message = new - { - message_id = 701, - date = 1_714_000_000, - chat = new - { - id = 42, - type = "private", - }, - from = new - { - id = 77, - is_bot = false, - first_name = "Alice", - }, - photo = new object[] - { - new - { - file_id = "photo-small", - file_size = 32, - }, - new - { - file_id = "photo-large", - file_size = 128, - }, - }, - }, - }); - - var response = await adapter.HandleWebhookAsync(new TelegramWebhookRequest(payload, SecretHeaders("secret-primary"))); - - response.StatusCode.ShouldBe(200); - response.Activity.ShouldNotBeNull(); - response.Activity.Content.Text.ShouldBeEmpty(); - response.Activity.Content.Attachments.Count.ShouldBe(1); - response.Activity.Content.Attachments[0].AttachmentId.ShouldBe("photo-large"); - response.Activity.Content.Attachments[0].Kind.ShouldBe(AttachmentKind.Image); - } - finally - { - await adapter.StopReceivingAsync(CancellationToken.None); - } - } - - [Fact] - public async Task HandleWebhookAsync_CallbackQuery_BecomesCardActionSubmission() - { - var harness = new TelegramAdapterHarness(); - var adapter = harness.Reset(); - await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); - await adapter.StartReceivingAsync(CancellationToken.None); - - try - { - var payload = JsonSerializer.SerializeToUtf8Bytes(new - { - update_id = 9201, - callback_query = new - { - id = "callback-1", - data = "confirm", - from = new - { - id = 91, - username = "alice", - }, - message = new - { - message_id = 333, - date = 1_714_000_000, - chat = new - { - id = 42, - type = "private", - }, - }, - }, - }); - - var response = await adapter.HandleWebhookAsync(new TelegramWebhookRequest(payload, SecretHeaders("secret-primary"))); - - response.StatusCode.ShouldBe(200); - response.Activity.ShouldNotBeNull(); - response.Activity.Type.ShouldBe(ActivityType.CardAction); - response.Activity.Content.CardAction.ShouldNotBeNull(); - response.Activity.Content.CardAction.ActionId.ShouldBe("confirm"); - response.Activity.Content.CardAction.SourceMessageId.ShouldBe("telegram:message:42:333"); - } - finally - { - await adapter.StopReceivingAsync(CancellationToken.None); - } - } - - [Fact] - public async Task BeginStreamingReplyAsync_ShouldFinalizeByEditingMessage() - { - var harness = new TelegramAdapterHarness(); - var adapter = harness.Reset(); - await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); - await adapter.StartReceivingAsync(CancellationToken.None); - - var reference = ConversationReference.TelegramPrivate(BotInstanceId.From("telegram-primary-bot"), "42"); - try - { - await using var handle = await adapter.BeginStreamingReplyAsync( - reference, - SampleMessageContent.SimpleText("seed"), - CancellationToken.None); - - var releaseEdit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - harness.HttpHandler.BlockNextEditCompletion = releaseEdit; - await handle.AppendAsync(new StreamChunk - { - SequenceNumber = 1, - Delta = " plus", - }); - await harness.HttpHandler.FirstEditCallAsync.WaitAsync(TimeSpan.FromSeconds(5)); - - var completeTask = handle.CompleteAsync(SampleMessageContent.SimpleText("seed plus final")); - completeTask.IsCompleted.ShouldBeFalse(); - - releaseEdit.TrySetResult(true); - await completeTask; - - harness.HttpHandler.EditCallCount.ShouldBe(2); - harness.HttpHandler.ReadText(harness.HttpHandler.LastMessageId!).ShouldBe("seed plus final"); - } - finally - { - await adapter.StopReceivingAsync(CancellationToken.None); - } - } - - [Fact] - public async Task StartReceiving_LongPollingAuthFailure_StopsRetryLoop() - { - var harness = new TelegramAdapterHarness(); - var adapter = harness.Reset(TransportMode.LongPolling); - harness.HttpHandler.EnqueuePollFailure(401, "unauthorized"); - - await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); - await adapter.StartReceivingAsync(CancellationToken.None); - - try - { - await harness.HttpHandler.FirstPollCallAsync.WaitAsync(TimeSpan.FromSeconds(5)); - } - finally - { - await adapter.StopReceivingAsync(CancellationToken.None); - } - - harness.HttpHandler.PollCallCount.ShouldBe(1); - } - - [Fact] - public async Task SendAsync_RefreshesBotCredentialBeforeOutboundRequest() - { - var harness = new TelegramAdapterHarness(); - var adapter = harness.Reset(); - await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); - await adapter.StartReceivingAsync(CancellationToken.None); - harness.CredentialProvider.Set("vault://telegram/primary", "rotated-bot-token"); - - try - { - var emit = await adapter.SendAsync( - ConversationReference.TelegramPrivate(BotInstanceId.From("telegram-primary-bot"), "42"), - SampleMessageContent.SimpleText("hello"), - CancellationToken.None); - - emit.Success.ShouldBeTrue(); - harness.HttpHandler.LastBotToken.ShouldBe("rotated-bot-token"); - } - finally - { - await adapter.StopReceivingAsync(CancellationToken.None); - } - } - - [Fact] - public async Task ContinueConversationAsync_OnBehalfOfUser_ReturnsUnsupported() - { - var harness = new TelegramAdapterHarness(); - var adapter = harness.Reset(); - await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); - await adapter.StartReceivingAsync(CancellationToken.None); - - try - { - var emit = await adapter.ContinueConversationAsync( - ConversationReference.TelegramPrivate(BotInstanceId.From("telegram-primary-bot"), "42"), - SampleMessageContent.SimpleText("hello"), - AuthContext.OnBehalfOfUser("vault://users/delegate", "delegate-user"), - CancellationToken.None); - - emit.Success.ShouldBeFalse(); - emit.ErrorCode.ShouldBe("principal_unsupported"); - emit.Capability.ShouldBe(ComposeCapability.Unsupported); - } - finally - { - await adapter.StopReceivingAsync(CancellationToken.None); - } - } - - [Fact] - public async Task HandleWebhookAsync_WhenRedactorThrows_FailsClosed() - { - var credentialProvider = new TestCredentialProvider(); - credentialProvider.Set("vault://telegram/test", "bot-token"); - var adapter = new TelegramChannelAdapter( - credentialProvider, - new TelegramMessageComposer(), - new ThrowingRedactor(), - NullLogger.Instance, - new HttpClient(new RecordingTelegramHttpHandler()) - { - BaseAddress = TelegramChannelDefaults.DefaultBaseAddress, - }); - var binding = ChannelTransportBinding.Create( - ChannelBotDescriptor.Create("telegram-test", ChannelId.From("telegram"), BotInstanceId.From("telegram-test-bot")), - "vault://telegram/test", - "secret-primary"); - await adapter.InitializeAsync(binding, CancellationToken.None); - await adapter.StartReceivingAsync(CancellationToken.None); - - try - { - var payload = TelegramWebhookFixture.BuildPayload(InboundActivitySeed.DirectMessage("hello"), out _); - - var response = await adapter.HandleWebhookAsync(new TelegramWebhookRequest(payload, SecretHeaders("secret-primary"))); - - response.StatusCode.ShouldBe(503); - response.Activity.ShouldBeNull(); - response.SanitizedPayload.ShouldBeNull(); - } - finally - { - await adapter.StopReceivingAsync(CancellationToken.None); - } - } - - [Fact] - public async Task SendAsync_WhitespaceOnlyTextWithoutAttachment_ReturnsFailure() - { - var harness = new TelegramAdapterHarness(); - var adapter = harness.Reset(); - await adapter.InitializeAsync(harness.DefaultBinding, CancellationToken.None); - await adapter.StartReceivingAsync(CancellationToken.None); - - try - { - var emit = await adapter.SendAsync( - ConversationReference.TelegramPrivate(BotInstanceId.From("telegram-primary-bot"), "42"), - new MessageContent - { - Text = " ", - Disposition = MessageDisposition.Normal, - }, - CancellationToken.None); - - emit.Success.ShouldBeFalse(); - emit.ErrorCode.ShouldBe("telegram_empty_message"); - } - finally - { - await adapter.StopReceivingAsync(CancellationToken.None); - } - } - - private static IReadOnlyDictionary SecretHeaders(string secret) => - new Dictionary(StringComparer.Ordinal) - { - [TelegramChannelDefaults.SecretHeaderName] = secret, - }; -} diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs b/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs deleted file mode 100644 index d3f6c564e..000000000 --- a/test/Aevatar.GAgents.Channel.Telegram.Tests/TelegramMessageComposerTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.Text.Json; -using Aevatar.GAgents.Channel.Abstractions; -using Aevatar.GAgents.Channel.Telegram; -using Aevatar.GAgents.Channel.Testing; -using Shouldly; - -namespace Aevatar.GAgents.Channel.Telegram.Tests; - -public sealed class TelegramMessageComposerTests : MessageComposerUnitTests -{ - protected override TelegramMessageComposer CreateComposer() => new(); - - protected override ChannelCapabilities CreateCapabilities() => new() - { - SupportsEphemeral = false, - SupportsEdit = true, - SupportsDelete = true, - SupportsThread = false, - Streaming = StreamingSupport.EditLoopRateLimited, - SupportsFiles = true, - MaxMessageLength = 4096, - SupportsActionButtons = true, - SupportsConfirmDialog = false, - SupportsModal = false, - SupportsMention = false, - SupportsTyping = false, - SupportsReactions = false, - RecommendedStreamDebounceMs = 3000, - Transport = TransportMode.Webhook, - }; - - protected override void AssertSimpleTextPayload(object payload, MessageContent intent, ComposeContext context) - { - var native = payload.ShouldBeOfType(); - native.Text.ShouldBe(intent.Text); - native.Attachment.ShouldBeNull(); - } - - protected override void AssertActionsPayload(object payload, MessageContent intent, ComposeContext context, ComposeCapability capability) - { - var native = payload.ShouldBeOfType(); - if (capability == ComposeCapability.Exact) - { - native.ReplyMarkupJson.ShouldNotBeNull(); - using var document = JsonDocument.Parse(native.ReplyMarkupJson); - document.RootElement.GetProperty("inline_keyboard").GetArrayLength().ShouldBe(2); - } - } - - protected override void AssertCardPayload(object payload, MessageContent intent, ComposeContext context, ComposeCapability capability) - { - var native = payload.ShouldBeOfType(); - native.Text.ShouldContain("Hero"); - capability.ShouldBe(ComposeCapability.Degraded); - } - - protected override void AssertOverflowTruncation(object payload, int maxLength) - { - payload.ShouldBeOfType().Text.Length.ShouldBeLessThanOrEqualTo(maxLength); - } - - [Fact] - public void Compose_WhitespaceOnlyIntent_DoesNotInventPlaceholderText() - { - var composer = CreateComposer(); - var payload = composer.Compose(new MessageContent - { - Text = " ", - Disposition = MessageDisposition.Normal, - }, CreateContext()); - - payload.Text.ShouldBeEmpty(); - } - - [Fact] - public void Options_DefaultLongPollingTimeout_UsesTelegramFriendlyDefault() - { - new TelegramChannelAdapterOptions().LongPollingTimeoutSeconds.ShouldBe(30); - } -} diff --git a/test/Aevatar.GAgents.Channel.Telegram.Tests/Aevatar.GAgents.Channel.Telegram.Tests.csproj b/test/Aevatar.GAgents.Platform.Telegram.Tests/Aevatar.GAgents.Platform.Telegram.Tests.csproj similarity index 73% rename from test/Aevatar.GAgents.Channel.Telegram.Tests/Aevatar.GAgents.Channel.Telegram.Tests.csproj rename to test/Aevatar.GAgents.Platform.Telegram.Tests/Aevatar.GAgents.Platform.Telegram.Tests.csproj index b84de5a31..4b49b5745 100644 --- a/test/Aevatar.GAgents.Channel.Telegram.Tests/Aevatar.GAgents.Channel.Telegram.Tests.csproj +++ b/test/Aevatar.GAgents.Platform.Telegram.Tests/Aevatar.GAgents.Platform.Telegram.Tests.csproj @@ -5,11 +5,18 @@ enable false true - Aevatar.GAgents.Channel.Telegram.Tests - Aevatar.GAgents.Channel.Telegram.Tests + Aevatar.GAgents.Platform.Telegram.Tests + Aevatar.GAgents.Platform.Telegram.Tests + + + + + + + @@ -18,10 +25,4 @@ - - - - - - diff --git a/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramChannelNativeMessageProducerTests.cs b/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramChannelNativeMessageProducerTests.cs new file mode 100644 index 000000000..0c42f0561 --- /dev/null +++ b/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramChannelNativeMessageProducerTests.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Platform.Telegram; +using Shouldly; + +namespace Aevatar.GAgents.Platform.Telegram.Tests; + +public sealed class TelegramChannelNativeMessageProducerTests +{ + [Fact] + public void Produce_for_text_only_intent_returns_text_native() + { + var producer = new TelegramChannelNativeMessageProducer(new TelegramMessageComposer()); + var native = producer.Produce( + new MessageContent { Text = "hello" }, + BuildContext()); + + native.Text.ShouldBe("hello"); + native.CardPayload.ShouldBeNull(); + native.MessageType.ShouldBe("text"); + native.IsInteractive.ShouldBeFalse(); + } + + [Fact] + public void Produce_for_button_intent_returns_interactive_card_payload() + { + var producer = new TelegramChannelNativeMessageProducer(new TelegramMessageComposer()); + var intent = new MessageContent { Text = "Choose" }; + intent.Actions.Add(new ActionElement + { + Kind = ActionElementKind.Button, + ActionId = "confirm", + Label = "Confirm", + Value = "yes", + }); + + var native = producer.Produce(intent, BuildContext()); + + native.IsInteractive.ShouldBeTrue(); + native.CardPayload.ShouldNotBeNull(); + native.MessageType.ShouldBe("interactive"); + native.CardPayload.ShouldBeOfType(); + var cardJson = JsonSerializer.Serialize(native.CardPayload); + cardJson.ShouldContain("inline_keyboard"); + cardJson.ShouldContain("Confirm"); + } + + [Fact] + public void Channel_is_telegram() + { + var producer = new TelegramChannelNativeMessageProducer(new TelegramMessageComposer()); + producer.Channel.Value.ShouldBe("telegram"); + } + + private static ComposeContext BuildContext() => new() + { + Conversation = ConversationReference.Create( + ChannelId.From("telegram"), + BotInstanceId.From("bot"), + ConversationScope.DirectMessage, + partition: null, + "user-1"), + Capabilities = TelegramMessageComposer.DefaultCapabilities.Clone(), + }; +} diff --git a/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramMessageComposerTests.cs b/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramMessageComposerTests.cs new file mode 100644 index 000000000..080adbca1 --- /dev/null +++ b/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramMessageComposerTests.cs @@ -0,0 +1,146 @@ +using System.Text.Json; +using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.Testing; +using Aevatar.GAgents.Platform.Telegram; +using Shouldly; + +namespace Aevatar.GAgents.Platform.Telegram.Tests; + +public sealed class TelegramMessageComposerTests : MessageComposerUnitTests +{ + protected override TelegramMessageComposer CreateComposer() => new(); + + protected override ChannelCapabilities CreateCapabilities() => TelegramMessageComposer.DefaultCapabilities.Clone(); + + protected override void AssertSimpleTextPayload(object payload, MessageContent intent, ComposeContext context) + { + var native = payload.ShouldBeOfType(); + native.MessageType.ShouldBe("text"); + native.IsInteractive.ShouldBeFalse(); + using var document = JsonDocument.Parse(native.ContentJson); + document.RootElement.GetProperty("text").GetString().ShouldBe(intent.Text); + } + + protected override void AssertActionsPayload(object payload, MessageContent intent, ComposeContext context, ComposeCapability capability) + { + var native = payload.ShouldBeOfType(); + if (capability == ComposeCapability.Degraded && !context.Capabilities!.SupportsActionButtons) + { + native.MessageType.ShouldBe("text"); + native.IsInteractive.ShouldBeFalse(); + return; + } + + native.MessageType.ShouldBe("interactive"); + native.IsInteractive.ShouldBeTrue(); + native.ContentJson.ShouldContain("Confirm"); + native.ContentJson.ShouldContain("Cancel"); + native.ContentJson.ShouldContain("inline_keyboard"); + } + + protected override void AssertCardPayload(object payload, MessageContent intent, ComposeContext context, ComposeCapability capability) + { + var native = payload.ShouldBeOfType(); + // Telegram has no native card UI; cards degrade into the rendered text body. + native.PlainText.ShouldContain("Hero"); + native.PlainText.ShouldContain("Hero body"); + } + + protected override void AssertOverflowTruncation(object payload, int maxLength) + { + var native = payload.ShouldBeOfType(); + native.PlainText.Length.ShouldBeLessThanOrEqualTo(maxLength); + } + + [Fact] + public void Compose_text_only_intent_emits_text_message_type() + { + var payload = CreateComposer().Compose( + new MessageContent { Text = "hello" }, + BuildContext()); + + payload.MessageType.ShouldBe("text"); + payload.IsInteractive.ShouldBeFalse(); + using var document = JsonDocument.Parse(payload.ContentJson); + document.RootElement.GetProperty("text").GetString().ShouldBe("hello"); + } + + [Fact] + public void Compose_with_button_intent_emits_inline_keyboard_payload() + { + var intent = new MessageContent + { + Text = "Choose", + }; + intent.Actions.Add(new ActionElement + { + Kind = ActionElementKind.Button, + ActionId = "confirm", + Label = "Confirm", + Value = "yes", + }); + + var payload = CreateComposer().Compose(intent, BuildContext()); + + payload.MessageType.ShouldBe("interactive"); + payload.IsInteractive.ShouldBeTrue(); + using var document = JsonDocument.Parse(payload.ContentJson); + var rows = document.RootElement.GetProperty("reply_markup").GetProperty("inline_keyboard"); + rows.GetArrayLength().ShouldBe(1); + var firstButton = rows[0][0]; + firstButton.GetProperty("text").GetString().ShouldBe("Confirm"); + firstButton.GetProperty("callback_data").GetString().ShouldBe("yes"); + } + + [Fact] + public void Compose_with_button_callback_data_truncates_to_telegram_limit() + { + var intent = new MessageContent(); + intent.Actions.Add(new ActionElement + { + Kind = ActionElementKind.Button, + ActionId = "long-action", + Label = "Long", + Value = new string('x', 200), + }); + + var payload = CreateComposer().Compose(intent, BuildContext()); + using var document = JsonDocument.Parse(payload.ContentJson); + var data = document.RootElement + .GetProperty("reply_markup") + .GetProperty("inline_keyboard")[0][0] + .GetProperty("callback_data") + .GetString(); + + data.ShouldNotBeNull(); + System.Text.Encoding.UTF8.GetByteCount(data!).ShouldBeLessThanOrEqualTo(64); + } + + [Fact] + public void Evaluate_attachments_without_files_capability_returns_unsupported() + { + var intent = new MessageContent + { + Text = "with file", + }; + intent.Attachments.Add(new AttachmentRef + { + ContentType = "image/png", + ExternalUrl = "https://example.com/cat.png", + }); + + var capability = CreateComposer().Evaluate(intent, BuildContext()); + capability.ShouldBe(ComposeCapability.Unsupported); + } + + private static ComposeContext BuildContext() => new() + { + Conversation = ConversationReference.Create( + ChannelId.From("telegram"), + BotInstanceId.From("bot"), + ConversationScope.DirectMessage, + partition: null, + "user-1"), + Capabilities = TelegramMessageComposer.DefaultCapabilities.Clone(), + }; +} From f7eb23d59333b2a11ce8e0483dbd6d0fb0e9a417 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 27 Apr 2026 11:56:38 +0800 Subject: [PATCH 05/15] Add Telegram bot provisioning + relay platform routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add NyxTelegramProvisioningService implementing the Lark-style provisioning flow for Telegram: create Nyx relay api-key, register the bot with Nyx via platform="telegram"+bot_token, create a default conversation route, and register the local mirror through ChannelBotRegistrationStoreCommands. Mirror NyxLarkProvisioningService's INyxChannelBotProvisioningService surface so the registration endpoint discovers Telegram by enumeration. Generalize NyxChannelBotProvisioningRequest with an optional Credentials map for platform-extensible secrets — Telegram reads "bot_token" from the map. The Lark typed sub-message stays in place so the existing Lark provisioning path is unchanged. Wire the registration endpoint to accept the Telegram-shaped fields (top-level bot_token shorthand or generic credentials map), platform- default the response nyx_provider_slug ("api-telegram-bot" for Telegram), and register both the Telegram composer/native producer/redactor and the new provisioning service in ChannelRuntime DI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Aevatar.GAgents.ChannelRuntime.csproj | 1 + .../ChannelCallbackEndpoints.cs | 42 +- .../NyxLarkProvisioningService.cs | 3 +- .../NyxTelegramProvisioningService.cs | 418 ++++++++++++++++++ .../ServiceCollectionExtensions.cs | 9 + .../NyxTelegramProvisioningServiceTests.cs | 235 ++++++++++ .../ServiceCollectionExtensionsTests.cs | 3 +- 7 files changed, 707 insertions(+), 4 deletions(-) create mode 100644 agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs create mode 100644 test/Aevatar.GAgents.ChannelRuntime.Tests/NyxTelegramProvisioningServiceTests.cs diff --git a/agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj b/agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj index 104bec970..7c0b51cba 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj +++ b/agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj @@ -28,6 +28,7 @@ + diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs b/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs index 069e7ed3e..fb8f60172 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs @@ -132,7 +132,8 @@ private static async Task HandleRegisterAsync( Lark: new NyxChannelLarkCredentials( AppId: request.AppId?.Trim() ?? string.Empty, AppSecret: request.AppSecret?.Trim() ?? string.Empty, - VerificationToken: request.VerificationToken?.Trim() ?? string.Empty)), + VerificationToken: request.VerificationToken?.Trim() ?? string.Empty), + Credentials: BuildCredentialsMap(platformNormalized, request)), ct); var payload = new @@ -141,7 +142,7 @@ private static async Task HandleRegisterAsync( registration_id = result.RegistrationId ?? string.Empty, platform = result.Platform, nyx_provider_slug = string.IsNullOrWhiteSpace(request.NyxProviderSlug) - ? "api-lark-bot" + ? ResolveDefaultProviderSlug(platformNormalized) : request.NyxProviderSlug.Trim(), nyx_channel_bot_id = result.NyxChannelBotId ?? string.Empty, nyx_agent_api_key_id = result.NyxAgentApiKeyId ?? string.Empty, @@ -440,8 +441,45 @@ private sealed record RegistrationRequest( string? NyxProviderSlug, string? ScopeId, string? WebhookBaseUrl, + // Lark-specific (legacy explicit fields kept for backward compatibility; Telegram and + // future platforms use the Credentials map below). string? AppId, string? AppSecret, string? VerificationToken, + // Telegram-specific shorthand: equivalent to Credentials["bot_token"]. + string? BotToken, + // Platform-extensible credential bag. Per-platform provisioning services document + // which keys they expect (e.g. Telegram reads "bot_token"). + IReadOnlyDictionary? Credentials, string? Label); + + private static IReadOnlyDictionary? BuildCredentialsMap( + string platform, + RegistrationRequest request) + { + var bag = new Dictionary(StringComparer.Ordinal); + if (request.Credentials is { Count: > 0 } incoming) + { + foreach (var (key, value) in incoming) + { + if (!string.IsNullOrWhiteSpace(value)) + bag[key] = value.Trim(); + } + } + + if (string.Equals(platform, "telegram", StringComparison.OrdinalIgnoreCase) && + !bag.ContainsKey("bot_token") && + !string.IsNullOrWhiteSpace(request.BotToken)) + { + bag["bot_token"] = request.BotToken!.Trim(); + } + + return bag.Count == 0 ? null : bag; + } + + private static string ResolveDefaultProviderSlug(string platform) => platform switch + { + "telegram" => "api-telegram-bot", + _ => "api-lark-bot", + }; } diff --git a/agents/Aevatar.GAgents.ChannelRuntime/NyxLarkProvisioningService.cs b/agents/Aevatar.GAgents.ChannelRuntime/NyxLarkProvisioningService.cs index 0a4a68f66..f3b0029c0 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/NyxLarkProvisioningService.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/NyxLarkProvisioningService.cs @@ -60,7 +60,8 @@ public sealed record NyxChannelBotProvisioningRequest( string ScopeId, string Label, string NyxProviderSlug, - NyxChannelLarkCredentials? Lark = null); + NyxChannelLarkCredentials? Lark = null, + IReadOnlyDictionary? Credentials = null); public sealed record NyxChannelBotProvisioningResult( bool Succeeded, diff --git a/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs b/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs new file mode 100644 index 000000000..4c0dd4ddc --- /dev/null +++ b/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs @@ -0,0 +1,418 @@ +using System.Text.Json; +using Aevatar.AI.ToolProviders.NyxId; +using Aevatar.Foundation.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.ChannelRuntime; + +public sealed record NyxTelegramProvisioningRequest( + string AccessToken, + string BotToken, + string WebhookBaseUrl, + string ScopeId, + string Label, + string NyxProviderSlug); + +public sealed record NyxTelegramProvisioningResult( + bool Succeeded, + string Status, + string? RegistrationId = null, + string? NyxChannelBotId = null, + string? NyxAgentApiKeyId = null, + string? NyxConversationRouteId = null, + string? RelayCallbackUrl = null, + string? WebhookUrl = null, + string? Error = null, + string? Note = null); + +public interface INyxTelegramProvisioningService +{ + string Platform { get; } + + Task ProvisionAsync(NyxTelegramProvisioningRequest request, CancellationToken ct); +} + +public sealed class NyxTelegramProvisioningService : INyxTelegramProvisioningService, INyxChannelBotProvisioningService +{ + private const string DefaultNyxProviderSlug = "api-telegram-bot"; + private const string NyxRelayApiKeyPlatform = "generic"; + public const string PlatformId = "telegram"; + + private readonly NyxIdApiClient _nyxClient; + private readonly NyxIdToolOptions _nyxOptions; + private readonly IActorRuntime _actorRuntime; + private readonly IActorDispatchPort _dispatchPort; + private readonly ILogger _logger; + + private sealed record RelayApiKeyCredentials(string Id); + + public NyxTelegramProvisioningService( + NyxIdApiClient nyxClient, + NyxIdToolOptions nyxOptions, + IActorRuntime actorRuntime, + IActorDispatchPort dispatchPort, + ILogger logger) + { + _nyxClient = nyxClient ?? throw new ArgumentNullException(nameof(nyxClient)); + _nyxOptions = nyxOptions ?? throw new ArgumentNullException(nameof(nyxOptions)); + _actorRuntime = actorRuntime ?? throw new ArgumentNullException(nameof(actorRuntime)); + _dispatchPort = dispatchPort ?? throw new ArgumentNullException(nameof(dispatchPort)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public string Platform => PlatformId; + + public async Task ProvisionAsync(NyxTelegramProvisioningRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + if (string.IsNullOrWhiteSpace(request.AccessToken)) + return Failure("missing_access_token"); + if (string.IsNullOrWhiteSpace(request.BotToken)) + return Failure("missing_bot_token"); + if (string.IsNullOrWhiteSpace(request.WebhookBaseUrl)) + return Failure("missing_webhook_base_url"); + if (string.IsNullOrWhiteSpace(request.ScopeId)) + return Failure("missing_scope_id"); + if (string.IsNullOrWhiteSpace(_nyxOptions.BaseUrl)) + return Failure("nyx_base_url_not_configured"); + + var registrationId = Guid.NewGuid().ToString("N"); + var nyxBaseUrl = _nyxOptions.BaseUrl.TrimEnd('/'); + var relayCallbackUrl = $"{request.WebhookBaseUrl.Trim().TrimEnd('/')}/api/webhooks/nyxid-relay"; + var label = string.IsNullOrWhiteSpace(request.Label) + ? $"Aevatar Telegram Bot {registrationId[..8]}" + : request.Label.Trim(); + var nyxProviderSlug = string.IsNullOrWhiteSpace(request.NyxProviderSlug) + ? DefaultNyxProviderSlug + : request.NyxProviderSlug.Trim(); + + string? apiKeyId = null; + string? channelBotId = null; + string? routeId = null; + var localMirrorAccepted = false; + + try + { + var relayApiKey = await CreateRelayApiKeyAsync(request.AccessToken, relayCallbackUrl, registrationId, ct); + apiKeyId = relayApiKey.Id; + + channelBotId = await RegisterChannelBotAsync( + request.AccessToken, + request.BotToken, + label, + ct); + routeId = await CreateDefaultRouteAsync(request.AccessToken, channelBotId, apiKeyId, ct); + + var webhookUrl = $"{nyxBaseUrl}/api/v1/webhooks/channel/telegram/{Uri.EscapeDataString(channelBotId)}"; + await RegisterLocalMirrorAsync( + registrationId, + nyxProviderSlug, + webhookUrl, + request.ScopeId?.Trim() ?? string.Empty, + apiKeyId, + channelBotId, + routeId, + ct); + localMirrorAccepted = true; + + return new NyxTelegramProvisioningResult( + Succeeded: true, + Status: "accepted", + RegistrationId: registrationId, + NyxChannelBotId: channelBotId, + NyxAgentApiKeyId: apiKeyId, + NyxConversationRouteId: routeId, + RelayCallbackUrl: relayCallbackUrl, + WebhookUrl: webhookUrl, + Note: "Provisioning completed in Nyx and the local mirror command was accepted. Configure the Telegram bot webhook URL via setWebhook to point at Nyx; local read model visibility is asynchronous."); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Nyx-backed Telegram provisioning failed: registration={RegistrationId}, botId={ChannelBotId}, apiKeyId={ApiKeyId}, routeId={RouteId}", + registrationId, + channelBotId, + apiKeyId, + routeId); + + if (!localMirrorAccepted && routeId is not null) + await TryRollbackAsync(() => _nyxClient.DeleteConversationRouteAsync(request.AccessToken, routeId, ct), "channel_route", routeId); + if (!localMirrorAccepted && channelBotId is not null) + await TryRollbackAsync(() => _nyxClient.DeleteChannelBotAsync(request.AccessToken, channelBotId, ct), "channel_bot", channelBotId); + if (!localMirrorAccepted && apiKeyId is not null) + await TryRollbackAsync(() => _nyxClient.DeleteApiKeyAsync(request.AccessToken, apiKeyId, ct), "api_key", apiKeyId); + + return Failure(localMirrorAccepted + ? "local_mirror_accepted_remote_cleanup_skipped" + : ex.Message); + } + } + + async Task INyxChannelBotProvisioningService.ProvisionAsync( + NyxChannelBotProvisioningRequest request, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + if (!string.Equals(request.Platform, PlatformId, StringComparison.OrdinalIgnoreCase)) + return ToGenericResult(Failure("unsupported_platform")); + + // The credentials map is the canonical platform-extensible carrier; absent map is a 400. + var botToken = ResolveBotToken(request); + if (string.IsNullOrWhiteSpace(botToken)) + return ToGenericResult(Failure("missing_bot_token")); + + var result = await ProvisionAsync( + new NyxTelegramProvisioningRequest( + AccessToken: request.AccessToken, + BotToken: botToken, + WebhookBaseUrl: request.WebhookBaseUrl, + ScopeId: request.ScopeId, + Label: request.Label, + NyxProviderSlug: request.NyxProviderSlug), + ct); + + return ToGenericResult(result); + } + + private static string ResolveBotToken(NyxChannelBotProvisioningRequest request) + { + if (request.Credentials is { } credentials && + credentials.TryGetValue("bot_token", out var fromMap) && + !string.IsNullOrWhiteSpace(fromMap)) + { + return fromMap.Trim(); + } + + return string.Empty; + } + + private async Task CreateRelayApiKeyAsync( + string accessToken, + string relayCallbackUrl, + string registrationId, + CancellationToken ct) + { + var response = await _nyxClient.CreateApiKeyAsync( + accessToken, + JsonSerializer.Serialize(new + { + name = $"aevatar-telegram-relay-{registrationId[..12]}", + scopes = "read write", + platform = NyxRelayApiKeyPlatform, + callback_url = relayCallbackUrl, + }), + ct); + + return ExtractRequiredRelayApiKeyCredentials(response); + } + + private async Task RegisterChannelBotAsync( + string accessToken, + string botToken, + string label, + CancellationToken ct) + { + var payload = new Dictionary + { + ["platform"] = PlatformId, + ["bot_token"] = botToken.Trim(), + ["label"] = label, + }; + + var response = await _nyxClient.RegisterChannelBotAsync( + accessToken, + JsonSerializer.Serialize(payload), + ct); + + return ExtractRequiredId(response, "channel_bot_id"); + } + + private async Task CreateDefaultRouteAsync( + string accessToken, + string channelBotId, + string apiKeyId, + CancellationToken ct) + { + var response = await _nyxClient.CreateConversationRouteAsync( + accessToken, + JsonSerializer.Serialize(new + { + channel_bot_id = channelBotId, + agent_api_key_id = apiKeyId, + default_agent = true, + }), + ct); + + return ExtractRequiredId(response, "channel_route_id"); + } + + private async Task RegisterLocalMirrorAsync( + string registrationId, + string nyxProviderSlug, + string webhookUrl, + string scopeId, + string apiKeyId, + string channelBotId, + string routeId, + CancellationToken ct) + { + var cmd = new ChannelBotRegisterCommand + { + RequestedId = registrationId, + Platform = PlatformId, + NyxProviderSlug = nyxProviderSlug, + ScopeId = scopeId, + WebhookUrl = webhookUrl, + NyxAgentApiKeyId = apiKeyId, + NyxChannelBotId = channelBotId, + NyxConversationRouteId = routeId, + }; + + await ChannelBotRegistrationStoreCommands.DispatchRegisterAsync( + _actorRuntime, + _dispatchPort, + cmd, + ct); + } + + private async Task TryRollbackAsync(Func> rollback, string resourceType, string resourceId) + { + try + { + var response = await rollback(); + if (LooksLikeErrorEnvelope(response)) + { + _logger.LogWarning( + "Nyx rollback returned an error envelope: type={ResourceType}, id={ResourceId}, response={Response}", + resourceType, + resourceId, + response); + } + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Nyx rollback failed: type={ResourceType}, id={ResourceId}", + resourceType, + resourceId); + } + } + + private static string ExtractRequiredId(string response, string resourceName) + { + if (LooksLikeErrorEnvelope(response)) + throw new InvalidOperationException($"{resourceName}_request_failed {ExtractErrorDetail(response)}"); + + try + { + using var document = JsonDocument.Parse(response); + var root = document.RootElement; + if (!root.TryGetProperty("id", out var idElement) || idElement.ValueKind != JsonValueKind.String) + throw new InvalidOperationException($"missing_id_in_{resourceName}_response"); + + var id = idElement.GetString()?.Trim(); + if (string.IsNullOrWhiteSpace(id)) + throw new InvalidOperationException($"empty_id_in_{resourceName}_response"); + + return id; + } + catch (JsonException ex) + { + throw new InvalidOperationException($"invalid_json_in_{resourceName}_response", ex); + } + } + + private static RelayApiKeyCredentials ExtractRequiredRelayApiKeyCredentials(string response) + { + if (LooksLikeErrorEnvelope(response)) + throw new InvalidOperationException($"api_key_id_request_failed {ExtractErrorDetail(response)}"); + + try + { + using var document = JsonDocument.Parse(response); + var root = document.RootElement; + if (!root.TryGetProperty("id", out var idElement) || idElement.ValueKind != JsonValueKind.String) + throw new InvalidOperationException("missing_id_in_api_key_id_response"); + + var id = idElement.GetString()?.Trim(); + if (string.IsNullOrWhiteSpace(id)) + throw new InvalidOperationException("empty_id_in_api_key_id_response"); + + return new RelayApiKeyCredentials(id); + } + catch (JsonException ex) + { + throw new InvalidOperationException($"invalid_json_in_api_key_id_response {ex.Message}", ex); + } + } + + private static bool LooksLikeErrorEnvelope(string response) + { + if (string.IsNullOrWhiteSpace(response)) + return true; + + try + { + using var document = JsonDocument.Parse(response); + return document.RootElement.TryGetProperty("error", out var errorProp) && + errorProp.ValueKind == JsonValueKind.True; + } + catch (JsonException) + { + return true; + } + } + + private static string ExtractErrorDetail(string response) + { + if (string.IsNullOrWhiteSpace(response)) + return "empty_response"; + + try + { + using var document = JsonDocument.Parse(response); + var root = document.RootElement; + var status = root.TryGetProperty("status", out var statusElement) && statusElement.ValueKind == JsonValueKind.Number + ? statusElement.GetInt32().ToString() + : "unknown"; + var body = root.TryGetProperty("body", out var bodyElement) && bodyElement.ValueKind == JsonValueKind.String + ? bodyElement.GetString() + : string.Empty; + var message = root.TryGetProperty("message", out var messageElement) && messageElement.ValueKind == JsonValueKind.String + ? messageElement.GetString() + : string.Empty; + + return $"nyx_status={status}" + + (string.IsNullOrWhiteSpace(body) ? string.Empty : $" body={body}") + + (string.IsNullOrWhiteSpace(message) ? string.Empty : $" message={message}"); + } + catch (JsonException) + { + return "invalid_error_envelope"; + } + } + + private static NyxTelegramProvisioningResult Failure(string error) => + new( + Succeeded: false, + Status: "error", + Error: string.IsNullOrWhiteSpace(error) ? "unknown_error" : error.Trim()); + + private static NyxChannelBotProvisioningResult ToGenericResult(NyxTelegramProvisioningResult result) => + new( + Succeeded: result.Succeeded, + Status: result.Status, + Platform: PlatformId, + RegistrationId: result.RegistrationId, + NyxChannelBotId: result.NyxChannelBotId, + NyxAgentApiKeyId: result.NyxAgentApiKeyId, + NyxConversationRouteId: result.NyxConversationRouteId, + RelayCallbackUrl: result.RelayCallbackUrl, + WebhookUrl: result.WebhookUrl, + Error: result.Error, + Note: result.Note); +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs index c7f2394bb..6899b4455 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs @@ -110,6 +110,8 @@ public static IServiceCollection AddChannelRuntime( services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.AddHostedService(); if (useElasticsearch) @@ -200,6 +202,13 @@ public static IServiceCollection AddChannelRuntime( services.TryAddEnumerable(ServiceDescriptor.Singleton( sp => sp.GetRequiredService())); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton( + sp => sp.GetRequiredService())); + services.TryAddEnumerable(ServiceDescriptor.Singleton( + sp => sp.GetRequiredService())); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(sp => { diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxTelegramProvisioningServiceTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxTelegramProvisioningServiceTests.cs new file mode 100644 index 000000000..6dddd5328 --- /dev/null +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxTelegramProvisioningServiceTests.cs @@ -0,0 +1,235 @@ +using System.Net; +using System.Text; +using Aevatar.AI.ToolProviders.NyxId; +using Aevatar.Foundation.Abstractions; +using FluentAssertions; +using NSubstitute; +using Xunit; + +namespace Aevatar.GAgents.ChannelRuntime.Tests; + +public class NyxTelegramProvisioningServiceTests +{ + [Fact] + public async Task ProvisionAsync_creates_nyx_resources_and_dispatches_local_mirror() + { + var handler = new RecordingHandler(); + handler.Enqueue("/api/v1/api-keys", """{"id":"key-tg-1","full_key":"full-key"}"""); + handler.Enqueue("/api/v1/channel-bots", """{"id":"bot-tg-1","status":"pending_webhook"}"""); + handler.Enqueue("/api/v1/channel-conversations", """{"id":"route-tg-1","default_agent":true}"""); + + var nyxClient = new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, + new HttpClient(handler)); + + EventEnvelope? capturedEnvelope = null; + var actor = Substitute.For(); + var actorRuntime = Substitute.For(); + actorRuntime.GetAsync(ChannelBotRegistrationGAgent.WellKnownId) + .Returns(Task.FromResult(actor)); + ((IActorDispatchPort)actorRuntime).DispatchAsync( + ChannelBotRegistrationGAgent.WellKnownId, + Arg.Do(envelope => capturedEnvelope = envelope), + Arg.Any()) + .Returns(Task.CompletedTask); + + var service = new NyxTelegramProvisioningService( + nyxClient, + new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, + actorRuntime, + (IActorDispatchPort)actorRuntime, + Substitute.For>()); + + var result = await service.ProvisionAsync( + new NyxTelegramProvisioningRequest( + AccessToken: "user-token", + BotToken: "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef-hi", + WebhookBaseUrl: "https://aevatar.example.com", + ScopeId: "scope-1", + Label: "Ops Bot", + NyxProviderSlug: "api-telegram-bot"), + CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + result.Status.Should().Be("accepted"); + result.RegistrationId.Should().NotBeNullOrWhiteSpace(); + result.NyxAgentApiKeyId.Should().Be("key-tg-1"); + result.NyxChannelBotId.Should().Be("bot-tg-1"); + result.NyxConversationRouteId.Should().Be("route-tg-1"); + result.RelayCallbackUrl.Should().Be("https://aevatar.example.com/api/webhooks/nyxid-relay"); + result.WebhookUrl.Should().Be("https://nyx.example.com/api/v1/webhooks/channel/telegram/bot-tg-1"); + + capturedEnvelope.Should().NotBeNull(); + capturedEnvelope!.Payload.Is(ChannelBotRegisterCommand.Descriptor).Should().BeTrue(); + var command = capturedEnvelope.Payload.Unpack(); + command.Platform.Should().Be("telegram"); + command.NyxProviderSlug.Should().Be("api-telegram-bot"); + command.NyxAgentApiKeyId.Should().Be("key-tg-1"); + command.NyxChannelBotId.Should().Be("bot-tg-1"); + command.NyxConversationRouteId.Should().Be("route-tg-1"); + command.WebhookUrl.Should().Be("https://nyx.example.com/api/v1/webhooks/channel/telegram/bot-tg-1"); + + handler.Requests.Should().HaveCount(3); + handler.Requests[0].Body.Should().Contain("\"callback_url\":\"https://aevatar.example.com/api/webhooks/nyxid-relay\""); + handler.Requests[0].Body.Should().Contain("\"platform\":\"generic\""); + handler.Requests[1].Body.Should().Contain("\"platform\":\"telegram\""); + handler.Requests[1].Body.Should().Contain("\"bot_token\":\"1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef-hi\""); + handler.Requests[1].Body.Should().NotContain("__unused_for_lark__"); + handler.Requests[2].Body.Should().Contain("\"default_agent\":true"); + } + + [Theory] + [InlineData("", "bot-token", "https://aevatar.example.com", "scope-1", "missing_access_token")] + [InlineData("user-token", "", "https://aevatar.example.com", "scope-1", "missing_bot_token")] + [InlineData("user-token", "bot-token", "", "scope-1", "missing_webhook_base_url")] + [InlineData("user-token", "bot-token", "https://aevatar.example.com", "", "missing_scope_id")] + public async Task ProvisionAsync_rejects_invalid_requests_before_calling_nyx( + string accessToken, + string botToken, + string webhookBaseUrl, + string scopeId, + string expectedError) + { + var handler = new RecordingHandler(); + var service = CreateService(handler); + + var result = await service.ProvisionAsync( + new NyxTelegramProvisioningRequest( + AccessToken: accessToken, + BotToken: botToken, + WebhookBaseUrl: webhookBaseUrl, + ScopeId: scopeId, + Label: "Ops Bot", + NyxProviderSlug: "api-telegram-bot"), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.Error.Should().Be(expectedError); + handler.Requests.Should().BeEmpty(); + } + + [Fact] + public async Task INyxChannelBotProvisioningService_reads_bot_token_from_credentials_map() + { + var handler = new RecordingHandler(); + handler.Enqueue("/api/v1/api-keys", """{"id":"key-tg-2"}"""); + handler.Enqueue("/api/v1/channel-bots", """{"id":"bot-tg-2"}"""); + handler.Enqueue("/api/v1/channel-conversations", """{"id":"route-tg-2"}"""); + var service = CreateService(handler); + + var generic = (INyxChannelBotProvisioningService)service; + var result = await generic.ProvisionAsync( + new NyxChannelBotProvisioningRequest( + Platform: "telegram", + AccessToken: "user-token", + WebhookBaseUrl: "https://aevatar.example.com", + ScopeId: "scope-1", + Label: "Ops Bot", + NyxProviderSlug: "api-telegram-bot", + Credentials: new Dictionary(StringComparer.Ordinal) + { + ["bot_token"] = "tok-from-map", + }), + CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + result.Platform.Should().Be("telegram"); + handler.Requests[1].Body.Should().Contain("\"bot_token\":\"tok-from-map\""); + } + + [Fact] + public async Task INyxChannelBotProvisioningService_returns_missing_bot_token_when_credentials_absent() + { + var handler = new RecordingHandler(); + var service = CreateService(handler); + var generic = (INyxChannelBotProvisioningService)service; + + var result = await generic.ProvisionAsync( + new NyxChannelBotProvisioningRequest( + Platform: "telegram", + AccessToken: "user-token", + WebhookBaseUrl: "https://aevatar.example.com", + ScopeId: "scope-1", + Label: "Ops Bot", + NyxProviderSlug: "api-telegram-bot"), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.Error.Should().Be("missing_bot_token"); + handler.Requests.Should().BeEmpty(); + } + + [Fact] + public async Task INyxChannelBotProvisioningService_rejects_non_telegram_platform() + { + var handler = new RecordingHandler(); + var service = CreateService(handler); + var generic = (INyxChannelBotProvisioningService)service; + + var result = await generic.ProvisionAsync( + new NyxChannelBotProvisioningRequest( + Platform: "lark", + AccessToken: "user-token", + WebhookBaseUrl: "https://aevatar.example.com", + ScopeId: "scope-1", + Label: "Ops Bot", + NyxProviderSlug: "api-telegram-bot", + Credentials: new Dictionary(StringComparer.Ordinal) + { + ["bot_token"] = "tok", + }), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.Error.Should().Be("unsupported_platform"); + handler.Requests.Should().BeEmpty(); + } + + private static NyxTelegramProvisioningService CreateService(RecordingHandler handler) + { + var nyxClient = new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, + new HttpClient(handler)); + + var actorRuntime = Substitute.For(); + actorRuntime.GetAsync(ChannelBotRegistrationGAgent.WellKnownId) + .Returns(Task.FromResult(Substitute.For())); + return new NyxTelegramProvisioningService( + nyxClient, + new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, + actorRuntime, + (IActorDispatchPort)actorRuntime, + Substitute.For>()); + } + + private sealed class RecordingHandler : HttpMessageHandler + { + private readonly Queue<(HttpMethod? Method, string Path, string Body)> _responses = new(); + + public List<(HttpMethod Method, string Path, string Body)> Requests { get; } = []; + + public void Enqueue(string path, string body) => _responses.Enqueue((null, path, body)); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (_responses.Count == 0) + throw new InvalidOperationException("No more queued responses."); + + var (expectedMethod, expectedPath, responseBody) = _responses.Dequeue(); + request.RequestUri.Should().NotBeNull(); + request.RequestUri!.AbsolutePath.Should().Be(expectedPath); + if (expectedMethod is not null) + request.Method.Should().Be(expectedMethod); + + var body = request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken); + Requests.Add((request.Method, expectedPath, body)); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseBody, Encoding.UTF8, "application/json"), + }; + } + } +} diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ServiceCollectionExtensionsTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ServiceCollectionExtensionsTests.cs index 5fe4fbb8e..7b56c955d 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ServiceCollectionExtensionsTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ServiceCollectionExtensionsTests.cs @@ -45,7 +45,8 @@ public void AddChannelRuntime_RegistersRegistrationProjectionServices_ForInMemor services.Count(descriptor => descriptor.ServiceType == typeof(IPlatformAdapter)) .Should().Be(0); services.Count(descriptor => descriptor.ServiceType == typeof(INyxChannelBotProvisioningService)) - .Should().Be(1); + .Should().Be(2); + registry.Get(ChannelId.From("telegram")).Should().BeOfType(); } [Fact] From cf9dc2198797a0a21da129d373283676744bb50d Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 27 Apr 2026 12:00:55 +0800 Subject: [PATCH 06/15] Add chat-only Telegram agent tool provider Mirror Aevatar.AI.ToolProviders.Lark for Telegram with the chat-only subset the user asked for: telegram_messages_send (Bot API sendMessage) and telegram_chats_lookup (Bot API getChat). Both go through the same NyxIdApiClient.ProxyRequestAsync surface using the api-telegram-bot provider slug, so credential brokering remains in NyxID. Replies inside the current inbound turn keep flowing through NyxIdRelayOutboundPort; these tools cover the proactive-send use case agents need for notifications and side effects. Co-Authored-By: Claude Opus 4.7 (1M context) --- aevatar.slnx | 2 + .../Aevatar.AI.ToolProviders.Telegram.csproj | 17 ++ .../ITelegramNyxClient.cs | 18 ++ .../ServiceCollectionExtensions.cs | 25 +++ .../TelegramAgentToolSource.cs | 50 +++++ .../TelegramNyxClient.cs | 76 +++++++ .../TelegramProxyResponseParser.cs | 145 +++++++++++++ .../TelegramToolOptions.cs | 8 + .../Tools/TelegramChatsLookupTool.cs | 63 ++++++ .../Tools/TelegramMessagesSendTool.cs | 98 +++++++++ ...tar.AI.ToolProviders.Telegram.Tests.csproj | 21 ++ .../TelegramToolsTests.cs | 204 ++++++++++++++++++ 12 files changed, 727 insertions(+) create mode 100644 src/Aevatar.AI.ToolProviders.Telegram/Aevatar.AI.ToolProviders.Telegram.csproj create mode 100644 src/Aevatar.AI.ToolProviders.Telegram/ITelegramNyxClient.cs create mode 100644 src/Aevatar.AI.ToolProviders.Telegram/ServiceCollectionExtensions.cs create mode 100644 src/Aevatar.AI.ToolProviders.Telegram/TelegramAgentToolSource.cs create mode 100644 src/Aevatar.AI.ToolProviders.Telegram/TelegramNyxClient.cs create mode 100644 src/Aevatar.AI.ToolProviders.Telegram/TelegramProxyResponseParser.cs create mode 100644 src/Aevatar.AI.ToolProviders.Telegram/TelegramToolOptions.cs create mode 100644 src/Aevatar.AI.ToolProviders.Telegram/Tools/TelegramChatsLookupTool.cs create mode 100644 src/Aevatar.AI.ToolProviders.Telegram/Tools/TelegramMessagesSendTool.cs create mode 100644 test/Aevatar.AI.ToolProviders.Telegram.Tests/Aevatar.AI.ToolProviders.Telegram.Tests.csproj create mode 100644 test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramToolsTests.cs diff --git a/aevatar.slnx b/aevatar.slnx index 33ba37c62..53449f358 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -44,6 +44,7 @@ + @@ -150,6 +151,7 @@ + diff --git a/src/Aevatar.AI.ToolProviders.Telegram/Aevatar.AI.ToolProviders.Telegram.csproj b/src/Aevatar.AI.ToolProviders.Telegram/Aevatar.AI.ToolProviders.Telegram.csproj new file mode 100644 index 000000000..a5b9497cc --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.Telegram/Aevatar.AI.ToolProviders.Telegram.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + enable + Aevatar.AI.ToolProviders.Telegram + Aevatar.AI.ToolProviders.Telegram + + + + + + + + + + diff --git a/src/Aevatar.AI.ToolProviders.Telegram/ITelegramNyxClient.cs b/src/Aevatar.AI.ToolProviders.Telegram/ITelegramNyxClient.cs new file mode 100644 index 000000000..cacf02a7b --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.Telegram/ITelegramNyxClient.cs @@ -0,0 +1,18 @@ +namespace Aevatar.AI.ToolProviders.Telegram; + +public interface ITelegramNyxClient +{ + Task SendMessageAsync(string token, TelegramSendMessageRequest request, CancellationToken ct); + Task GetChatAsync(string token, TelegramGetChatRequest request, CancellationToken ct); +} + +public sealed record TelegramSendMessageRequest( + string ChatId, + string Text, + string? ParseMode = null, + bool? DisableNotification = null, + int? ReplyToMessageId = null, + string? ReplyMarkupJson = null); + +public sealed record TelegramGetChatRequest( + string ChatId); diff --git a/src/Aevatar.AI.ToolProviders.Telegram/ServiceCollectionExtensions.cs b/src/Aevatar.AI.ToolProviders.Telegram/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..96e940ebb --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.Telegram/ServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using Aevatar.AI.Abstractions.ToolProviders; +using Aevatar.AI.ToolProviders.NyxId; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.AI.ToolProviders.Telegram; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddTelegramTools( + this IServiceCollection services, + Action? configure = null) + { + var options = new TelegramToolOptions(); + configure?.Invoke(options); + + services.TryAddSingleton(options); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + return services; + } +} diff --git a/src/Aevatar.AI.ToolProviders.Telegram/TelegramAgentToolSource.cs b/src/Aevatar.AI.ToolProviders.Telegram/TelegramAgentToolSource.cs new file mode 100644 index 000000000..234315818 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.Telegram/TelegramAgentToolSource.cs @@ -0,0 +1,50 @@ +using Aevatar.AI.Abstractions.ToolProviders; +using Aevatar.AI.ToolProviders.NyxId; +using Aevatar.AI.ToolProviders.Telegram.Tools; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.AI.ToolProviders.Telegram; + +public sealed class TelegramAgentToolSource : IAgentToolSource +{ + private readonly TelegramToolOptions _options; + private readonly NyxIdToolOptions _nyxOptions; + private readonly ITelegramNyxClient _client; + private readonly ILogger _logger; + + public TelegramAgentToolSource( + TelegramToolOptions options, + NyxIdToolOptions nyxOptions, + ITelegramNyxClient client, + ILogger? logger = null) + { + _options = options; + _nyxOptions = nyxOptions; + _client = client; + _logger = logger ?? NullLogger.Instance; + } + + public Task> DiscoverToolsAsync(CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(_nyxOptions.BaseUrl)) + { + _logger.LogDebug("NyxID base URL not configured, skipping typed Telegram tools"); + return Task.FromResult>([]); + } + + if (string.IsNullOrWhiteSpace(_options.ProviderSlug)) + { + _logger.LogDebug("Telegram provider slug not configured, skipping typed Telegram tools"); + return Task.FromResult>([]); + } + + var tools = new List(); + if (_options.EnableMessageSend) + tools.Add(new TelegramMessagesSendTool(_client)); + if (_options.EnableChatLookup) + tools.Add(new TelegramChatsLookupTool(_client)); + + return Task.FromResult>(tools); + } +} diff --git a/src/Aevatar.AI.ToolProviders.Telegram/TelegramNyxClient.cs b/src/Aevatar.AI.ToolProviders.Telegram/TelegramNyxClient.cs new file mode 100644 index 000000000..534693c53 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.Telegram/TelegramNyxClient.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Aevatar.AI.ToolProviders.NyxId; + +namespace Aevatar.AI.ToolProviders.Telegram; + +public sealed class TelegramNyxClient : ITelegramNyxClient +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly TelegramToolOptions _options; + private readonly NyxIdApiClient _nyxClient; + + public TelegramNyxClient(TelegramToolOptions options, NyxIdApiClient nyxClient) + { + _options = options; + _nyxClient = nyxClient; + } + + public Task SendMessageAsync(string token, TelegramSendMessageRequest request, CancellationToken ct) + { + var body = new JsonObject + { + ["chat_id"] = request.ChatId, + ["text"] = request.Text, + }; + if (!string.IsNullOrWhiteSpace(request.ParseMode)) + body["parse_mode"] = request.ParseMode; + if (request.DisableNotification == true) + body["disable_notification"] = true; + if (request.ReplyToMessageId is { } replyTo) + body["reply_to_message_id"] = replyTo; + if (!string.IsNullOrWhiteSpace(request.ReplyMarkupJson)) + { + try + { + body["reply_markup"] = JsonNode.Parse(request.ReplyMarkupJson); + } + catch (JsonException) + { + // Caller is responsible for valid JSON; surface as Telegram-side error if invalid. + body["reply_markup"] = request.ReplyMarkupJson; + } + } + + return _nyxClient.ProxyRequestAsync( + token, + _options.ProviderSlug, + "sendMessage", + "POST", + body.ToJsonString(JsonOptions), + extraHeaders: null, + ct); + } + + public Task GetChatAsync(string token, TelegramGetChatRequest request, CancellationToken ct) + { + var body = JsonSerializer.Serialize(new + { + chat_id = request.ChatId, + }); + + return _nyxClient.ProxyRequestAsync( + token, + _options.ProviderSlug, + "getChat", + "POST", + body, + extraHeaders: null, + ct); + } +} diff --git a/src/Aevatar.AI.ToolProviders.Telegram/TelegramProxyResponseParser.cs b/src/Aevatar.AI.ToolProviders.Telegram/TelegramProxyResponseParser.cs new file mode 100644 index 000000000..72bb39ca9 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.Telegram/TelegramProxyResponseParser.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Aevatar.AI.ToolProviders.Telegram; + +internal static class TelegramProxyResponseParser +{ + private static readonly JsonSerializerOptions OutputOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public static string Serialize(object payload) => JsonSerializer.Serialize(payload, OutputOptions); + + /// + /// Returns true and populates when the response is either a NyxID + /// proxy error envelope or a Telegram Bot API ok:false response. Bot API responses use + /// {ok, result, error_code, description}; NyxID wraps non-2xx HTTP into + /// {error:true, status, body, message}. + /// + public static bool TryParseError(string? response, out string error) + { + error = string.Empty; + if (string.IsNullOrWhiteSpace(response)) + { + error = "empty_telegram_response"; + return true; + } + + try + { + using var document = JsonDocument.Parse(response); + var root = document.RootElement; + + if (root.TryGetProperty("error", out var errorProp) && errorProp.ValueKind == JsonValueKind.True) + { + var status = TryReadInt(root, "status"); + var message = TryReadString(root, "message"); + var body = TryReadString(root, "body"); + error = $"nyx_proxy_error status={status?.ToString() ?? "unknown"}"; + if (!string.IsNullOrWhiteSpace(message)) + error += $" message={message}"; + if (!string.IsNullOrWhiteSpace(body)) + error += $" body={body}"; + return true; + } + + if (root.TryGetProperty("ok", out var okProp) && okProp.ValueKind == JsonValueKind.False) + { + var code = TryReadInt(root, "error_code"); + var description = TryReadString(root, "description"); + error = $"telegram_error_code={code?.ToString() ?? "unknown"}"; + if (!string.IsNullOrWhiteSpace(description)) + error += $" description={description}"; + return true; + } + + return false; + } + catch (JsonException) + { + error = "invalid_telegram_response_json"; + return true; + } + } + + /// + /// Extracts useful identifiers from a successful Telegram sendMessage response. The + /// shape is {ok:true, result:{message_id, chat:{id,type}, date, ...}}. + /// + public static TelegramSendMessageResult ParseSendSuccess(string response) + { + try + { + using var document = JsonDocument.Parse(response); + if (!document.RootElement.TryGetProperty("result", out var result) || result.ValueKind != JsonValueKind.Object) + return new TelegramSendMessageResult(MessageId: null, ChatId: null, Date: null); + + var messageId = TryReadInt(result, "message_id"); + var date = TryReadInt(result, "date"); + string? chatId = null; + if (result.TryGetProperty("chat", out var chat) && chat.ValueKind == JsonValueKind.Object) + { + chatId = TryReadStringOrNumber(chat, "id"); + } + + return new TelegramSendMessageResult(messageId, chatId, date); + } + catch (JsonException) + { + return new TelegramSendMessageResult(MessageId: null, ChatId: null, Date: null); + } + } + + /// + /// Extracts the chat block from a successful Telegram getChat response. + /// + public static TelegramChatInfo ParseChatSuccess(string response) + { + try + { + using var document = JsonDocument.Parse(response); + if (!document.RootElement.TryGetProperty("result", out var chat) || chat.ValueKind != JsonValueKind.Object) + return new TelegramChatInfo(Id: null, Type: null, Title: null, Username: null); + + return new TelegramChatInfo( + Id: TryReadStringOrNumber(chat, "id"), + Type: TryReadString(chat, "type"), + Title: TryReadString(chat, "title"), + Username: TryReadString(chat, "username")); + } + catch (JsonException) + { + return new TelegramChatInfo(Id: null, Type: null, Title: null, Username: null); + } + } + + private static int? TryReadInt(JsonElement root, string propertyName) => + root.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var value) + ? value + : null; + + private static string? TryReadString(JsonElement root, string propertyName) => + root.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String + ? prop.GetString() + : null; + + private static string? TryReadStringOrNumber(JsonElement root, string propertyName) + { + if (!root.TryGetProperty(propertyName, out var prop)) + return null; + + return prop.ValueKind switch + { + JsonValueKind.String => prop.GetString(), + JsonValueKind.Number => prop.ToString(), + _ => null, + }; + } +} + +internal sealed record TelegramSendMessageResult(int? MessageId, string? ChatId, int? Date); + +internal sealed record TelegramChatInfo(string? Id, string? Type, string? Title, string? Username); diff --git a/src/Aevatar.AI.ToolProviders.Telegram/TelegramToolOptions.cs b/src/Aevatar.AI.ToolProviders.Telegram/TelegramToolOptions.cs new file mode 100644 index 000000000..15afbed99 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.Telegram/TelegramToolOptions.cs @@ -0,0 +1,8 @@ +namespace Aevatar.AI.ToolProviders.Telegram; + +public sealed class TelegramToolOptions +{ + public string ProviderSlug { get; set; } = "api-telegram-bot"; + public bool EnableMessageSend { get; set; } = true; + public bool EnableChatLookup { get; set; } = true; +} diff --git a/src/Aevatar.AI.ToolProviders.Telegram/Tools/TelegramChatsLookupTool.cs b/src/Aevatar.AI.ToolProviders.Telegram/Tools/TelegramChatsLookupTool.cs new file mode 100644 index 000000000..f095155e7 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.Telegram/Tools/TelegramChatsLookupTool.cs @@ -0,0 +1,63 @@ +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.AI.Abstractions.ToolProviders; + +namespace Aevatar.AI.ToolProviders.Telegram.Tools; + +public sealed class TelegramChatsLookupTool : AgentToolBase +{ + private readonly ITelegramNyxClient _client; + + public TelegramChatsLookupTool(ITelegramNyxClient client) + { + _client = client; + } + + public override string Name => "telegram_chats_lookup"; + + public override string Description => + "Look up Telegram chat metadata (id, type, title, username) by chat_id through Nyx-backed transport. " + + "Read-only — useful for confirming chat identity or scoping before a send."; + + public override ToolApprovalMode ApprovalMode => ToolApprovalMode.Auto; + + protected override async Task ExecuteAsync(Parameters parameters, CancellationToken ct) + { + var token = AgentToolRequestContext.TryGet(LLMRequestMetadataKeys.NyxIdAccessToken); + if (string.IsNullOrWhiteSpace(token)) + return TelegramProxyResponseParser.Serialize(new { success = false, error = "No NyxID access token available. User must be authenticated." }); + + var chatId = parameters.ChatId?.Trim(); + if (string.IsNullOrWhiteSpace(chatId)) + return TelegramProxyResponseParser.Serialize(new { success = false, error = "chat_id is required" }); + + var response = await _client.GetChatAsync( + token, + new TelegramGetChatRequest(ChatId: chatId), + ct); + + if (TelegramProxyResponseParser.TryParseError(response, out var error)) + { + return TelegramProxyResponseParser.Serialize(new + { + success = false, + error, + chat_id = chatId, + }); + } + + var info = TelegramProxyResponseParser.ParseChatSuccess(response); + return TelegramProxyResponseParser.Serialize(new + { + success = true, + chat_id = info.Id ?? chatId, + type = info.Type, + title = info.Title, + username = info.Username, + }); + } + + public sealed class Parameters + { + public string? ChatId { get; set; } + } +} diff --git a/src/Aevatar.AI.ToolProviders.Telegram/Tools/TelegramMessagesSendTool.cs b/src/Aevatar.AI.ToolProviders.Telegram/Tools/TelegramMessagesSendTool.cs new file mode 100644 index 000000000..d0fee1248 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.Telegram/Tools/TelegramMessagesSendTool.cs @@ -0,0 +1,98 @@ +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.AI.Abstractions.ToolProviders; + +namespace Aevatar.AI.ToolProviders.Telegram.Tools; + +public sealed class TelegramMessagesSendTool : AgentToolBase +{ + private static readonly HashSet AllowedParseModes = + [ + "MarkdownV2", + "HTML", + "Markdown", + ]; + + private readonly ITelegramNyxClient _client; + + public TelegramMessagesSendTool(ITelegramNyxClient client) + { + _client = client; + } + + public override string Name => "telegram_messages_send"; + + public override string Description => + "Proactively send a Telegram message through Nyx-backed transport. " + + "Use this for notifications or workflow side effects, not for replying to the current inbound relay turn."; + + public override ToolApprovalMode ApprovalMode => ToolApprovalMode.Auto; + + protected override async Task ExecuteAsync(Parameters parameters, CancellationToken ct) + { + var token = AgentToolRequestContext.TryGet(LLMRequestMetadataKeys.NyxIdAccessToken); + if (string.IsNullOrWhiteSpace(token)) + return TelegramProxyResponseParser.Serialize(new { success = false, error = "No NyxID access token available. User must be authenticated." }); + + var chatId = parameters.ChatId?.Trim(); + if (string.IsNullOrWhiteSpace(chatId)) + return TelegramProxyResponseParser.Serialize(new { success = false, error = "chat_id is required" }); + + var text = parameters.Text; + if (string.IsNullOrWhiteSpace(text)) + return TelegramProxyResponseParser.Serialize(new { success = false, error = "text is required" }); + + string? parseMode = null; + if (!string.IsNullOrWhiteSpace(parameters.ParseMode)) + { + var trimmed = parameters.ParseMode.Trim(); + if (!AllowedParseModes.Contains(trimmed)) + { + return TelegramProxyResponseParser.Serialize(new + { + success = false, + error = "parse_mode must be one of: MarkdownV2, HTML, Markdown", + }); + } + + parseMode = trimmed; + } + + var response = await _client.SendMessageAsync( + token, + new TelegramSendMessageRequest( + ChatId: chatId, + Text: text, + ParseMode: parseMode, + DisableNotification: parameters.DisableNotification, + ReplyToMessageId: parameters.ReplyToMessageId), + ct); + + if (TelegramProxyResponseParser.TryParseError(response, out var error)) + { + return TelegramProxyResponseParser.Serialize(new + { + success = false, + error, + chat_id = chatId, + }); + } + + var result = TelegramProxyResponseParser.ParseSendSuccess(response); + return TelegramProxyResponseParser.Serialize(new + { + success = true, + chat_id = result.ChatId ?? chatId, + message_id = result.MessageId, + date = result.Date, + }); + } + + public sealed class Parameters + { + public string? ChatId { get; set; } + public string? Text { get; set; } + public string? ParseMode { get; set; } + public bool? DisableNotification { get; set; } + public int? ReplyToMessageId { get; set; } + } +} diff --git a/test/Aevatar.AI.ToolProviders.Telegram.Tests/Aevatar.AI.ToolProviders.Telegram.Tests.csproj b/test/Aevatar.AI.ToolProviders.Telegram.Tests/Aevatar.AI.ToolProviders.Telegram.Tests.csproj new file mode 100644 index 000000000..849573019 --- /dev/null +++ b/test/Aevatar.AI.ToolProviders.Telegram.Tests/Aevatar.AI.ToolProviders.Telegram.Tests.csproj @@ -0,0 +1,21 @@ + + + net10.0 + enable + enable + false + true + Aevatar.AI.ToolProviders.Telegram.Tests + Aevatar.AI.ToolProviders.Telegram.Tests + + + + + + + + + + + + diff --git a/test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramToolsTests.cs b/test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramToolsTests.cs new file mode 100644 index 000000000..341127052 --- /dev/null +++ b/test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramToolsTests.cs @@ -0,0 +1,204 @@ +using System.Text.Json; +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.AI.Abstractions.ToolProviders; +using Aevatar.AI.ToolProviders.Telegram; +using Aevatar.AI.ToolProviders.Telegram.Tools; +using FluentAssertions; +using Xunit; + +namespace Aevatar.AI.ToolProviders.Telegram.Tests; + +public class TelegramToolsTests +{ + [Fact] + public async Task SendMessage_returns_success_with_message_id_and_chat_id() + { + var client = new StubTelegramNyxClient + { + SendResponse = """{"ok":true,"result":{"message_id":42,"chat":{"id":12345,"type":"private"},"date":1730000000}}""", + }; + var tool = new TelegramMessagesSendTool(client); + + using var _ = new AgentToolRequestMetadataScope("token-abc"); + var result = await tool.ExecuteAsync( + """{"chat_id":"12345","text":"hello world"}"""); + + using var document = JsonDocument.Parse(result); + document.RootElement.GetProperty("success").GetBoolean().Should().BeTrue(); + document.RootElement.GetProperty("message_id").GetInt32().Should().Be(42); + document.RootElement.GetProperty("chat_id").GetString().Should().Be("12345"); + client.LastSendRequest.Should().NotBeNull(); + client.LastSendRequest!.Text.Should().Be("hello world"); + client.LastSendRequest!.ParseMode.Should().BeNull(); + } + + [Fact] + public async Task SendMessage_validates_inputs() + { + var tool = new TelegramMessagesSendTool(new StubTelegramNyxClient()); + + using (new AgentToolRequestMetadataScope()) + { + (await tool.ExecuteAsync("""{"chat_id":"12345","text":"hi"}""")) + .Should().Contain("No NyxID access token available"); + } + + using (new AgentToolRequestMetadataScope("token-abc")) + { + (await tool.ExecuteAsync("""{"chat_id":" ","text":"hi"}""")) + .Should().Contain("chat_id is required"); + (await tool.ExecuteAsync("""{"chat_id":"12345","text":""}""")) + .Should().Contain("text is required"); + (await tool.ExecuteAsync("""{"chat_id":"12345","text":"hi","parse_mode":"plaintext"}""")) + .Should().Contain("parse_mode must be one of"); + } + } + + [Fact] + public async Task SendMessage_surfaces_proxy_and_telegram_errors_with_chat_id() + { + var nyxErrorTool = new TelegramMessagesSendTool(new StubTelegramNyxClient + { + SendResponse = """{"error":true,"status":503,"message":"upstream offline"}""", + }); + using (new AgentToolRequestMetadataScope("token-abc")) + { + var result = await nyxErrorTool.ExecuteAsync("""{"chat_id":"99","text":"hi"}"""); + result.Should().Contain("nyx_proxy_error status=503"); + result.Should().Contain("\"chat_id\":\"99\""); + } + + var tgErrorTool = new TelegramMessagesSendTool(new StubTelegramNyxClient + { + SendResponse = """{"ok":false,"error_code":403,"description":"Forbidden: bot was blocked by the user"}""", + }); + using (new AgentToolRequestMetadataScope("token-abc")) + { + var result = await tgErrorTool.ExecuteAsync("""{"chat_id":"99","text":"hi"}"""); + result.Should().Contain("telegram_error_code=403"); + result.Should().Contain("Forbidden: bot was blocked by the user"); + } + } + + [Fact] + public async Task ChatsLookup_returns_chat_metadata() + { + var client = new StubTelegramNyxClient + { + GetChatResponse = """{"ok":true,"result":{"id":-1001234,"type":"supergroup","title":"My Group","username":"my_group"}}""", + }; + var tool = new TelegramChatsLookupTool(client); + + using var _ = new AgentToolRequestMetadataScope("token-abc"); + var result = await tool.ExecuteAsync("""{"chat_id":"-1001234"}"""); + + using var document = JsonDocument.Parse(result); + document.RootElement.GetProperty("success").GetBoolean().Should().BeTrue(); + document.RootElement.GetProperty("chat_id").GetString().Should().Be("-1001234"); + document.RootElement.GetProperty("type").GetString().Should().Be("supergroup"); + document.RootElement.GetProperty("title").GetString().Should().Be("My Group"); + document.RootElement.GetProperty("username").GetString().Should().Be("my_group"); + client.LastGetChatRequest.Should().NotBeNull(); + client.LastGetChatRequest!.ChatId.Should().Be("-1001234"); + } + + [Fact] + public async Task ChatsLookup_validates_inputs() + { + var tool = new TelegramChatsLookupTool(new StubTelegramNyxClient()); + + using (new AgentToolRequestMetadataScope()) + { + (await tool.ExecuteAsync("""{"chat_id":"99"}""")) + .Should().Contain("No NyxID access token available"); + } + + using (new AgentToolRequestMetadataScope("token-abc")) + { + (await tool.ExecuteAsync("""{"chat_id":""}""")) + .Should().Contain("chat_id is required"); + } + } + + [Fact] + public async Task ToolSource_emits_no_tools_when_nyx_base_url_missing() + { + var source = new TelegramAgentToolSource( + new TelegramToolOptions(), + new NyxId.NyxIdToolOptions { BaseUrl = null }, + new StubTelegramNyxClient()); + + var tools = await source.DiscoverToolsAsync(); + tools.Should().BeEmpty(); + } + + [Fact] + public async Task ToolSource_emits_send_and_lookup_tools_when_configured() + { + var source = new TelegramAgentToolSource( + new TelegramToolOptions(), + new NyxId.NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, + new StubTelegramNyxClient()); + + var tools = await source.DiscoverToolsAsync(); + tools.Select(t => t.Name).Should().BeEquivalentTo("telegram_messages_send", "telegram_chats_lookup"); + } + + [Fact] + public async Task ToolSource_respects_disable_flags() + { + var source = new TelegramAgentToolSource( + new TelegramToolOptions + { + EnableMessageSend = false, + EnableChatLookup = false, + }, + new NyxId.NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, + new StubTelegramNyxClient()); + + var tools = await source.DiscoverToolsAsync(); + tools.Should().BeEmpty(); + } + + private sealed class StubTelegramNyxClient : ITelegramNyxClient + { + public string? SendResponse { get; set; } + public string? GetChatResponse { get; set; } + public TelegramSendMessageRequest? LastSendRequest { get; private set; } + public TelegramGetChatRequest? LastGetChatRequest { get; private set; } + + public Task SendMessageAsync(string token, TelegramSendMessageRequest request, CancellationToken ct) + { + LastSendRequest = request; + return Task.FromResult(SendResponse ?? """{"ok":true,"result":{"message_id":1,"chat":{"id":1,"type":"private"},"date":0}}"""); + } + + public Task GetChatAsync(string token, TelegramGetChatRequest request, CancellationToken ct) + { + LastGetChatRequest = request; + return Task.FromResult(GetChatResponse ?? """{"ok":true,"result":{"id":1,"type":"private"}}"""); + } + } + + private sealed class AgentToolRequestMetadataScope : IDisposable + { + private readonly IReadOnlyDictionary? _previous; + + public AgentToolRequestMetadataScope(string? accessToken = null) + { + _previous = AgentToolRequestContext.CurrentMetadata; + if (string.IsNullOrWhiteSpace(accessToken)) + { + AgentToolRequestContext.CurrentMetadata = null; + return; + } + + AgentToolRequestContext.CurrentMetadata = new Dictionary(StringComparer.Ordinal) + { + [LLMRequestMetadataKeys.NyxIdAccessToken] = accessToken, + }; + } + + public void Dispose() => AgentToolRequestContext.CurrentMetadata = _previous; + } +} From 6626aa81a1548c30563d45a11939149e21969943 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 27 Apr 2026 12:02:34 +0800 Subject: [PATCH 07/15] Document Telegram on the unified inbound backbone Amend ADR-0013 with a Telegram section capturing the platform-specific choices: same NyxIdRelay transport, new Platform.Telegram renderer, NyxTelegramProvisioningService for bot registration, generalized Credentials map on the provisioning request, and the chat-only ToolProviders.Telegram surface. Add the 2026-04-27 Telegram cutover runbook to mirror the Lark one: provisioning request shapes (bot_token shorthand or generic credentials map), setWebhook configuration against the returned Nyx webhook_url, expected runtime behavior, and the known gaps (forum topics + file attachments out of scope for chat-only). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../0013-unified-channel-inbound-backbone.md | 41 ++++++ ...2026-04-27-telegram-nyx-cutover-runbook.md | 131 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 docs/operations/2026-04-27-telegram-nyx-cutover-runbook.md diff --git a/docs/decisions/0013-unified-channel-inbound-backbone.md b/docs/decisions/0013-unified-channel-inbound-backbone.md index d9c59578b..801fbd3fa 100644 --- a/docs/decisions/0013-unified-channel-inbound-backbone.md +++ b/docs/decisions/0013-unified-channel-inbound-backbone.md @@ -51,3 +51,44 @@ Concretely: - ADR-0011 is superseded: the production relay edge remains `Lark -> NyxID -> Aevatar`, but inbound ownership is no longer a Lark-only webhook design - ADR-0012 remains in force: Aevatar still does not become the long-term credential authority for channel bots + +## Telegram amendment (2026-04-27) + +Telegram joins as the second platform to ride this backbone, replacing the earlier +direct-callback `Aevatar.GAgents.Channel.Telegram` adapter prototype that ADR-0012 had +already excluded from the supported production contract. + +- transport: same `Aevatar.GAgents.Channel.NyxIdRelay`. The relay payload carries + `platform="telegram"` so the transport's normalize / parse path needs no Telegram + branch. `ConversationReference.Scope` is derived from + `NyxIdRelayConversationTypeMap` (`private` -> `DirectMessage`, + `group` / `supergroup` -> `Group`, `channel` -> `Channel`). +- rendering: new `Aevatar.GAgents.Platform.Telegram` package mirrors + `Aevatar.GAgents.Platform.Lark` — `TelegramMessageComposer` + `TelegramChannelNativeMessageProducer` + + `TelegramOutboundMessage` + `TelegramPayloadRedactor`. Telegram has no card layout + primitive, so cards degrade into the rendered text body and action buttons render as a + single-row `inline_keyboard` with `callback_data` truncated to the 64-byte Bot API limit. +- provisioning: new `NyxTelegramProvisioningService` parallels `NyxLarkProvisioningService` + but registers `platform="telegram"` with a real `bot_token` (no Lark + `__unused_for_lark__` placeholder) and no `app_id` / `app_secret` / + `verification_token`. The default Nyx provider slug is `api-telegram-bot`. +- registration contract: `NyxChannelBotProvisioningRequest` gains an optional + `Credentials` map so future platforms can carry their secret bag without growing the + record's typed sub-messages. The Lark typed sub-message stays in place to keep the + existing Lark provisioning unchanged. The HTTP `POST /api/channels/registrations` + endpoint accepts a top-level `bot_token` shorthand for Telegram and a generic + `credentials` JSON map for future platforms; the endpoint mirrors the legacy Lark + fields into the typed sub-message and the Telegram `bot_token` into the + `Credentials["bot_token"]` map. +- tools: new `Aevatar.AI.ToolProviders.Telegram` exposes the chat-only subset needed + today — `telegram_messages_send` (Bot API `sendMessage`) and `telegram_chats_lookup` + (Bot API `getChat`). Both go through `NyxIdApiClient.ProxyRequestAsync` against the + `api-telegram-bot` provider slug; reply-in-turn keeps flowing through + `NyxIdRelayOutboundPort`. +- credential boundary: ADR-0012 still applies — Aevatar holds no Telegram bot tokens. + The bot token only crosses the registration endpoint on the way to Nyx, never persisted + locally. Webhook subscription URL points at Nyx (`/api/v1/webhooks/channel/telegram/{channelBotId}`), + and inbound traffic still flows through the same callback-JWT-validated + `/api/webhooks/nyxid-relay` ingress. + +The lessons that shaped this PR are captured in `docs/operations/2026-04-27-telegram-nyx-cutover-runbook.md`. diff --git a/docs/operations/2026-04-27-telegram-nyx-cutover-runbook.md b/docs/operations/2026-04-27-telegram-nyx-cutover-runbook.md new file mode 100644 index 000000000..05f48ce33 --- /dev/null +++ b/docs/operations/2026-04-27-telegram-nyx-cutover-runbook.md @@ -0,0 +1,131 @@ +# Telegram -> NyxID -> Aevatar Cutover Runbook + +This runbook reflects the post-`#262` Telegram production contract; it is the +Telegram counterpart to `2026-04-22-lark-nyx-cutover-runbook.md` and assumes the +same ADR-0013 unified inbound backbone is already deployed. + +## Preflight + +- ADR-0012 disallows local Telegram credential ownership in ChannelRuntime; the + earlier `Aevatar.GAgents.Channel.Telegram` direct adapter prototype is removed and + must not be redeployed. +- This cut requires the same `channel-bot-registration-store` greenfield / wipe state + as the Lark cutover: do not register Telegram bots into a store that still holds + pre-ADR-0012 wire-shape entries. +- Confirm the Aevatar relay ingress (`POST /api/webhooks/nyxid-relay`) and the Nyx relay + reply path are healthy before adding Telegram traffic. + +## Goal + +Bring Telegram bot ingress online through `Telegram -> NyxID -> Aevatar`. There is no +direct Telegram ingress on Aevatar — Aevatar exposes no webhook URL that BotFather can +target. The Telegram bot's `setWebhook` URL must point at Nyx, exactly as Lark's +Developer Console webhook does. + +## Preconditions + +- Aevatar relay ingress is deployed at: + - `POST /api/webhooks/nyxid-relay` +- Nyx relay JWT validation is enabled in Aevatar. +- NyxID exposes the `api-telegram-bot` proxy slug for outbound Telegram Bot API calls + (`sendMessage`, `getChat`). +- A real Telegram bot token has been issued by `@BotFather` and is in hand. + +## Provisioning + +Provisioning is dispatched through the same registration endpoint as Lark; the platform +discriminator is `telegram` and the only required secret is the bot token. Either of the +following two body shapes is accepted: + +Shorthand (Telegram only): + +```json +{ + "platform": "telegram", + "label": "Ops Bot", + "webhook_base_url": "https://aevatar.example.com", + "bot_token": "1234567890:AA...REDACTED..." +} +``` + +Generic credentials map (forward-compatible for future platforms): + +```json +{ + "platform": "telegram", + "label": "Ops Bot", + "webhook_base_url": "https://aevatar.example.com", + "credentials": { + "bot_token": "1234567890:AA...REDACTED..." + } +} +``` + +The endpoint returns the standard provisioning payload: + +- `registration_id` +- `nyx_channel_bot_id` +- `nyx_agent_api_key_id` +- `nyx_conversation_route_id` +- `relay_callback_url` — Aevatar's Nyx relay ingress +- `webhook_url` — the Nyx Telegram webhook URL: `https:///api/v1/webhooks/channel/telegram/{nyx_channel_bot_id}` +- `nyx_provider_slug` — defaults to `api-telegram-bot` + +## Cutover Steps + +1. Complete the preflight wipe / greenfield check for `channel-bot-registration-store`. +2. Deploy Aevatar with `INyxChannelBotProvisioningService` discovery for Telegram + active and the `Aevatar.GAgents.Platform.Telegram` composer registered (verify the + ChannelRuntime DI bucket reports two `INyxChannelBotProvisioningService` entries — + Lark and Telegram — and that `IChannelMessageComposerRegistry.Get(ChannelId.From("telegram"))` + resolves to `TelegramMessageComposer`). +3. Provision the Telegram bot through the registration endpoint (either body shape). +4. Call Telegram's `setWebhook` with the returned Nyx `webhook_url`. Recommended + parameters: + - `url` = `webhook_url` + - `allowed_updates` = `["message","edited_message","callback_query","channel_post"]` + - `secret_token` = whatever Nyx documents for HMAC validation on its side +5. Observe: + - Nyx -> Aevatar relay callback success on `/api/webhooks/nyxid-relay` for inbound + Telegram messages + - Aevatar -> Nyx `channel-relay/reply` success for outbound replies + - Optional: agent-tool calls `telegram_messages_send` / `telegram_chats_lookup` + succeed against `api-telegram-bot` +6. If you need to rotate the bot token: + - Issue a new token through `@BotFather` (`/revoke` then `/token`). + - Re-provision through the registration endpoint with the new token; this creates a + new `nyx_channel_bot_id`. + - Re-run `setWebhook` against the new Nyx `webhook_url`. + +## Expected Runtime Behavior + +- Inbound Telegram updates arrive at Aevatar through `POST /api/webhooks/nyxid-relay` + carrying `payload.platform == "telegram"`. There is no separate `/api/channels/telegram/callback/...` path on Aevatar. +- `ConversationReference.Scope` for Telegram traffic is derived by + `NyxIdRelayConversationTypeMap`: + `private` -> `DirectMessage`, `group` / `supergroup` -> `Group`, + `channel` -> `Channel`. Forum topics (`message_thread_id`) are not yet modeled. +- Reply text-only messages flow through `NyxIdRelayOutboundPort.SendAsync(platform="telegram", ...)` + which dispatches via `TelegramChannelNativeMessageProducer` -> Nyx + `channel-relay/reply` -> Telegram `sendMessage`. +- Cards in agent intents degrade into the rendered text body for Telegram (no native + card UI). Action buttons render as a single-row `inline_keyboard` with + `callback_data` truncated to the Bot API 64-byte limit; submissions arrive as + `CardActionSubmission` activities exactly like Lark `card.action.trigger`. +- Aevatar persists no Telegram bot tokens. The token only exists in transit through + the registration endpoint; revocation/rotation is handled at Telegram + + re-provisioning time as documented in step 6. +- Telegram tools (`telegram_messages_send`, `telegram_chats_lookup`) require a + per-call NyxID access token in the request metadata; without it they return + `success=false, error="No NyxID access token available"` rather than calling Nyx. + +## Known Gaps + +- Telegram forum topics (`message_thread_id`) are not surfaced in + `ConversationReference` yet; group threads collapse into the parent group conversation + scope. Add a typed `ThreadId` field on `TransportExtras` if/when topic-scoped routing + becomes a product requirement. +- File / photo / voice attachments are not in the chat-only scope. The Telegram + composer reports `Unsupported` capability when an intent carries attachments; + agents must avoid producing attachment intents for Telegram until the composer + grows that branch. From 68cbd460ee9c23aceaac5b44bd4ed7496175c7b4 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 27 Apr 2026 12:09:39 +0800 Subject: [PATCH 08/15] Drop dead Telegram ConversationReference helper test The TelegramPrivate / TelegramGroup / TelegramChannel partial helpers were direct-adapter surface that NyxIdRelayTransport now subsumes; the partial backing them was deleted along with the legacy Channel.Telegram project. Lark has no equivalent helper, so the canonical-key building path is solely the relay transport's responsibility. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ChannelAbstractionsSurfaceTests.cs | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/test/Aevatar.GAgents.Channel.Protocol.Tests/ChannelAbstractionsSurfaceTests.cs b/test/Aevatar.GAgents.Channel.Protocol.Tests/ChannelAbstractionsSurfaceTests.cs index 83b132c16..cb0e438a8 100644 --- a/test/Aevatar.GAgents.Channel.Protocol.Tests/ChannelAbstractionsSurfaceTests.cs +++ b/test/Aevatar.GAgents.Channel.Protocol.Tests/ChannelAbstractionsSurfaceTests.cs @@ -44,26 +44,6 @@ public void ConversationReferenceHelpers_ShouldRejectMissingCanonicalSegments() exception.ParamName.ShouldBe("segments"); } - [Fact] - public void ConversationReferenceHelpers_ShouldBuildTelegramCanonicalKeys() - { - var bot = BotInstanceId.From("telegram-bot"); - - var direct = ConversationReference.TelegramPrivate(bot, "42"); - var group = ConversationReference.TelegramGroup(bot, "-1001"); - var supergroup = ConversationReference.TelegramGroup(bot, "-1002", isSupergroup: true); - var channel = ConversationReference.TelegramChannel(bot, "-1003"); - - direct.CanonicalKey.ShouldBe("telegram:private:42"); - direct.Scope.ShouldBe(ConversationScope.DirectMessage); - group.CanonicalKey.ShouldBe("telegram:group:-1001"); - group.Scope.ShouldBe(ConversationScope.Group); - supergroup.CanonicalKey.ShouldBe("telegram:supergroup:-1002"); - supergroup.Scope.ShouldBe(ConversationScope.Group); - channel.CanonicalKey.ShouldBe("telegram:channel:-1003"); - channel.Scope.ShouldBe(ConversationScope.Channel); - } - [Fact] public void EmitResultHelpers_ShouldCaptureRetryDelay() { From 9e2b4730b481cf42aa5956a7698d25f4ee3e6199 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 27 Apr 2026 12:54:50 +0800 Subject: [PATCH 09/15] Map missing_bot_token to 400 in registration endpoint Codex review on PR #289 flagged that POST /api/channels/registrations falls through to 502 BadGateway when the caller omits the Telegram bot_token, because ResolveProvisioningFailureStatusCode only listed the Lark-shaped missing_* error codes. Treating client validation input as a server/proxy failure breaks client retry/validation logic and adds noise to operational triage. Add missing_bot_token to the 400 BadRequest bucket alongside missing_app_id / missing_app_secret / missing_verification_token / missing_webhook_base_url / missing_scope_id, and add a regression test asserting the Telegram missing-bot_token path returns 400. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ChannelCallbackEndpoints.cs | 2 +- .../ChannelCallbackEndpointsTests.cs | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs b/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs index fb8f60172..238b8fd40 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs @@ -359,7 +359,7 @@ private static int ResolveProvisioningFailureStatusCode(string? error) { "unsupported_platform" => StatusCodes.Status409Conflict, "missing_access_token" => StatusCodes.Status401Unauthorized, - "missing_app_id" or "missing_app_secret" or "missing_verification_token" or "missing_webhook_base_url" or "missing_scope_id" => StatusCodes.Status400BadRequest, + "missing_app_id" or "missing_app_secret" or "missing_verification_token" or "missing_bot_token" or "missing_webhook_base_url" or "missing_scope_id" => StatusCodes.Status400BadRequest, "nyx_base_url_not_configured" => StatusCodes.Status500InternalServerError, _ => StatusCodes.Status502BadGateway, }; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelCallbackEndpointsTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelCallbackEndpointsTests.cs index 251e50171..95f1b0e28 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelCallbackEndpointsTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelCallbackEndpointsTests.cs @@ -156,6 +156,35 @@ public async Task HandleRegisterAsync_ReturnsBadGateway_WhenNyxProvisioningFails response.Body.Should().Contain("invalid app secret"); } + [Fact] + public async Task HandleRegisterAsync_ReturnsBadRequest_WhenTelegramBotTokenMissing() + { + var provisioningService = Substitute.For(); + provisioningService.Platform.Returns("telegram"); + provisioningService.ProvisionAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new NyxChannelBotProvisioningResult( + Succeeded: false, + Status: "error", + Platform: "telegram", + Error: "missing_bot_token"))); + + var http = CreateJsonHttpContext( + """{"platform":"telegram","webhook_base_url":"https://aevatar.example.com"}""", + "scope-1"); + http.Request.Headers.Authorization = "Bearer test-token"; + + var result = await InvokeAsync( + "HandleRegisterAsync", + http, + new[] { provisioningService }, + NullLoggerFactory.Instance, + CancellationToken.None); + var response = await ExecuteResultAsync(result); + + response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + response.Body.Should().Contain("missing_bot_token"); + } + [Fact] public async Task HandleListRegistrationsAsync_ReturnsRelayModeOnly() { From cc9cc4bfec9b73d0ee6855e3f95bc918c86f3c5e Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 27 Apr 2026 13:09:06 +0800 Subject: [PATCH 10/15] Address pre-landing review on PR #289 - [P1] Drop the unused Telegram.Bot v22.9.5.3 PackageVersion. The legacy Aevatar.GAgents.Channel.Telegram adapter that referenced it was deleted in this PR; ADR-0013 explicitly says no per-platform SDK client. Restore the trailing newline on Directory.Packages.props. - [P1] Map "supergroup" to ConversationScope.Group in NyxIdRelayConversationTypeMap. Telegram emits "supergroup" as a distinct Bot API conversation type, so without this row inbound supergroup traffic fell through to the default branch and the transport rejected it. Add regression cases to NyxIdRelayConversationTypeMapTests. - [P2] Stop globally relaxing the conformance debounce ceiling to 3000. The shared ChannelAdapterConformanceTests cap returns to 2000; channels with platform rate limits that need more (e.g. Telegram editMessageText ~1/s per chat) override MaxRecommendedStreamDebounceMs in their concrete conformance subclass. Telegram itself does not currently subclass the conformance suite (NyxIdRelay subsumes its inbound), so this is a forward-looking guardrail. - [P2] Document the manual Nyx cleanup procedure in the Telegram cutover runbook for the local_mirror_accepted_remote_cleanup_skipped error, including the reverse-order DELETE sequence operators run against Nyx. The other three review notes are intentional or moot (Nyx rollback order is correct; the redactor list is more aggressive than Lark but IPayloadRedactor has no production consumer today; callback_data truncation is a Bot API constraint with no clean composer-side answer without restructuring action.Value semantics). Co-Authored-By: Claude Opus 4.7 (1M context) --- Directory.Packages.props | 3 +-- .../NyxIdRelayConversationTypeMap.cs | 5 +++++ ...2026-04-27-telegram-nyx-cutover-runbook.md | 22 +++++++++++++++++++ .../ChannelAdapterConformanceTests.cs | 10 ++++++++- .../NyxIdRelayConversationTypeMapTests.cs | 2 ++ 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3296457ec..67ff150cd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -160,7 +160,6 @@ - @@ -352,4 +351,4 @@ - \ No newline at end of file + diff --git a/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxIdRelayConversationTypeMap.cs b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxIdRelayConversationTypeMap.cs index 5b9adb300..8a8a0ca4a 100644 --- a/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxIdRelayConversationTypeMap.cs +++ b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxIdRelayConversationTypeMap.cs @@ -17,6 +17,11 @@ public static bool TryMap(string? conversationType, out ConversationScope scope) scope = ConversationScope.DirectMessage; return true; case "group": + // Telegram supergroups carry distinct semantics on the Bot API but the + // ChatActivity scope only distinguishes DM / Group / Channel; collapse to + // Group so supergroup inbound traffic is not rejected as an unsupported + // conversation type. + case "supergroup": scope = ConversationScope.Group; return true; case "channel": diff --git a/docs/operations/2026-04-27-telegram-nyx-cutover-runbook.md b/docs/operations/2026-04-27-telegram-nyx-cutover-runbook.md index 05f48ce33..6732b6de1 100644 --- a/docs/operations/2026-04-27-telegram-nyx-cutover-runbook.md +++ b/docs/operations/2026-04-27-telegram-nyx-cutover-runbook.md @@ -97,6 +97,28 @@ The endpoint returns the standard provisioning payload: new `nyx_channel_bot_id`. - Re-run `setWebhook` against the new Nyx `webhook_url`. +## Manual Cleanup On Partial Provisioning Failure + +`NyxTelegramProvisioningService` rolls back any of the three Nyx resources it +created (`api_key` -> `channel_bot` -> `channel_route`) when an exception is +thrown **before** the local mirror dispatch is accepted. If the local mirror +dispatch itself fails after all three Nyx resources are live, the service +returns `error="local_mirror_accepted_remote_cleanup_skipped"` and **does not +delete the Nyx resources** — the caller is expected to clean up manually so a +later operator can correlate the orphaned IDs with the failed registration. + +When you see that error, the response payload still carries the Nyx resource +identifiers (`nyx_channel_bot_id`, `nyx_agent_api_key_id`, and the conversation +route ID is logged on the server side). Reverse-order cleanup against Nyx: + +1. Delete the conversation route — `DELETE /api/v1/channel-conversations/{route_id}` +2. Delete the channel bot — `DELETE /api/v1/channel-bots/{nyx_channel_bot_id}` +3. Delete the relay api-key — `DELETE /api/v1/api-keys/{nyx_agent_api_key_id}` + +Then re-run the registration endpoint to provision a fresh set. The earlier +ADR-0012 contract still applies — there is no Aevatar-side cleanup needed +because Aevatar never persisted the bot token. + ## Expected Runtime Behavior - Inbound Telegram updates arrive at Aevatar through `POST /api/webhooks/nyxid-relay` diff --git a/test/Aevatar.GAgents.Channel.Testing/Conformance/ChannelAdapterConformanceTests.cs b/test/Aevatar.GAgents.Channel.Testing/Conformance/ChannelAdapterConformanceTests.cs index 93960c8d2..2b38188a1 100644 --- a/test/Aevatar.GAgents.Channel.Testing/Conformance/ChannelAdapterConformanceTests.cs +++ b/test/Aevatar.GAgents.Channel.Testing/Conformance/ChannelAdapterConformanceTests.cs @@ -292,9 +292,17 @@ public async Task Outbound_StreamingReply_CoalescesDeltasWithinRateLimit() emit.Success.ShouldBeTrue(); var debounce = Math.Max(CapabilitiesOf(lifetime.Adapter).RecommendedStreamDebounceMs, 0); - debounce.ShouldBeLessThanOrEqualTo(3000); + debounce.ShouldBeLessThanOrEqualTo(MaxRecommendedStreamDebounceMs); } + /// + /// Upper bound on the conformance suite + /// will accept. Channels with stricter platform rate limits (Telegram's editMessageText caps near + /// once per second per chat) override this to relax the ceiling; the shared default keeps fast-edit + /// channels honest. + /// + protected virtual int MaxRecommendedStreamDebounceMs => 2000; + [Fact] public async Task Lifecycle_StartStop_NoLeaks() { diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayConversationTypeMapTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayConversationTypeMapTests.cs index 9d6ce6fbb..5555836c0 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayConversationTypeMapTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayConversationTypeMapTests.cs @@ -9,6 +9,8 @@ public sealed class NyxIdRelayConversationTypeMapTests [Theory] [InlineData("private", ConversationScope.DirectMessage)] [InlineData("group", ConversationScope.Group)] + [InlineData("supergroup", ConversationScope.Group)] + [InlineData("SUPERGROUP", ConversationScope.Group)] [InlineData("channel", ConversationScope.Channel)] public void TryMap_ShouldResolveSupportedConversationTypes(string rawType, ConversationScope expected) { From f70f0f1dfc0c51d0de9bfd99e7318364357264eb Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 27 Apr 2026 13:18:12 +0800 Subject: [PATCH 11/15] Address second-round review on PR #289 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [P2] Stop leaking arbitrary ex.Message through the registration response. Both NyxLarkProvisioningService and NyxTelegramProvisioningService now route caught exceptions through SanitizeFailureReason, which surfaces controlled InvalidOperationException strings (e.g. "channel_bot_id_request_failed nyx_status=401 body=invalid app secret") verbatim while collapsing HTTP transport errors, JSON parser internals, and any other exception type to "provisioning_failed". Operators still get the full exception via the existing LogWarning. Lark and Telegram patched together to keep parity. Add a NyxTelegram regression test that asserts the controlled-string path surfaces and no .NET internals leak. - [P3] Build the default Nyx provider slug from the platform name pattern ($"api-{platform}-bot") instead of switch-falling-back to api-lark-bot. Adding a future platform (e.g. discord) now naturally echoes api-discord-bot rather than silently mislabelling the registration. - [P3] Fail loud on malformed TelegramSendMessageRequest.ReplyMarkupJson. TelegramNyxClient.SendMessageAsync now throws ArgumentException with a clear paramName/message instead of silently forwarding raw garbage to Nyx → Telegram and getting back an opaque Bad Request. Add a regression test exercising the malformed-JSON path with a throwing HttpHandler so the test fails loudly if the call ever reaches Nyx. The other two review notes are intentional or duplicate: - Telegram redactor scrubbing `text`/`caption` was already raised in the prior review; IPayloadRedactor still has zero production consumers (verified by grep on dev), so the redaction list has no runtime impact. - ToolProviders.Telegram.ServiceCollectionExtensions registering an empty NyxIdToolOptions singleton is identical to the established ToolProviders.Lark pattern; the BaseUrl emptiness check in TelegramAgentToolSource.DiscoverToolsAsync is the intended guard. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ChannelCallbackEndpoints.cs | 13 +++++---- .../NyxLarkProvisioningService.cs | 16 +++++++++-- .../NyxTelegramProvisioningService.cs | 14 +++++++++- .../TelegramNyxClient.cs | 13 ++++++--- .../TelegramToolsTests.cs | 24 +++++++++++++++++ .../NyxTelegramProvisioningServiceTests.cs | 27 +++++++++++++++++++ 6 files changed, 95 insertions(+), 12 deletions(-) diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs b/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs index 238b8fd40..cf0025607 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs @@ -477,9 +477,12 @@ private sealed record RegistrationRequest( return bag.Count == 0 ? null : bag; } - private static string ResolveDefaultProviderSlug(string platform) => platform switch - { - "telegram" => "api-telegram-bot", - _ => "api-lark-bot", - }; + /// + /// Builds the default Nyx provider slug echoed back to the client when the registration request + /// did not pin nyx_provider_slug. The convention is api-{platform}-bot, so adding + /// a new platform doesn't need a new switch arm and a future discord registration would + /// surface api-discord-bot rather than silently echoing api-lark-bot. + /// + private static string ResolveDefaultProviderSlug(string platform) => + $"api-{platform}-bot"; } diff --git a/agents/Aevatar.GAgents.ChannelRuntime/NyxLarkProvisioningService.cs b/agents/Aevatar.GAgents.ChannelRuntime/NyxLarkProvisioningService.cs index f3b0029c0..6e50f6d72 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/NyxLarkProvisioningService.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/NyxLarkProvisioningService.cs @@ -213,10 +213,22 @@ await RegisterLocalMirrorAsync( return Failure(localMirrorAccepted ? "local_mirror_accepted_remote_cleanup_skipped" - : ex.Message); + : SanitizeFailureReason(ex)); } } + /// + /// Returns a client-safe failure reason. instances + /// thrown inside this service carry controlled, structured error codes (e.g. + /// channel_bot_id_request_failed nyx_status=401) so they are safe to surface verbatim. + /// Anything else (HTTP transport errors, JSON parser internals, generic exceptions) collapses + /// to provisioning_failed so we don't leak endpoint paths, internal state, or stack + /// fragments through the registration response. Operators get the full exception via the + /// LogWarning above. + /// + private static string SanitizeFailureReason(Exception ex) => + ex is InvalidOperationException ? ex.Message : "provisioning_failed"; + public async Task RepairLocalMirrorAsync(NyxLarkMirrorRepairRequest request, CancellationToken ct) { ArgumentNullException.ThrowIfNull(request); @@ -289,7 +301,7 @@ await RegisterLocalMirrorAsync( request.NyxAgentApiKeyId, request.NyxConversationRouteId); - return MirrorFailure(ex.Message); + return MirrorFailure(SanitizeFailureReason(ex)); } } diff --git a/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs b/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs index 4c0dd4ddc..7d6d663e3 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs @@ -146,10 +146,22 @@ await RegisterLocalMirrorAsync( return Failure(localMirrorAccepted ? "local_mirror_accepted_remote_cleanup_skipped" - : ex.Message); + : SanitizeFailureReason(ex)); } } + /// + /// Returns a client-safe failure reason. instances + /// thrown inside this service carry controlled, structured error codes (e.g. + /// channel_bot_id_request_failed nyx_status=401) so they are safe to surface verbatim. + /// Anything else (HTTP transport errors, JSON parser internals, generic exceptions) collapses + /// to provisioning_failed so we don't leak endpoint paths, internal state, or stack + /// fragments through the registration response. Operators get the full exception via the + /// LogWarning above. + /// + private static string SanitizeFailureReason(Exception ex) => + ex is InvalidOperationException ? ex.Message : "provisioning_failed"; + async Task INyxChannelBotProvisioningService.ProvisionAsync( NyxChannelBotProvisioningRequest request, CancellationToken ct) diff --git a/src/Aevatar.AI.ToolProviders.Telegram/TelegramNyxClient.cs b/src/Aevatar.AI.ToolProviders.Telegram/TelegramNyxClient.cs index 534693c53..2c977b174 100644 --- a/src/Aevatar.AI.ToolProviders.Telegram/TelegramNyxClient.cs +++ b/src/Aevatar.AI.ToolProviders.Telegram/TelegramNyxClient.cs @@ -36,15 +36,20 @@ public Task SendMessageAsync(string token, TelegramSendMessageRequest re body["reply_to_message_id"] = replyTo; if (!string.IsNullOrWhiteSpace(request.ReplyMarkupJson)) { + JsonNode? parsed; try { - body["reply_markup"] = JsonNode.Parse(request.ReplyMarkupJson); + parsed = JsonNode.Parse(request.ReplyMarkupJson); } - catch (JsonException) + catch (JsonException ex) { - // Caller is responsible for valid JSON; surface as Telegram-side error if invalid. - body["reply_markup"] = request.ReplyMarkupJson; + throw new ArgumentException( + $"{nameof(TelegramSendMessageRequest)}.{nameof(TelegramSendMessageRequest.ReplyMarkupJson)} must be valid JSON: {ex.Message}", + nameof(request), + ex); } + + body["reply_markup"] = parsed; } return _nyxClient.ProxyRequestAsync( diff --git a/test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramToolsTests.cs b/test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramToolsTests.cs index 341127052..abc43e990 100644 --- a/test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramToolsTests.cs +++ b/test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramToolsTests.cs @@ -80,6 +80,30 @@ public async Task SendMessage_surfaces_proxy_and_telegram_errors_with_chat_id() } } + [Fact] + public async Task TelegramNyxClient_throws_argument_exception_for_malformed_reply_markup_json() + { + var nyxOptions = new NyxId.NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }; + var client = new TelegramNyxClient( + new TelegramToolOptions(), + new NyxId.NyxIdApiClient(nyxOptions, new HttpClient(new ThrowingHandler()))); + + var act = async () => await client.SendMessageAsync( + "token-abc", + new TelegramSendMessageRequest(ChatId: "1", Text: "hi", ReplyMarkupJson: "{not-json"), + CancellationToken.None); + + var assertion = await act.Should().ThrowAsync(); + assertion.Which.Message.Should().Contain("ReplyMarkupJson"); + assertion.Which.ParamName.Should().Be("request"); + } + + private sealed class ThrowingHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + throw new InvalidOperationException("nyx client should not be reached when reply_markup_json is invalid"); + } + [Fact] public async Task ChatsLookup_returns_chat_metadata() { diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxTelegramProvisioningServiceTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxTelegramProvisioningServiceTests.cs index 6dddd5328..dab053b5a 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxTelegramProvisioningServiceTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxTelegramProvisioningServiceTests.cs @@ -108,6 +108,33 @@ public async Task ProvisionAsync_rejects_invalid_requests_before_calling_nyx( handler.Requests.Should().BeEmpty(); } + [Fact] + public async Task ProvisionAsync_surfaces_controlled_invalid_operation_message_but_not_dotnet_internals() + { + var handler = new RecordingHandler(); + // Non-JSON body in the api-keys response makes ExtractRequiredRelayApiKeyCredentials + // throw InvalidOperationException with a structured controlled message. The catch in + // ProvisionAsync routes that through SanitizeFailureReason — the controlled string is + // safe to surface, but raw .NET stack/type internals must never leak. + handler.Enqueue("/api/v1/api-keys", "not-json-at-all"); + + var service = CreateService(handler); + var result = await service.ProvisionAsync( + new NyxTelegramProvisioningRequest( + AccessToken: "user-token", + BotToken: "1234567890:AA", + WebhookBaseUrl: "https://aevatar.example.com", + ScopeId: "scope-1", + Label: "Ops Bot", + NyxProviderSlug: "api-telegram-bot"), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.Error.Should().Contain("api_key_id_request_failed"); + result.Error.Should().NotContain("System."); + result.Error.Should().NotContain("StackTrace"); + } + [Fact] public async Task INyxChannelBotProvisioningService_reads_bot_token_from_credentials_map() { From 5acf8fefac65cd0966e65155938fa84333f4e0f3 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 27 Apr 2026 13:44:40 +0800 Subject: [PATCH 12/15] Address third-round review on PR #289 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [P0] ChannelBotRegistrationGAgent.HandleRegister was hard-coded to drop any platform other than "lark", so Telegram registrations succeeded in Nyx but the local mirror command was silently discarded by the actor — the prior NyxTelegramProvisioningServiceTests only verified DispatchAsync was called, never that the actor persisted. Replace the single-platform guard with a SupportedPlatforms allowlist {lark, telegram}, update the test that asserted Telegram is silently ignored to assert it is now persisted with the Telegram-shaped fields, and add a regression test that an unsupported platform (discord) is still ignored. - [P2 dedup] Extract NyxApiResponseHelper for the JSON parsing / envelope-detection / error-detail / TryRollback / SanitizeFailureReason helpers that were duplicated between NyxLarkProvisioningService and NyxTelegramProvisioningService. About 100 duplicated lines collapse to one place; both services now delegate to the helper. The legacy private RelayApiKeyCredentials record stays in each service to keep the typed return-shape per platform. - [P3] HandleRegisterAsync now reuses the existing ResolveBearerAccessToken helper instead of reparsing the Bearer prefix inline, matching what the rebuild / delete endpoints already do. - [P3] TelegramNyxClient.GetChatAsync switches from JsonSerializer + anonymous type to the JsonObject style SendMessageAsync uses, so the whole 76-line client uses one JSON construction pattern. The other review points were already addressed or duplicate: - missing_bot_token mapping to 400 was added in commit 9e2b4730 (the reviewer was looking at an earlier commit). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ChannelBotRegistrationGAgent.cs | 17 +- .../ChannelCallbackEndpoints.cs | 6 +- .../NyxApiResponseHelper.cs | 171 ++++++++++++++++++ .../NyxLarkProvisioningService.cs | 162 ++--------------- .../NyxTelegramProvisioningService.cs | 144 +-------------- .../TelegramNyxClient.cs | 8 +- .../ChannelBotRegistrationStoreTests.cs | 31 +++- 7 files changed, 244 insertions(+), 295 deletions(-) create mode 100644 agents/Aevatar.GAgents.ChannelRuntime/NyxApiResponseHelper.cs diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationGAgent.cs b/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationGAgent.cs index 6d6dbbe24..14b4f04f4 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationGAgent.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/ChannelBotRegistrationGAgent.cs @@ -30,13 +30,26 @@ protected override ChannelBotRegistrationStoreState TransitionState(ChannelBotRe // ─── Commands ─── + /// + /// Platforms whose registrations are allowed to land in the local mirror. Aligned with the + /// set of INyxChannelBotProvisioningService registered on the supported production + /// contract. Anything outside this set is treated as a retired direct-callback dispatch and + /// dropped without persistence so legacy producers cannot resurface old wire shapes. + /// + private static readonly HashSet SupportedPlatforms = + new(StringComparer.OrdinalIgnoreCase) + { + "lark", + "telegram", + }; + [EventHandler] public async Task HandleRegister(ChannelBotRegisterCommand cmd) { - if (!string.Equals(cmd.Platform, "lark", StringComparison.OrdinalIgnoreCase)) + if (!SupportedPlatforms.Contains(cmd.Platform ?? string.Empty)) { Logger.LogWarning( - "Ignoring retired direct-callback registration request: platform={Platform}, requestedId={RequestedId}", + "Ignoring registration request for unsupported platform: platform={Platform}, requestedId={RequestedId}", cmd.Platform, cmd.RequestedId); return; diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs b/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs index cf0025607..0c855a401 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs @@ -104,11 +104,7 @@ private static async Task HandleRegisterAsync( }); } - var accessToken = http.Request.Headers.Authorization.ToString(); - const string bearerPrefix = "Bearer "; - if (accessToken.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) - accessToken = accessToken[bearerPrefix.Length..].Trim(); - + var accessToken = ResolveBearerAccessToken(http); if (string.IsNullOrWhiteSpace(accessToken)) return Results.Unauthorized(); diff --git a/agents/Aevatar.GAgents.ChannelRuntime/NyxApiResponseHelper.cs b/agents/Aevatar.GAgents.ChannelRuntime/NyxApiResponseHelper.cs new file mode 100644 index 000000000..2d4b23515 --- /dev/null +++ b/agents/Aevatar.GAgents.ChannelRuntime/NyxApiResponseHelper.cs @@ -0,0 +1,171 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.ChannelRuntime; + +/// +/// Shared parsing / rollback helpers for the Nyx-side responses consumed by per-platform +/// provisioning services (, , +/// future platforms). Centralized here so the Lark and Telegram services do not drift on the +/// JSON shape Nyx returns and the failure-string contract surfaced through the registration +/// endpoint stays uniform. +/// +internal static class NyxApiResponseHelper +{ + /// + /// Returns the trimmed id field from a Nyx create-resource response, or throws + /// with a controlled error code suffix derived from + /// . Wraps + . + /// + public static string ExtractRequiredId(string response, string resourceName) + { + if (LooksLikeErrorEnvelope(response)) + throw new InvalidOperationException($"{resourceName}_request_failed {ExtractErrorDetail(response)}"); + + try + { + using var document = JsonDocument.Parse(response); + var root = document.RootElement; + if (!root.TryGetProperty("id", out var idElement) || idElement.ValueKind != JsonValueKind.String) + throw new InvalidOperationException($"missing_id_in_{resourceName}_response"); + + var id = idElement.GetString()?.Trim(); + if (string.IsNullOrWhiteSpace(id)) + throw new InvalidOperationException($"empty_id_in_{resourceName}_response"); + + return id; + } + catch (JsonException ex) + { + throw new InvalidOperationException($"invalid_json_in_{resourceName}_response", ex); + } + } + + /// + /// Returns the trimmed id field from a Nyx api-key creation response. Distinct from + /// because the legacy error code surface uses the + /// api_key_id_request_failed prefix specifically. + /// + public static string ExtractRequiredApiKeyId(string response) + { + if (LooksLikeErrorEnvelope(response)) + throw new InvalidOperationException($"api_key_id_request_failed {ExtractErrorDetail(response)}"); + + try + { + using var document = JsonDocument.Parse(response); + var root = document.RootElement; + if (!root.TryGetProperty("id", out var idElement) || idElement.ValueKind != JsonValueKind.String) + throw new InvalidOperationException("missing_id_in_api_key_id_response"); + + var id = idElement.GetString()?.Trim(); + if (string.IsNullOrWhiteSpace(id)) + throw new InvalidOperationException("empty_id_in_api_key_id_response"); + + return id; + } + catch (JsonException ex) + { + throw new InvalidOperationException($"invalid_json_in_api_key_id_response {ex.Message}", ex); + } + } + + /// + /// Returns true when the response either is unparseable or carries a top-level "error":true + /// envelope (the wrapping shape Nyx applies to non-2xx HTTP responses from the upstream platform). + /// + public static bool LooksLikeErrorEnvelope(string response) + { + if (string.IsNullOrWhiteSpace(response)) + return true; + + try + { + using var document = JsonDocument.Parse(response); + return document.RootElement.TryGetProperty("error", out var errorProp) && + errorProp.ValueKind == JsonValueKind.True; + } + catch (JsonException) + { + return true; + } + } + + /// + /// Builds a single-line diagnostic string from a Nyx error envelope, surfacing the + /// status, body, and message fields when present. + /// + public static string ExtractErrorDetail(string response) + { + if (string.IsNullOrWhiteSpace(response)) + return "empty_response"; + + try + { + using var document = JsonDocument.Parse(response); + var root = document.RootElement; + var status = root.TryGetProperty("status", out var statusElement) && statusElement.ValueKind == JsonValueKind.Number + ? statusElement.GetInt32().ToString() + : "unknown"; + var body = root.TryGetProperty("body", out var bodyElement) && bodyElement.ValueKind == JsonValueKind.String + ? bodyElement.GetString() + : string.Empty; + var message = root.TryGetProperty("message", out var messageElement) && messageElement.ValueKind == JsonValueKind.String + ? messageElement.GetString() + : string.Empty; + + return $"nyx_status={status}" + + (string.IsNullOrWhiteSpace(body) ? string.Empty : $" body={body}") + + (string.IsNullOrWhiteSpace(message) ? string.Empty : $" message={message}"); + } + catch (JsonException) + { + return "invalid_error_envelope"; + } + } + + /// + /// Best-effort delete of a Nyx resource during provisioning rollback. Logs both the + /// error-envelope and exception cases and never re-throws, so a failed rollback never + /// shadows the original provisioning failure that triggered it. + /// + public static async Task TryRollbackAsync( + Func> rollback, + string resourceType, + string resourceId, + ILogger logger) + { + try + { + var response = await rollback(); + if (LooksLikeErrorEnvelope(response)) + { + logger.LogWarning( + "Nyx rollback returned an error envelope: type={ResourceType}, id={ResourceId}, response={Response}", + resourceType, + resourceId, + response); + } + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Nyx rollback failed: type={ResourceType}, id={ResourceId}", + resourceType, + resourceId); + } + } + + /// + /// Returns a client-safe failure reason. instances + /// thrown by the helpers in this class carry controlled, structured error codes (e.g. + /// channel_bot_id_request_failed nyx_status=401 body=invalid app secret) so they are + /// safe to surface verbatim. Anything else (HTTP transport errors, generic exceptions) + /// collapses to provisioning_failed so endpoint paths, internal state, and stack + /// fragments do not leak through the registration response. Callers should still log the + /// full exception out-of-band for operational triage. + /// + public static string SanitizeFailureReason(Exception ex) => + ex is InvalidOperationException ? ex.Message : "provisioning_failed"; +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/NyxLarkProvisioningService.cs b/agents/Aevatar.GAgents.ChannelRuntime/NyxLarkProvisioningService.cs index 6e50f6d72..85179c1b3 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/NyxLarkProvisioningService.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/NyxLarkProvisioningService.cs @@ -205,30 +205,18 @@ await RegisterLocalMirrorAsync( routeId); if (!localMirrorAccepted && routeId is not null) - await TryRollbackAsync(() => _nyxClient.DeleteConversationRouteAsync(request.AccessToken, routeId, ct), "channel_route", routeId); + await NyxApiResponseHelper.TryRollbackAsync(() => _nyxClient.DeleteConversationRouteAsync(request.AccessToken, routeId, ct), "channel_route", routeId, _logger); if (!localMirrorAccepted && channelBotId is not null) - await TryRollbackAsync(() => _nyxClient.DeleteChannelBotAsync(request.AccessToken, channelBotId, ct), "channel_bot", channelBotId); + await NyxApiResponseHelper.TryRollbackAsync(() => _nyxClient.DeleteChannelBotAsync(request.AccessToken, channelBotId, ct), "channel_bot", channelBotId, _logger); if (!localMirrorAccepted && apiKeyId is not null) - await TryRollbackAsync(() => _nyxClient.DeleteApiKeyAsync(request.AccessToken, apiKeyId, ct), "api_key", apiKeyId); + await NyxApiResponseHelper.TryRollbackAsync(() => _nyxClient.DeleteApiKeyAsync(request.AccessToken, apiKeyId, ct), "api_key", apiKeyId, _logger); return Failure(localMirrorAccepted ? "local_mirror_accepted_remote_cleanup_skipped" - : SanitizeFailureReason(ex)); + : NyxApiResponseHelper.SanitizeFailureReason(ex)); } } - /// - /// Returns a client-safe failure reason. instances - /// thrown inside this service carry controlled, structured error codes (e.g. - /// channel_bot_id_request_failed nyx_status=401) so they are safe to surface verbatim. - /// Anything else (HTTP transport errors, JSON parser internals, generic exceptions) collapses - /// to provisioning_failed so we don't leak endpoint paths, internal state, or stack - /// fragments through the registration response. Operators get the full exception via the - /// LogWarning above. - /// - private static string SanitizeFailureReason(Exception ex) => - ex is InvalidOperationException ? ex.Message : "provisioning_failed"; - public async Task RepairLocalMirrorAsync(NyxLarkMirrorRepairRequest request, CancellationToken ct) { ArgumentNullException.ThrowIfNull(request); @@ -301,7 +289,7 @@ await RegisterLocalMirrorAsync( request.NyxAgentApiKeyId, request.NyxConversationRouteId); - return MirrorFailure(SanitizeFailureReason(ex)); + return MirrorFailure(NyxApiResponseHelper.SanitizeFailureReason(ex)); } } @@ -346,7 +334,7 @@ private async Task CreateRelayApiKeyAsync( }), ct); - return ExtractRequiredRelayApiKeyCredentials(response); + return new RelayApiKeyCredentials(NyxApiResponseHelper.ExtractRequiredApiKeyId(response)); } private async Task RegisterChannelBotAsync( @@ -374,7 +362,7 @@ private async Task RegisterChannelBotAsync( JsonSerializer.Serialize(payload), ct); - return ExtractRequiredId(response, "channel_bot_id"); + return NyxApiResponseHelper.ExtractRequiredId(response, "channel_bot_id"); } private async Task CreateDefaultRouteAsync( @@ -393,7 +381,7 @@ private async Task CreateDefaultRouteAsync( }), ct); - return ExtractRequiredId(response, "channel_route_id"); + return NyxApiResponseHelper.ExtractRequiredId(response, "channel_route_id"); } private async Task RegisterLocalMirrorAsync( @@ -432,8 +420,8 @@ private async Task GetConfirmedRelayApiKeyAsync( CancellationToken ct) { var response = await _nyxClient.GetApiKeyAsync(accessToken, apiKeyId, ct); - if (LooksLikeErrorEnvelope(response)) - throw new InvalidOperationException($"api_key_lookup_failed {ExtractErrorDetail(response)}"); + if (NyxApiResponseHelper.LooksLikeErrorEnvelope(response)) + throw new InvalidOperationException($"api_key_lookup_failed {NyxApiResponseHelper.ExtractErrorDetail(response)}"); try { @@ -461,8 +449,8 @@ private async Task GetConfirmedLarkChannelBotAsync( CancellationToken ct) { var response = await _nyxClient.GetChannelBotAsync(accessToken, channelBotId, ct); - if (LooksLikeErrorEnvelope(response)) - throw new InvalidOperationException($"channel_bot_lookup_failed {ExtractErrorDetail(response)}"); + if (NyxApiResponseHelper.LooksLikeErrorEnvelope(response)) + throw new InvalidOperationException($"channel_bot_lookup_failed {NyxApiResponseHelper.ExtractErrorDetail(response)}"); try { @@ -497,15 +485,15 @@ private async Task ResolveConfirmedConversationRoute if (!string.IsNullOrWhiteSpace(requestedRouteId)) { var response = await _nyxClient.GetConversationRouteAsync(accessToken, requestedRouteId, ct); - if (LooksLikeErrorEnvelope(response)) - throw new InvalidOperationException($"channel_route_lookup_failed {ExtractErrorDetail(response)}"); + if (NyxApiResponseHelper.LooksLikeErrorEnvelope(response)) + throw new InvalidOperationException($"channel_route_lookup_failed {NyxApiResponseHelper.ExtractErrorDetail(response)}"); return ParseConfirmedConversationRoute(response, expectedChannelBotId, expectedApiKeyId, "channel_route_lookup"); } var listResponse = await _nyxClient.ListConversationRoutesAsync(accessToken, expectedChannelBotId, ct); - if (LooksLikeErrorEnvelope(listResponse)) - throw new InvalidOperationException($"channel_route_list_failed {ExtractErrorDetail(listResponse)}"); + if (NyxApiResponseHelper.LooksLikeErrorEnvelope(listResponse)) + throw new InvalidOperationException($"channel_route_list_failed {NyxApiResponseHelper.ExtractErrorDetail(listResponse)}"); var matches = ParseConversationRoutes(listResponse) .Where(route => @@ -650,124 +638,6 @@ private static string ExtractRequiredString(JsonElement element, string property private static string NormalizeUrl(string value) => value.Trim().TrimEnd('/'); - private async Task TryRollbackAsync(Func> rollback, string resourceType, string resourceId) - { - try - { - var response = await rollback(); - if (LooksLikeErrorEnvelope(response)) - { - _logger.LogWarning( - "Nyx rollback returned an error envelope: type={ResourceType}, id={ResourceId}, response={Response}", - resourceType, - resourceId, - response); - } - } - catch (Exception ex) - { - _logger.LogWarning( - ex, - "Nyx rollback failed: type={ResourceType}, id={ResourceId}", - resourceType, - resourceId); - } - } - - private static string ExtractRequiredId(string response, string resourceName) - { - if (LooksLikeErrorEnvelope(response)) - throw new InvalidOperationException($"{resourceName}_request_failed {ExtractErrorDetail(response)}"); - - try - { - using var document = JsonDocument.Parse(response); - var root = document.RootElement; - if (!root.TryGetProperty("id", out var idElement) || idElement.ValueKind != JsonValueKind.String) - throw new InvalidOperationException($"missing_id_in_{resourceName}_response"); - - var id = idElement.GetString()?.Trim(); - if (string.IsNullOrWhiteSpace(id)) - throw new InvalidOperationException($"empty_id_in_{resourceName}_response"); - - return id; - } - catch (JsonException ex) - { - throw new InvalidOperationException($"invalid_json_in_{resourceName}_response", ex); - } - } - - private static RelayApiKeyCredentials ExtractRequiredRelayApiKeyCredentials(string response) - { - if (LooksLikeErrorEnvelope(response)) - throw new InvalidOperationException($"api_key_id_request_failed {ExtractErrorDetail(response)}"); - - try - { - using var document = JsonDocument.Parse(response); - var root = document.RootElement; - if (!root.TryGetProperty("id", out var idElement) || idElement.ValueKind != JsonValueKind.String) - throw new InvalidOperationException("missing_id_in_api_key_id_response"); - - var id = idElement.GetString()?.Trim(); - if (string.IsNullOrWhiteSpace(id)) - throw new InvalidOperationException("empty_id_in_api_key_id_response"); - - return new RelayApiKeyCredentials(id); - } - catch (JsonException ex) - { - throw new InvalidOperationException($"invalid_json_in_api_key_id_response {ex.Message}", ex); - } - } - - private static bool LooksLikeErrorEnvelope(string response) - { - if (string.IsNullOrWhiteSpace(response)) - return true; - - try - { - using var document = JsonDocument.Parse(response); - return document.RootElement.TryGetProperty("error", out var errorProp) && - errorProp.ValueKind == JsonValueKind.True; - } - catch (JsonException) - { - return true; - } - } - - private static string ExtractErrorDetail(string response) - { - if (string.IsNullOrWhiteSpace(response)) - return "empty_response"; - - try - { - using var document = JsonDocument.Parse(response); - var root = document.RootElement; - var status = root.TryGetProperty("status", out var statusElement) && statusElement.ValueKind == JsonValueKind.Number - ? statusElement.GetInt32().ToString() - : "unknown"; - var body = root.TryGetProperty("body", out var bodyElement) && bodyElement.ValueKind == JsonValueKind.String - ? bodyElement.GetString() - : string.Empty; - var message = root.TryGetProperty("message", out var messageElement) && messageElement.ValueKind == JsonValueKind.String - ? messageElement.GetString() - : string.Empty; - - return $"nyx_status={status}" + - (string.IsNullOrWhiteSpace(body) ? string.Empty : $" body={body}") + - (string.IsNullOrWhiteSpace(message) ? string.Empty : $" message={message}"); - } - catch (JsonException) - { - return "invalid_error_envelope"; - } - } - private static NyxLarkProvisioningResult Failure(string error) => new( Succeeded: false, diff --git a/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs b/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs index 7d6d663e3..357363cf6 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs @@ -138,30 +138,18 @@ await RegisterLocalMirrorAsync( routeId); if (!localMirrorAccepted && routeId is not null) - await TryRollbackAsync(() => _nyxClient.DeleteConversationRouteAsync(request.AccessToken, routeId, ct), "channel_route", routeId); + await NyxApiResponseHelper.TryRollbackAsync(() => _nyxClient.DeleteConversationRouteAsync(request.AccessToken, routeId, ct), "channel_route", routeId, _logger); if (!localMirrorAccepted && channelBotId is not null) - await TryRollbackAsync(() => _nyxClient.DeleteChannelBotAsync(request.AccessToken, channelBotId, ct), "channel_bot", channelBotId); + await NyxApiResponseHelper.TryRollbackAsync(() => _nyxClient.DeleteChannelBotAsync(request.AccessToken, channelBotId, ct), "channel_bot", channelBotId, _logger); if (!localMirrorAccepted && apiKeyId is not null) - await TryRollbackAsync(() => _nyxClient.DeleteApiKeyAsync(request.AccessToken, apiKeyId, ct), "api_key", apiKeyId); + await NyxApiResponseHelper.TryRollbackAsync(() => _nyxClient.DeleteApiKeyAsync(request.AccessToken, apiKeyId, ct), "api_key", apiKeyId, _logger); return Failure(localMirrorAccepted ? "local_mirror_accepted_remote_cleanup_skipped" - : SanitizeFailureReason(ex)); + : NyxApiResponseHelper.SanitizeFailureReason(ex)); } } - /// - /// Returns a client-safe failure reason. instances - /// thrown inside this service carry controlled, structured error codes (e.g. - /// channel_bot_id_request_failed nyx_status=401) so they are safe to surface verbatim. - /// Anything else (HTTP transport errors, JSON parser internals, generic exceptions) collapses - /// to provisioning_failed so we don't leak endpoint paths, internal state, or stack - /// fragments through the registration response. Operators get the full exception via the - /// LogWarning above. - /// - private static string SanitizeFailureReason(Exception ex) => - ex is InvalidOperationException ? ex.Message : "provisioning_failed"; - async Task INyxChannelBotProvisioningService.ProvisionAsync( NyxChannelBotProvisioningRequest request, CancellationToken ct) @@ -218,7 +206,7 @@ private async Task CreateRelayApiKeyAsync( }), ct); - return ExtractRequiredRelayApiKeyCredentials(response); + return new RelayApiKeyCredentials(NyxApiResponseHelper.ExtractRequiredApiKeyId(response)); } private async Task RegisterChannelBotAsync( @@ -239,7 +227,7 @@ private async Task RegisterChannelBotAsync( JsonSerializer.Serialize(payload), ct); - return ExtractRequiredId(response, "channel_bot_id"); + return NyxApiResponseHelper.ExtractRequiredId(response, "channel_bot_id"); } private async Task CreateDefaultRouteAsync( @@ -258,7 +246,7 @@ private async Task CreateDefaultRouteAsync( }), ct); - return ExtractRequiredId(response, "channel_route_id"); + return NyxApiResponseHelper.ExtractRequiredId(response, "channel_route_id"); } private async Task RegisterLocalMirrorAsync( @@ -290,124 +278,6 @@ await ChannelBotRegistrationStoreCommands.DispatchRegisterAsync( ct); } - private async Task TryRollbackAsync(Func> rollback, string resourceType, string resourceId) - { - try - { - var response = await rollback(); - if (LooksLikeErrorEnvelope(response)) - { - _logger.LogWarning( - "Nyx rollback returned an error envelope: type={ResourceType}, id={ResourceId}, response={Response}", - resourceType, - resourceId, - response); - } - } - catch (Exception ex) - { - _logger.LogWarning( - ex, - "Nyx rollback failed: type={ResourceType}, id={ResourceId}", - resourceType, - resourceId); - } - } - - private static string ExtractRequiredId(string response, string resourceName) - { - if (LooksLikeErrorEnvelope(response)) - throw new InvalidOperationException($"{resourceName}_request_failed {ExtractErrorDetail(response)}"); - - try - { - using var document = JsonDocument.Parse(response); - var root = document.RootElement; - if (!root.TryGetProperty("id", out var idElement) || idElement.ValueKind != JsonValueKind.String) - throw new InvalidOperationException($"missing_id_in_{resourceName}_response"); - - var id = idElement.GetString()?.Trim(); - if (string.IsNullOrWhiteSpace(id)) - throw new InvalidOperationException($"empty_id_in_{resourceName}_response"); - - return id; - } - catch (JsonException ex) - { - throw new InvalidOperationException($"invalid_json_in_{resourceName}_response", ex); - } - } - - private static RelayApiKeyCredentials ExtractRequiredRelayApiKeyCredentials(string response) - { - if (LooksLikeErrorEnvelope(response)) - throw new InvalidOperationException($"api_key_id_request_failed {ExtractErrorDetail(response)}"); - - try - { - using var document = JsonDocument.Parse(response); - var root = document.RootElement; - if (!root.TryGetProperty("id", out var idElement) || idElement.ValueKind != JsonValueKind.String) - throw new InvalidOperationException("missing_id_in_api_key_id_response"); - - var id = idElement.GetString()?.Trim(); - if (string.IsNullOrWhiteSpace(id)) - throw new InvalidOperationException("empty_id_in_api_key_id_response"); - - return new RelayApiKeyCredentials(id); - } - catch (JsonException ex) - { - throw new InvalidOperationException($"invalid_json_in_api_key_id_response {ex.Message}", ex); - } - } - - private static bool LooksLikeErrorEnvelope(string response) - { - if (string.IsNullOrWhiteSpace(response)) - return true; - - try - { - using var document = JsonDocument.Parse(response); - return document.RootElement.TryGetProperty("error", out var errorProp) && - errorProp.ValueKind == JsonValueKind.True; - } - catch (JsonException) - { - return true; - } - } - - private static string ExtractErrorDetail(string response) - { - if (string.IsNullOrWhiteSpace(response)) - return "empty_response"; - - try - { - using var document = JsonDocument.Parse(response); - var root = document.RootElement; - var status = root.TryGetProperty("status", out var statusElement) && statusElement.ValueKind == JsonValueKind.Number - ? statusElement.GetInt32().ToString() - : "unknown"; - var body = root.TryGetProperty("body", out var bodyElement) && bodyElement.ValueKind == JsonValueKind.String - ? bodyElement.GetString() - : string.Empty; - var message = root.TryGetProperty("message", out var messageElement) && messageElement.ValueKind == JsonValueKind.String - ? messageElement.GetString() - : string.Empty; - - return $"nyx_status={status}" + - (string.IsNullOrWhiteSpace(body) ? string.Empty : $" body={body}") + - (string.IsNullOrWhiteSpace(message) ? string.Empty : $" message={message}"); - } - catch (JsonException) - { - return "invalid_error_envelope"; - } - } - private static NyxTelegramProvisioningResult Failure(string error) => new( Succeeded: false, diff --git a/src/Aevatar.AI.ToolProviders.Telegram/TelegramNyxClient.cs b/src/Aevatar.AI.ToolProviders.Telegram/TelegramNyxClient.cs index 2c977b174..5ba8f07fc 100644 --- a/src/Aevatar.AI.ToolProviders.Telegram/TelegramNyxClient.cs +++ b/src/Aevatar.AI.ToolProviders.Telegram/TelegramNyxClient.cs @@ -64,17 +64,17 @@ public Task SendMessageAsync(string token, TelegramSendMessageRequest re public Task GetChatAsync(string token, TelegramGetChatRequest request, CancellationToken ct) { - var body = JsonSerializer.Serialize(new + var body = new JsonObject { - chat_id = request.ChatId, - }); + ["chat_id"] = request.ChatId, + }; return _nyxClient.ProxyRequestAsync( token, _options.ProviderSlug, "getChat", "POST", - body, + body.ToJsonString(JsonOptions), extraHeaders: null, ct); } diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStoreTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStoreTests.cs index b8efb0ae9..eef785a6e 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStoreTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelBotRegistrationStoreTests.cs @@ -68,13 +68,42 @@ await _agent.HandleRegister(new ChannelBotRegisterCommand } [Fact] - public async Task HandleRegister_IgnoresRetiredDirectCallbackPlatforms() + public async Task HandleRegister_PersistsTelegramRelayRegistration() { await _agent.HandleRegister(new ChannelBotRegisterCommand { Platform = "telegram", NyxProviderSlug = "api-telegram-bot", + ScopeId = "scope-1", + WebhookUrl = "https://nyx.example.com/api/v1/webhooks/channel/telegram/bot-tg-1", RequestedId = "reg-telegram", + NyxChannelBotId = "bot-tg-1", + NyxAgentApiKeyId = "key-tg-1", + NyxConversationRouteId = "route-tg-1", + }); + + _agent.State.Registrations.Should().ContainSingle(); + var entry = _agent.State.Registrations[0]; + entry.Id.Should().Be("reg-telegram"); + entry.Platform.Should().Be("telegram"); + entry.NyxProviderSlug.Should().Be("api-telegram-bot"); + entry.ScopeId.Should().Be("scope-1"); + entry.WebhookUrl.Should().Contain("/api/v1/webhooks/channel/telegram/"); + entry.NyxChannelBotId.Should().Be("bot-tg-1"); + entry.NyxAgentApiKeyId.Should().Be("key-tg-1"); + entry.NyxConversationRouteId.Should().Be("route-tg-1"); + entry.Tombstoned.Should().BeFalse(); + } + + [Fact] + public async Task HandleRegister_IgnoresUnsupportedPlatforms() + { + await _agent.HandleRegister(new ChannelBotRegisterCommand + { + Platform = "discord", + NyxProviderSlug = "api-discord-bot", + ScopeId = "scope-1", + RequestedId = "reg-discord", }); _agent.State.Registrations.Should().BeEmpty(); From 0e17b3e9065844a0d422da0f6197b575a30efd78 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 27 Apr 2026 13:57:04 +0800 Subject: [PATCH 13/15] Align Telegram adapter with NyxID relay reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five eanzhao review threads on PR #289 traced back to the same root cause: the Telegram composer / provisioning note / canonical doc / host wiring all assumed contracts the NyxID-side Telegram channel adapter does not actually implement. Verified each claim against `~/Code/NyxID/backend/src/services/channel_adapters/telegram.rs` and fixed: - TelegramMessageComposer.DefaultCapabilities.SupportsActionButtons set to false. NyxID's `register_webhook` only subscribes to message / edited_message / channel_post — `callback_query` is not in `allowed_updates`, and `parse_inbound` returns empty for callback queries. So inline_keyboard click-back never round-trips to Aevatar. Compose now degrades intent.Actions into a plain-text bullet list of labels; Evaluate flags any actions as Degraded so callers know the click path is unavailable. - BuildRenderedText now applies Telegram legacy-Markdown escaping to `_`, `*`, `[`, `` ` ``. NyxID's `send_reply` sends every relay reply with `parse_mode="Markdown"`, so unescaped model output containing those characters would either turn into formatting or trip a 400 "can't parse entities" rejection. Add a regression test that asserts the four control characters get backslash-escaped. - NyxTelegramProvisioningService.Note + the cutover runbook step 4 no longer tell operators to call `setWebhook` themselves. NyxID's `POST /api/v1/channel-bots` already calls `setWebhook` server-side using a NyxID-managed `secret_token`; manual setWebhook overwrites that secret and breaks `x-telegram-bot-api-secret-token` verification. Note + runbook now say so explicitly. - src/Aevatar.Mainnet.Host.Api now project-references Aevatar.AI.ToolProviders.Telegram and calls AddTelegramTools alongside AddLarkTools so `telegram_messages_send` / `telegram_chats_lookup` actually enter the IAgentToolSource discovery chain in production. Extend MainnetHostCompositionTests to assert both LarkAgentToolSource and TelegramAgentToolSource resolve from the live container so a future regression in the host wiring surfaces immediately. - docs/canon/aevatar-channel-architecture.md §10.2 rewritten from the retired direct-adapter description (webhook + long-poll fallback + credential snapshot) to the actual relay-only path: NyxIdRelay is the sole transport, Platform.Telegram is composer/redactor only, NyxID owns Bot API credentials and webhook registration, action buttons are intentionally degraded today. Updated tests for composer behavior changes (15 Platform.Telegram tests pass). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../NyxTelegramProvisioningService.cs | 2 +- .../TelegramMessageComposer.cs | 141 ++++++++---------- docs/canon/aevatar-channel-architecture.md | 18 ++- ...2026-04-27-telegram-nyx-cutover-runbook.md | 39 +++-- .../Aevatar.Mainnet.Host.Api.csproj | 1 + .../Hosting/MainnetHostBuilderExtensions.cs | 5 + ...legramChannelNativeMessageProducerTests.cs | 19 ++- .../TelegramMessageComposerTests.cs | 85 ++++++----- .../MainnetHostCompositionTests.cs | 11 ++ 9 files changed, 172 insertions(+), 149 deletions(-) diff --git a/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs b/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs index 357363cf6..3ece4fe25 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs @@ -125,7 +125,7 @@ await RegisterLocalMirrorAsync( NyxConversationRouteId: routeId, RelayCallbackUrl: relayCallbackUrl, WebhookUrl: webhookUrl, - Note: "Provisioning completed in Nyx and the local mirror command was accepted. Configure the Telegram bot webhook URL via setWebhook to point at Nyx; local read model visibility is asynchronous."); + Note: "Provisioning completed in Nyx and the local mirror command was accepted. NyxID has already registered the Telegram webhook and secret_token with the Bot API; do not call setWebhook manually or you will overwrite NyxID's secret_token and break inbound verification. Local read model visibility is asynchronous."); } catch (Exception ex) { diff --git a/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramMessageComposer.cs b/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramMessageComposer.cs index 91e02aa66..6dc6f4b38 100644 --- a/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramMessageComposer.cs +++ b/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramMessageComposer.cs @@ -9,7 +9,6 @@ public sealed class TelegramMessageComposer : IMessageComposer Compose(intent, context); @@ -91,7 +66,9 @@ public ComposeCapability Evaluate(MessageContent intent, ComposeContext context) degraded = true; if (intent.Attachments.Count > 0 && !capabilities.SupportsFiles) return ComposeCapability.Unsupported; - if (intent.Actions.Count > 0 && !capabilities.SupportsActionButtons) + // Actions can only be expressed as text labels for Telegram today (see DefaultCapabilities + // comment) — flag as degraded so callers know the click-back path is unavailable. + if (intent.Actions.Count > 0) degraded = true; var maxLength = ResolveTextLimit(capabilities.MaxMessageLength, TelegramTextLimit); @@ -101,47 +78,6 @@ public ComposeCapability Evaluate(MessageContent intent, ComposeContext context) return degraded ? ComposeCapability.Degraded : ComposeCapability.Exact; } - private static object[][]? BuildInlineKeyboard(IEnumerable actions) - { - var rows = actions - .Where(static action => action.Kind == ActionElementKind.Button && !string.IsNullOrWhiteSpace(action.Label)) - .Select(static action => new object[] - { - new - { - text = action.Label, - callback_data = BuildCallbackData(action), - }, - }) - .ToArray(); - - return rows.Length == 0 ? null : rows; - } - - private static string BuildCallbackData(ActionElement action) - { - var raw = !string.IsNullOrWhiteSpace(action.Value) - ? action.Value - : !string.IsNullOrWhiteSpace(action.ActionId) - ? action.ActionId - : action.Label; - if (Encoding.UTF8.GetByteCount(raw) <= CallbackDataMaxBytes) - return raw; - - var textInfo = new StringInfo(raw); - var builder = new StringBuilder(); - for (var i = 0; i < textInfo.LengthInTextElements; i++) - { - var next = builder.ToString() + textInfo.SubstringByTextElements(i, 1); - if (Encoding.UTF8.GetByteCount(next) > CallbackDataMaxBytes) - break; - - builder.Append(textInfo.SubstringByTextElements(i, 1)); - } - - return builder.ToString(); - } - private static string BuildRenderedText(MessageContent intent, int maxLength) { var builder = new StringBuilder(); @@ -155,13 +91,31 @@ private static string BuildRenderedText(MessageContent intent, int maxLength) AppendParagraph(builder, $"{field.Title}: {field.Text}"); } - var text = builder.ToString().Trim(); + // Render available action labels as a bullet list so the user can still see what + // the agent intended to offer, even though clicks cannot round-trip through the + // current Nyx Telegram relay contract. + var buttonActions = intent.Actions + .Where(static action => action.Kind == ActionElementKind.Button && !string.IsNullOrWhiteSpace(action.Label)) + .Select(static action => $"• {action.Label.Trim()}") + .ToArray(); + if (buttonActions.Length > 0) + { + if (builder.Length > 0) + builder.AppendLine().AppendLine(); + builder.Append(string.Join("\n", buttonActions)); + } + + // NyxID's Telegram relay sends every reply with parse_mode="Markdown" + // (telegram.rs::send_reply). Escape Telegram's legacy-Markdown control characters so + // ordinary model output containing _ * [ ` does not turn into half-formatted text or, + // worse, a "can't parse entities" 400 that breaks the entire reply. + var escaped = EscapeLegacyMarkdown(builder.ToString().Trim()); if (maxLength <= 0) - return text; + return escaped; - var textInfo = new StringInfo(text); + var textInfo = new StringInfo(escaped); if (textInfo.LengthInTextElements <= maxLength) - return text; + return escaped; return textInfo.SubstringByTextElements(0, maxLength); } @@ -176,6 +130,33 @@ private static void AppendParagraph(StringBuilder builder, string? value) builder.Append(value.Trim()); } + /// + /// Escapes the four characters Telegram's legacy parse_mode=Markdown uses as control + /// tokens (_, *, [, `) by prefixing each with a backslash so + /// arbitrary model output never accidentally enters bold / italic / link / code mode and + /// never trips the Bot API's can't parse entities rejection. + /// + /// + /// MarkdownV2 would require escaping a much larger set, but NyxID's relay sends + /// parse_mode=Markdown (legacy), so only this minimal set is needed and the escape + /// stays human-readable. + /// + private static string EscapeLegacyMarkdown(string text) + { + if (string.IsNullOrEmpty(text)) + return text; + + var builder = new StringBuilder(text.Length); + foreach (var ch in text) + { + if (ch is '_' or '*' or '[' or '`') + builder.Append('\\'); + builder.Append(ch); + } + + return builder.ToString(); + } + private static int ResolveTextLimit(int configuredMax, int fallback) => configuredMax > 0 ? Math.Min(configuredMax, fallback) : fallback; } diff --git a/docs/canon/aevatar-channel-architecture.md b/docs/canon/aevatar-channel-architecture.md index 2057ddc15..54e69033d 100644 --- a/docs/canon/aevatar-channel-architecture.md +++ b/docs/canon/aevatar-channel-architecture.md @@ -1905,14 +1905,16 @@ Lark webhook / long-connection / gateway 这类 ingress concern 属于 `Channel. ### 10.2 Telegram(`agents/platforms/Aevatar.GAgents.Platform.Telegram`) -- **底层传输**:直接调用 Telegram Bot API HTTP endpoint;adapter 内只保留轻量 DTO / composer / redactor / credential snapshot,不再包一层专用 SDK 客户端 -- **Transport**:Webhook 优先;long-polling 作为 fallback(开发模式) -- **迁移**: - - 历史 runtime 中存在 `TelegramPlatformAdapter`;issue `#308` 已移除该适配器 - - 若未来恢复 Telegram 生产支持,transport/outbound 契约应以 `TelegramChannelAdapter` 这类 channel-scoped 命名落地,而不是恢复旧 callback-owner 适配器 - - `TelegramMessageComposer`:intent → text + inline keyboard - - Group / supergroup / channel 的区分 -- **Capability gap**:不支持 ephemeral / thread / confirm dialog / modal +- **底层传输**:和 Lark 一样走 ADR-0013 统一通道——唯一生产 transport 是 `agents/channels/Aevatar.GAgents.Channel.NyxIdRelay`。Aevatar 不直接持有 bot token、不直接调用 Telegram Bot API、不暴露 Telegram webhook;所有 ingress / outbound 都经过 NyxID 中继。`Aevatar.GAgents.Platform.Telegram` 只提供 composer / native message producer / payload redactor,没有 transport / credential / SDK 包装代码。 +- **Transport**:N/A —— 见上一条。issue `#262` 早期原型曾经在 `Aevatar.GAgents.Channel.Telegram` 提交了 webhook + long-polling + credential snapshot 的 direct adapter,已在合并前删除;任何"恢复 Telegram 生产支持"的工作都不应该回到那条路径。 +- **Inbound conversation type 映射**:通过 `NyxIdRelayConversationTypeMap` 把 NyxID 投递的 `private / group / supergroup / channel` 映射到 `ConversationScope.DirectMessage / Group / Group / Channel`。`message_thread_id`(forum topic)当前未建模。 +- **Outbound 渲染(`TelegramMessageComposer`)**: + - `intent.Text` + `intent.Cards` 拼成单段纯文本,再做 Telegram **legacy Markdown** 转义(NyxID 中继发送时强制 `parse_mode=Markdown`,详见 NyxID `backend/src/services/channel_adapters/telegram.rs::send_reply`)。escape 集合 = `_`、`*`、`[`、`` ` ``,对应 Telegram legacy Markdown 的四个控制字符;不做 MarkdownV2 escaping,避免给后续可能切换到纯 plain 的 NyxID 留 backward-compat 包袱。 + - `intent.Actions` 当前**降级为纯文本 bullet 列表**,不再发 `inline_keyboard`。原因:NyxID Telegram adapter 的 `register_webhook` 只订阅 `message` / `edited_message` / `channel_post`,没有订阅 `callback_query`;`parse_inbound` 对 callback query 的测试显式断言返回空。也就是说即使 Aevatar 发出 `inline_keyboard`,按钮点击也不会进入 Aevatar,不会被翻译成 `CardActionSubmission`。`DefaultCapabilities.SupportsActionButtons = false` 把这个事实诚实暴露给 caller。 + - 如果未来 NyxID 把 `callback_query` 全链路接通,把 `SupportsActionButtons` 翻回 `true` 并恢复 `inline_keyboard` 输出 + `BuildCallbackData` 的 64-byte 截断逻辑;同时更新 runbook。 +- **凭据与 webhook 注册**:bot token 只在注册接口入参里出现,不本地持久化(ADR-0012)。NyxID 的 `POST /api/v1/channel-bots` 在创建 channel bot 的同时已经替运营调用了 Telegram `setWebhook`,并使用 NyxID 自己生成/保存的 `secret_token`;运营**不要**手动再 `setWebhook`,否则会覆盖该 secret 并触发 `x-telegram-bot-api-secret-token` 校验失败。 +- **工具支持**:`src/Aevatar.AI.ToolProviders.Telegram` 提供 `telegram_messages_send`(Bot API `sendMessage`)和 `telegram_chats_lookup`(Bot API `getChat`),通过 NyxID `api-telegram-bot` 代理槽位调用。`AddTelegramTools` 已在 `MainnetHostBuilderExtensions` 注册,运行时通过 `IAgentToolSource` 发现。 +- **Capability gap**:当前不支持 ephemeral / thread / confirm dialog / modal / file 附件 / action button click-back。 ### 10.3 Slack(`agents/platforms/Aevatar.GAgents.Platform.Slack`) diff --git a/docs/operations/2026-04-27-telegram-nyx-cutover-runbook.md b/docs/operations/2026-04-27-telegram-nyx-cutover-runbook.md index 6732b6de1..d190a48d5 100644 --- a/docs/operations/2026-04-27-telegram-nyx-cutover-runbook.md +++ b/docs/operations/2026-04-27-telegram-nyx-cutover-runbook.md @@ -80,22 +80,25 @@ The endpoint returns the standard provisioning payload: Lark and Telegram — and that `IChannelMessageComposerRegistry.Get(ChannelId.From("telegram"))` resolves to `TelegramMessageComposer`). 3. Provision the Telegram bot through the registration endpoint (either body shape). -4. Call Telegram's `setWebhook` with the returned Nyx `webhook_url`. Recommended - parameters: - - `url` = `webhook_url` - - `allowed_updates` = `["message","edited_message","callback_query","channel_post"]` - - `secret_token` = whatever Nyx documents for HMAC validation on its side -5. Observe: + NyxID's `POST /api/v1/channel-bots` already calls Telegram's `setWebhook` server-side + during this step using a NyxID-managed `secret_token`; **do not call `setWebhook` + yourself** — overwriting NyxID's secret breaks `x-telegram-bot-api-secret-token` + verification and may also drop `allowed_updates` types Aevatar expects. +4. Observe: - Nyx -> Aevatar relay callback success on `/api/webhooks/nyxid-relay` for inbound - Telegram messages - - Aevatar -> Nyx `channel-relay/reply` success for outbound replies + Telegram messages (NyxID currently subscribes to `message`, `edited_message`, + `channel_post` only — `callback_query` button clicks do not round-trip yet, so + the Telegram composer degrades action buttons to a plain-text bullet list) + - Aevatar -> Nyx `channel-relay/reply` success for outbound replies (NyxID sends + these with `parse_mode="Markdown"`; the composer escapes `_`, `*`, `[`, `` ` `` + so model output cannot accidentally trip `can't parse entities`) - Optional: agent-tool calls `telegram_messages_send` / `telegram_chats_lookup` succeed against `api-telegram-bot` -6. If you need to rotate the bot token: +5. If you need to rotate the bot token: - Issue a new token through `@BotFather` (`/revoke` then `/token`). - - Re-provision through the registration endpoint with the new token; this creates a - new `nyx_channel_bot_id`. - - Re-run `setWebhook` against the new Nyx `webhook_url`. + - Re-provision through the registration endpoint with the new token; this creates + a new `nyx_channel_bot_id` and triggers NyxID to re-register the webhook. + - Do **not** call `setWebhook` manually as part of rotation either. ## Manual Cleanup On Partial Provisioning Failure @@ -131,12 +134,16 @@ because Aevatar never persisted the bot token. which dispatches via `TelegramChannelNativeMessageProducer` -> Nyx `channel-relay/reply` -> Telegram `sendMessage`. - Cards in agent intents degrade into the rendered text body for Telegram (no native - card UI). Action buttons render as a single-row `inline_keyboard` with - `callback_data` truncated to the Bot API 64-byte limit; submissions arrive as - `CardActionSubmission` activities exactly like Lark `card.action.trigger`. + card UI). Action buttons also degrade into a plain-text bullet list of labels + rather than `inline_keyboard` callback buttons: NyxID's Telegram channel adapter + does not subscribe to `callback_query` updates today, so any `inline_keyboard` + click would never round-trip back to Aevatar. The composer's + `SupportsActionButtons=false` advertises this honestly so callers can plan around + it; once NyxID grows the `callback_query` subscribe + parse + forward contract + end-to-end, flip this back and revisit the runbook. - Aevatar persists no Telegram bot tokens. The token only exists in transit through the registration endpoint; revocation/rotation is handled at Telegram + - re-provisioning time as documented in step 6. + re-provisioning time as documented in step 5. - Telegram tools (`telegram_messages_send`, `telegram_chats_lookup`) require a per-call NyxID access token in the request metadata; without it they return `success=false, error="No NyxID access token available"` rather than calling Nyx. diff --git a/src/Aevatar.Mainnet.Host.Api/Aevatar.Mainnet.Host.Api.csproj b/src/Aevatar.Mainnet.Host.Api/Aevatar.Mainnet.Host.Api.csproj index 099045652..61bcf49a2 100644 --- a/src/Aevatar.Mainnet.Host.Api/Aevatar.Mainnet.Host.Api.csproj +++ b/src/Aevatar.Mainnet.Host.Api/Aevatar.Mainnet.Host.Api.csproj @@ -18,6 +18,7 @@ + diff --git a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs index 1c96e8b32..91076e33e 100644 --- a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs +++ b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs @@ -1,6 +1,7 @@ using Aevatar.AI.ToolProviders.ChronoStorage; using Aevatar.AI.ToolProviders.Lark; using Aevatar.AI.ToolProviders.NyxId; +using Aevatar.AI.ToolProviders.Telegram; using Aevatar.Authentication.Hosting; using Aevatar.Authentication.Providers.NyxId; using Aevatar.Bootstrap.Hosting; @@ -69,6 +70,10 @@ public static WebApplicationBuilder AddAevatarMainnetHost( { o.ProviderSlug = builder.Configuration["Aevatar:Lark:NyxProviderSlug"] ?? "api-lark-bot"; }); + builder.Services.AddTelegramTools(o => + { + o.ProviderSlug = builder.Configuration["Aevatar:Telegram:NyxProviderSlug"] ?? "api-telegram-bot"; + }); builder.Services.AddChronoStorageTools(o => { // Self-referencing: the explorer endpoints are served by this same host. diff --git a/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramChannelNativeMessageProducerTests.cs b/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramChannelNativeMessageProducerTests.cs index 0c42f0561..811350453 100644 --- a/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramChannelNativeMessageProducerTests.cs +++ b/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramChannelNativeMessageProducerTests.cs @@ -22,8 +22,11 @@ public void Produce_for_text_only_intent_returns_text_native() } [Fact] - public void Produce_for_button_intent_returns_interactive_card_payload() + public void Produce_for_button_intent_degrades_to_text_native() { + // Composer degrades action buttons to a plain-text bullet list because NyxID's Telegram + // relay does not subscribe to callback_query updates, so the producer should not surface + // a CardPayload for that intent. var producer = new TelegramChannelNativeMessageProducer(new TelegramMessageComposer()); var intent = new MessageContent { Text = "Choose" }; intent.Actions.Add(new ActionElement @@ -36,13 +39,13 @@ public void Produce_for_button_intent_returns_interactive_card_payload() var native = producer.Produce(intent, BuildContext()); - native.IsInteractive.ShouldBeTrue(); - native.CardPayload.ShouldNotBeNull(); - native.MessageType.ShouldBe("interactive"); - native.CardPayload.ShouldBeOfType(); - var cardJson = JsonSerializer.Serialize(native.CardPayload); - cardJson.ShouldContain("inline_keyboard"); - cardJson.ShouldContain("Confirm"); + native.IsInteractive.ShouldBeFalse(); + native.CardPayload.ShouldBeNull(); + native.MessageType.ShouldBe("text"); + native.Text.ShouldNotBeNull(); + native.Text!.ShouldContain("Choose"); + native.Text.ShouldContain("Confirm"); + native.Capability.ShouldBe(ComposeCapability.Degraded); } [Fact] diff --git a/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramMessageComposerTests.cs b/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramMessageComposerTests.cs index 080adbca1..f9fea0ff8 100644 --- a/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramMessageComposerTests.cs +++ b/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramMessageComposerTests.cs @@ -23,19 +23,15 @@ protected override void AssertSimpleTextPayload(object payload, MessageContent i protected override void AssertActionsPayload(object payload, MessageContent intent, ComposeContext context, ComposeCapability capability) { + // NyxID's Telegram relay does not subscribe to callback_query updates today, so the + // composer degrades action buttons into a plain-text bullet list of labels rather + // than emitting an inline_keyboard click-back surface that would never round-trip. var native = payload.ShouldBeOfType(); - if (capability == ComposeCapability.Degraded && !context.Capabilities!.SupportsActionButtons) - { - native.MessageType.ShouldBe("text"); - native.IsInteractive.ShouldBeFalse(); - return; - } - - native.MessageType.ShouldBe("interactive"); - native.IsInteractive.ShouldBeTrue(); - native.ContentJson.ShouldContain("Confirm"); - native.ContentJson.ShouldContain("Cancel"); - native.ContentJson.ShouldContain("inline_keyboard"); + native.MessageType.ShouldBe("text"); + native.IsInteractive.ShouldBeFalse(); + native.PlainText.ShouldContain("Confirm"); + native.PlainText.ShouldContain("Cancel"); + native.ContentJson.ShouldNotContain("inline_keyboard"); } protected override void AssertCardPayload(object payload, MessageContent intent, ComposeContext context, ComposeCapability capability) @@ -66,7 +62,7 @@ public void Compose_text_only_intent_emits_text_message_type() } [Fact] - public void Compose_with_button_intent_emits_inline_keyboard_payload() + public void Compose_with_button_intent_degrades_buttons_to_plain_text_bullets() { var intent = new MessageContent { @@ -79,41 +75,52 @@ public void Compose_with_button_intent_emits_inline_keyboard_payload() Label = "Confirm", Value = "yes", }); + intent.Actions.Add(new ActionElement + { + Kind = ActionElementKind.Button, + ActionId = "cancel", + Label = "Cancel", + }); var payload = CreateComposer().Compose(intent, BuildContext()); - payload.MessageType.ShouldBe("interactive"); - payload.IsInteractive.ShouldBeTrue(); - using var document = JsonDocument.Parse(payload.ContentJson); - var rows = document.RootElement.GetProperty("reply_markup").GetProperty("inline_keyboard"); - rows.GetArrayLength().ShouldBe(1); - var firstButton = rows[0][0]; - firstButton.GetProperty("text").GetString().ShouldBe("Confirm"); - firstButton.GetProperty("callback_data").GetString().ShouldBe("yes"); + payload.MessageType.ShouldBe("text"); + payload.IsInteractive.ShouldBeFalse(); + payload.ContentJson.ShouldNotContain("inline_keyboard"); + payload.ContentJson.ShouldNotContain("callback_data"); + payload.PlainText.ShouldContain("• Confirm"); + payload.PlainText.ShouldContain("• Cancel"); } [Fact] - public void Compose_with_button_callback_data_truncates_to_telegram_limit() + public void Evaluate_with_actions_returns_degraded_because_buttons_are_unavailable() { - var intent = new MessageContent(); + var intent = new MessageContent + { + Text = "Choose", + }; intent.Actions.Add(new ActionElement { Kind = ActionElementKind.Button, - ActionId = "long-action", - Label = "Long", - Value = new string('x', 200), + ActionId = "confirm", + Label = "Confirm", }); - var payload = CreateComposer().Compose(intent, BuildContext()); - using var document = JsonDocument.Parse(payload.ContentJson); - var data = document.RootElement - .GetProperty("reply_markup") - .GetProperty("inline_keyboard")[0][0] - .GetProperty("callback_data") - .GetString(); - - data.ShouldNotBeNull(); - System.Text.Encoding.UTF8.GetByteCount(data!).ShouldBeLessThanOrEqualTo(64); + var capability = CreateComposer().Evaluate(intent, BuildContext()); + capability.ShouldBe(ComposeCapability.Degraded); + } + + [Fact] + public void Compose_escapes_legacy_markdown_metacharacters_in_text() + { + // NyxID's Telegram relay always sends parse_mode="Markdown", so any unescaped _, *, [ + // or backtick in the text would either turn into formatting or surface as a 400 + // "can't parse entities" rejection. + var payload = CreateComposer().Compose( + new MessageContent { Text = "use _foo_ or *bar* with [link](x) and `code`" }, + BuildContext()); + + payload.PlainText.ShouldBe(@"use \_foo\_ or \*bar\* with \[link](x) and \`code\`"); } [Fact] @@ -133,6 +140,12 @@ public void Evaluate_attachments_without_files_capability_returns_unsupported() capability.ShouldBe(ComposeCapability.Unsupported); } + [Fact] + public void DefaultCapabilities_does_not_advertise_action_button_support() + { + TelegramMessageComposer.DefaultCapabilities.SupportsActionButtons.ShouldBeFalse(); + } + private static ComposeContext BuildContext() => new() { Conversation = ConversationReference.Create( diff --git a/test/Aevatar.Hosting.Tests/MainnetHostCompositionTests.cs b/test/Aevatar.Hosting.Tests/MainnetHostCompositionTests.cs index 1296ecfe5..9e4957dff 100644 --- a/test/Aevatar.Hosting.Tests/MainnetHostCompositionTests.cs +++ b/test/Aevatar.Hosting.Tests/MainnetHostCompositionTests.cs @@ -1,3 +1,6 @@ +using Aevatar.AI.Abstractions.ToolProviders; +using Aevatar.AI.ToolProviders.Lark; +using Aevatar.AI.ToolProviders.Telegram; using Aevatar.Configuration; using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.GAgentService.Abstractions.Ports; @@ -68,6 +71,14 @@ public async Task AddAevatarMainnetHost_WithInMemoryDependencies_ShouldBuildAndS routePatterns.Should().Contain("/api/channels/registrations"); routePatterns.Should().Contain("/api/services/"); + // Both Lark and Telegram tool providers must register with IAgentToolSource so the + // declared agent tools (lark_messages_send / telegram_messages_send / telegram_chats_lookup + // / etc.) are actually discoverable at runtime. Without this assertion the host can + // silently drop a tool provider after a project-reference change and tests still pass. + var toolSources = app.Services.GetServices().ToList(); + toolSources.Should().Contain(source => source is LarkAgentToolSource); + toolSources.Should().Contain(source => source is TelegramAgentToolSource); + await app.StopAsync(); } From 90392d91279e212aeb75922dced52df6a24b8ff8 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 27 Apr 2026 14:13:26 +0800 Subject: [PATCH 14/15] Sync ADR-0013 amendment + capability matrix to no-buttons reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two doc surfaces still claimed Telegram action buttons render as an inline_keyboard with callback_data, but the implementation flipped to SupportsActionButtons=false in commit 0e17b3e9 (NyxID's Telegram channel adapter doesn't subscribe callback_query updates and parse_inbound returns empty for them, so any inline_keyboard click would never round- trip back to Aevatar). - ADR-0013 Telegram amendment: rewrote the rendering bullet to describe the actual degraded behavior — actions become a plain-text bullet list, body text gets legacy-Markdown escaped for parse_mode="Markdown". Added the conditional reverse path (flip SupportsActionButtons + restore callback_data builder + update §10.2) for when NyxID adds callback_query. - docs/canon/aevatar-channel-architecture.md §5.5 capability matrix: Telegram SupportsActionButtons row changed from "✅ (inline keyboard)" to "❌ (degrade to text bullets — see §10.2)" so the matrix lines up with TelegramMessageComposer.DefaultCapabilities and §10.2. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/canon/aevatar-channel-architecture.md | 2 +- .../0013-unified-channel-inbound-backbone.md | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/canon/aevatar-channel-architecture.md b/docs/canon/aevatar-channel-architecture.md index 54e69033d..16465241f 100644 --- a/docs/canon/aevatar-channel-architecture.md +++ b/docs/canon/aevatar-channel-architecture.md @@ -731,7 +731,7 @@ if (adapter is IChannelTypingAdapter typingAdapter | **Streaming** | **Native** | **EditLoopRateLimited** | **EditLoopRateLimited** | **EditLoopRateLimited** | | SupportsFiles | ✅ | ✅ | ✅ | ✅ | | MaxMessageLength | 30k | 4096 | 40k (blocks) | 2000 | -| SupportsActionButtons | ✅ | ✅ (inline keyboard) | ✅ (block actions) | ✅ (components) | +| SupportsActionButtons | ✅ | ❌ (degrade to text bullets — see §10.2) | ✅ (block actions) | ✅ (components) | | SupportsConfirmDialog | ✅ | ❌ | ✅ | ⚠️ modal 替代 | | SupportsModal | ❌ | ❌ | ✅ (views) | ✅ (modal) | | SupportsMention | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/decisions/0013-unified-channel-inbound-backbone.md b/docs/decisions/0013-unified-channel-inbound-backbone.md index 801fbd3fa..afcaed01d 100644 --- a/docs/decisions/0013-unified-channel-inbound-backbone.md +++ b/docs/decisions/0013-unified-channel-inbound-backbone.md @@ -66,8 +66,15 @@ already excluded from the supported production contract. - rendering: new `Aevatar.GAgents.Platform.Telegram` package mirrors `Aevatar.GAgents.Platform.Lark` — `TelegramMessageComposer` + `TelegramChannelNativeMessageProducer` + `TelegramOutboundMessage` + `TelegramPayloadRedactor`. Telegram has no card layout - primitive, so cards degrade into the rendered text body and action buttons render as a - single-row `inline_keyboard` with `callback_data` truncated to the 64-byte Bot API limit. + primitive, so cards degrade into the rendered text body. Action buttons also degrade + into a plain-text bullet list of labels (`DefaultCapabilities.SupportsActionButtons = false`), + not an `inline_keyboard`: NyxID's Telegram channel adapter does not subscribe + `callback_query` updates and its `parse_inbound` returns empty for them, so an + `inline_keyboard` click would never round-trip back to Aevatar. Body text is escaped + for Telegram legacy Markdown (`_`, `*`, `[`, `` ` ``) because NyxID's relay sends every + reply with `parse_mode="Markdown"`. Once NyxID grows the `callback_query` subscribe + + parse + forward contract end-to-end, flip `SupportsActionButtons` back, restore the + `inline_keyboard` + `callback_data` 64-byte truncation logic, and update §10.2. - provisioning: new `NyxTelegramProvisioningService` parallels `NyxLarkProvisioningService` but registers `platform="telegram"` with a real `bot_token` (no Lark `__unused_for_lark__` placeholder) and no `app_id` / `app_secret` / From 5650c5390da97698963f5a9b3fa21c027bdb23d1 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 27 Apr 2026 14:40:17 +0800 Subject: [PATCH 15/15] Lift Telegram tool provider patch coverage from 70% to ~98% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codecov flagged patch coverage 70.03% on PR #289 (threshold ≥80%) with the worst offenders being TelegramNyxClient (35%), TelegramProxyResponseParser (66%), TelegramChatsLookupTool (65%), TelegramMessagesSendTool (88%), TelegramAgentToolSource (85%). Add a direct HTTP-level test class TelegramNyxClientTests that uses a recording HttpMessageHandler to assert TelegramNyxClient builds the sendMessage / getChat request bodies correctly across every option field — chat_id, text, parse_mode, disable_notification (only when explicitly true), reply_to_message_id, reply_markup parsed as a JSON object, custom provider slug. Also covers the response-pass-through path that the in-process stub used by the tool tests cannot exercise. Extend TelegramToolsTests with branch coverage that was missing: - SendMessage propagates parse_mode / disable_notification / reply_to_message_id to the client; accepts each of the three supported parse modes (Markdown / MarkdownV2 / HTML) - All four parser branches: empty response, invalid JSON, ok:false with no error_code/description, error:true with no status/message - Tool falls back to request chat_id when ok:true response has no result - ChatsLookup mirror tests: number-typed chat.id coercion, missing-result fallback, both Nyx error and Telegram error paths - ToolSource per-flag matrix: only-send / only-lookup / blank-slug - AddTelegramTools DI extension test (and default-options variant) so ServiceCollectionExtensions is exercised in this test project too, not only via MainnetHostCompositionTests - Tool metadata pinning test (Name / Description / ApprovalMode) Local cobertura: every class in the Telegram tool provider now ≥89.7% line / ≥90% branch, with all but TelegramProxyResponseParser at 100%. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...tar.AI.ToolProviders.Telegram.Tests.csproj | 1 + .../TelegramNyxClientTests.cs | 191 ++++++++++++++ .../TelegramToolsTests.cs | 247 ++++++++++++++++++ 3 files changed, 439 insertions(+) create mode 100644 test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramNyxClientTests.cs diff --git a/test/Aevatar.AI.ToolProviders.Telegram.Tests/Aevatar.AI.ToolProviders.Telegram.Tests.csproj b/test/Aevatar.AI.ToolProviders.Telegram.Tests/Aevatar.AI.ToolProviders.Telegram.Tests.csproj index 849573019..c83574ebf 100644 --- a/test/Aevatar.AI.ToolProviders.Telegram.Tests/Aevatar.AI.ToolProviders.Telegram.Tests.csproj +++ b/test/Aevatar.AI.ToolProviders.Telegram.Tests/Aevatar.AI.ToolProviders.Telegram.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramNyxClientTests.cs b/test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramNyxClientTests.cs new file mode 100644 index 000000000..f8e667277 --- /dev/null +++ b/test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramNyxClientTests.cs @@ -0,0 +1,191 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Aevatar.AI.ToolProviders.NyxId; +using Aevatar.AI.ToolProviders.Telegram; +using FluentAssertions; +using Xunit; + +namespace Aevatar.AI.ToolProviders.Telegram.Tests; + +/// +/// Direct HTTP-level coverage for . Uses a recording +/// so every body field, slug routing, and method assertion is exercised +/// without relying on the in-process stub used by the higher-level tool tests. +/// +public sealed class TelegramNyxClientTests +{ + [Fact] + public async Task SendMessage_posts_chat_id_text_only_body_to_proxy_slug() + { + var (client, handler) = CreateClient(); + + await client.SendMessageAsync( + "user-token", + new TelegramSendMessageRequest(ChatId: "12345", Text: "hello"), + CancellationToken.None); + + var request = handler.LastRequest!; + request.Method.Should().Be(HttpMethod.Post); + request.Path.Should().Be("/api/v1/proxy/s/api-telegram-bot/sendMessage"); + request.AuthorizationHeader.Should().Be("Bearer user-token"); + + using var body = JsonDocument.Parse(handler.LastBody!); + body.RootElement.GetProperty("chat_id").GetString().Should().Be("12345"); + body.RootElement.GetProperty("text").GetString().Should().Be("hello"); + body.RootElement.TryGetProperty("parse_mode", out _).Should().BeFalse(); + body.RootElement.TryGetProperty("disable_notification", out _).Should().BeFalse(); + body.RootElement.TryGetProperty("reply_to_message_id", out _).Should().BeFalse(); + body.RootElement.TryGetProperty("reply_markup", out _).Should().BeFalse(); + } + + [Fact] + public async Task SendMessage_includes_optional_fields_only_when_set() + { + var (client, handler) = CreateClient(); + + await client.SendMessageAsync( + "user-token", + new TelegramSendMessageRequest( + ChatId: "12345", + Text: "hello", + ParseMode: "MarkdownV2", + DisableNotification: true, + ReplyToMessageId: 42), + CancellationToken.None); + + using var body = JsonDocument.Parse(handler.LastBody!); + body.RootElement.GetProperty("parse_mode").GetString().Should().Be("MarkdownV2"); + body.RootElement.GetProperty("disable_notification").GetBoolean().Should().BeTrue(); + body.RootElement.GetProperty("reply_to_message_id").GetInt32().Should().Be(42); + } + + [Fact] + public async Task SendMessage_omits_disable_notification_when_explicitly_false() + { + // The client only adds disable_notification when caller explicitly opted in (true); + // false / null both keep the body lean so Telegram applies its default behavior. + var (client, handler) = CreateClient(); + + await client.SendMessageAsync( + "user-token", + new TelegramSendMessageRequest( + ChatId: "12345", + Text: "hello", + DisableNotification: false), + CancellationToken.None); + + using var body = JsonDocument.Parse(handler.LastBody!); + body.RootElement.TryGetProperty("disable_notification", out _).Should().BeFalse(); + } + + [Fact] + public async Task SendMessage_parses_reply_markup_json_into_object_field() + { + // Without the JsonNode.Parse path, reply_markup would arrive as a JSON string at Telegram + // and be rejected; this asserts it lands as a structured object. + var (client, handler) = CreateClient(); + + await client.SendMessageAsync( + "user-token", + new TelegramSendMessageRequest( + ChatId: "12345", + Text: "hello", + ReplyMarkupJson: """{"inline_keyboard":[[{"text":"yes","callback_data":"y"}]]}"""), + CancellationToken.None); + + using var body = JsonDocument.Parse(handler.LastBody!); + var markup = body.RootElement.GetProperty("reply_markup"); + markup.ValueKind.Should().Be(JsonValueKind.Object); + var firstButton = markup.GetProperty("inline_keyboard")[0][0]; + firstButton.GetProperty("text").GetString().Should().Be("yes"); + firstButton.GetProperty("callback_data").GetString().Should().Be("y"); + } + + [Fact] + public async Task SendMessage_returns_response_body_verbatim() + { + var (client, handler) = CreateClient(); + handler.NextResponseBody = """{"ok":true,"result":{"message_id":7}}"""; + + var response = await client.SendMessageAsync( + "user-token", + new TelegramSendMessageRequest(ChatId: "1", Text: "hi"), + CancellationToken.None); + + response.Should().Contain("\"message_id\":7"); + } + + [Fact] + public async Task GetChat_posts_chat_id_body_to_get_chat_slug() + { + var (client, handler) = CreateClient(); + + await client.GetChatAsync( + "user-token", + new TelegramGetChatRequest(ChatId: "-1001234"), + CancellationToken.None); + + var request = handler.LastRequest!; + request.Method.Should().Be(HttpMethod.Post); + request.Path.Should().Be("/api/v1/proxy/s/api-telegram-bot/getChat"); + request.AuthorizationHeader.Should().Be("Bearer user-token"); + + using var body = JsonDocument.Parse(handler.LastBody!); + body.RootElement.GetProperty("chat_id").GetString().Should().Be("-1001234"); + } + + [Fact] + public async Task SendMessage_uses_overridden_provider_slug() + { + // Custom configuration must be honored — Mainnet host wires this through + // Aevatar:Telegram:NyxProviderSlug (see MainnetHostBuilderExtensions). + var nyxOptions = new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }; + var handler = new RecordingHandler(); + var client = new TelegramNyxClient( + new TelegramToolOptions { ProviderSlug = "api-telegram-staging" }, + new NyxIdApiClient(nyxOptions, new HttpClient(handler))); + + await client.SendMessageAsync( + "user-token", + new TelegramSendMessageRequest(ChatId: "1", Text: "hi"), + CancellationToken.None); + + handler.LastRequest!.Path.Should().Be("/api/v1/proxy/s/api-telegram-staging/sendMessage"); + } + + private static (TelegramNyxClient Client, RecordingHandler Handler) CreateClient() + { + var nyxOptions = new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }; + var handler = new RecordingHandler(); + var client = new TelegramNyxClient( + new TelegramToolOptions(), + new NyxIdApiClient(nyxOptions, new HttpClient(handler))); + return (client, handler); + } + + private sealed class RecordingHandler : HttpMessageHandler + { + public RecordedRequest? LastRequest { get; private set; } + public string? LastBody { get; private set; } + public string NextResponseBody { get; set; } = """{"ok":true,"result":{}}"""; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + LastRequest = new RecordedRequest( + request.Method, + request.RequestUri!.AbsolutePath, + request.Headers.Authorization?.ToString() ?? string.Empty); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(NextResponseBody, Encoding.UTF8, "application/json"), + }; + } + } + + private sealed record RecordedRequest(HttpMethod Method, string Path, string AuthorizationHeader); +} diff --git a/test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramToolsTests.cs b/test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramToolsTests.cs index abc43e990..0bec3fe66 100644 --- a/test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramToolsTests.cs +++ b/test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramToolsTests.cs @@ -1,15 +1,35 @@ using System.Text.Json; using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.AI.Abstractions.ToolProviders; +using Aevatar.AI.ToolProviders.NyxId; using Aevatar.AI.ToolProviders.Telegram; using Aevatar.AI.ToolProviders.Telegram.Tools; using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Aevatar.AI.ToolProviders.Telegram.Tests; public class TelegramToolsTests { + [Fact] + public void Tool_metadata_is_stable() + { + // Pin the metadata the agent framework reads from each tool — Name flows into + // tool registration, Description into the tool spec presented to the LLM, + // ApprovalMode into the auto-execute decision. Drift in any of these silently + // changes agent behavior, so they are worth pinning. + var send = new TelegramMessagesSendTool(new StubTelegramNyxClient()); + send.Name.Should().Be("telegram_messages_send"); + send.Description.Should().Contain("Telegram").And.Contain("Nyx"); + send.ApprovalMode.Should().Be(ToolApprovalMode.Auto); + + var lookup = new TelegramChatsLookupTool(new StubTelegramNyxClient()); + lookup.Name.Should().Be("telegram_chats_lookup"); + lookup.Description.Should().Contain("Telegram").And.Contain("chat"); + lookup.ApprovalMode.Should().Be(ToolApprovalMode.Auto); + } + [Fact] public async Task SendMessage_returns_success_with_message_id_and_chat_id() { @@ -54,6 +74,105 @@ public async Task SendMessage_validates_inputs() } } + [Fact] + public async Task SendMessage_propagates_optional_fields_to_client() + { + var client = new StubTelegramNyxClient(); + var tool = new TelegramMessagesSendTool(client); + + using var _ = new AgentToolRequestMetadataScope("token-abc"); + await tool.ExecuteAsync( + """{"chat_id":"12345","text":"hi","parse_mode":"MarkdownV2","disable_notification":true,"reply_to_message_id":42}"""); + + client.LastSendRequest.Should().NotBeNull(); + client.LastSendRequest!.ParseMode.Should().Be("MarkdownV2"); + client.LastSendRequest.DisableNotification.Should().BeTrue(); + client.LastSendRequest.ReplyToMessageId.Should().Be(42); + } + + [Theory] + [InlineData("Markdown")] + [InlineData("MarkdownV2")] + [InlineData("HTML")] + public async Task SendMessage_accepts_each_supported_parse_mode(string parseMode) + { + var client = new StubTelegramNyxClient(); + var tool = new TelegramMessagesSendTool(client); + + using var _ = new AgentToolRequestMetadataScope("token-abc"); + var json = $$"""{"chat_id":"12345","text":"hi","parse_mode":"{{parseMode}}"}"""; + var result = await tool.ExecuteAsync(json); + + result.Should().Contain("\"success\":true"); + client.LastSendRequest!.ParseMode.Should().Be(parseMode); + } + + [Fact] + public async Task SendMessage_surfaces_empty_response_as_error() + { + var tool = new TelegramMessagesSendTool(new StubTelegramNyxClient { SendResponse = string.Empty }); + + using var _ = new AgentToolRequestMetadataScope("token-abc"); + var result = await tool.ExecuteAsync("""{"chat_id":"99","text":"hi"}"""); + result.Should().Contain("empty_telegram_response"); + } + + [Fact] + public async Task SendMessage_surfaces_invalid_json_response_as_error() + { + var tool = new TelegramMessagesSendTool(new StubTelegramNyxClient { SendResponse = "not-json-at-all" }); + + using var _ = new AgentToolRequestMetadataScope("token-abc"); + var result = await tool.ExecuteAsync("""{"chat_id":"99","text":"hi"}"""); + result.Should().Contain("invalid_telegram_response_json"); + } + + [Fact] + public async Task SendMessage_handles_unknown_telegram_error_code() + { + // ok:false without error_code/description still produces a structured error string. + var tool = new TelegramMessagesSendTool(new StubTelegramNyxClient + { + SendResponse = """{"ok":false}""", + }); + + using var _ = new AgentToolRequestMetadataScope("token-abc"); + var result = await tool.ExecuteAsync("""{"chat_id":"99","text":"hi"}"""); + result.Should().Contain("telegram_error_code=unknown"); + } + + [Fact] + public async Task SendMessage_handles_unknown_nyx_status() + { + // error:true without status/message/body still produces a structured error string. + var tool = new TelegramMessagesSendTool(new StubTelegramNyxClient + { + SendResponse = """{"error":true}""", + }); + + using var _ = new AgentToolRequestMetadataScope("token-abc"); + var result = await tool.ExecuteAsync("""{"chat_id":"99","text":"hi"}"""); + result.Should().Contain("nyx_proxy_error status=unknown"); + } + + [Fact] + public async Task SendMessage_falls_back_to_request_chat_id_when_response_omits_result() + { + // Telegram could in theory return ok:true with no result block; the tool should still + // surface chat_id from the original request rather than emitting empty. + var tool = new TelegramMessagesSendTool(new StubTelegramNyxClient + { + SendResponse = """{"ok":true}""", + }); + + using var _ = new AgentToolRequestMetadataScope("token-abc"); + var result = await tool.ExecuteAsync("""{"chat_id":"99","text":"hi"}"""); + + using var document = JsonDocument.Parse(result); + document.RootElement.GetProperty("success").GetBoolean().Should().BeTrue(); + document.RootElement.GetProperty("chat_id").GetString().Should().Be("99"); + } + [Fact] public async Task SendMessage_surfaces_proxy_and_telegram_errors_with_chat_id() { @@ -126,6 +245,68 @@ public async Task ChatsLookup_returns_chat_metadata() client.LastGetChatRequest!.ChatId.Should().Be("-1001234"); } + [Fact] + public async Task ChatsLookup_handles_chat_id_as_number_in_response() + { + // Telegram returns chat.id as a JSON number; the parser must coerce it to string for + // the tool output. + var tool = new TelegramChatsLookupTool(new StubTelegramNyxClient + { + GetChatResponse = """{"ok":true,"result":{"id":42,"type":"private"}}""", + }); + + using var _ = new AgentToolRequestMetadataScope("token-abc"); + var result = await tool.ExecuteAsync("""{"chat_id":"42"}"""); + + using var document = JsonDocument.Parse(result); + document.RootElement.GetProperty("success").GetBoolean().Should().BeTrue(); + document.RootElement.GetProperty("chat_id").GetString().Should().Be("42"); + document.RootElement.GetProperty("type").GetString().Should().Be("private"); + } + + [Fact] + public async Task ChatsLookup_falls_back_to_request_chat_id_when_response_omits_result() + { + var tool = new TelegramChatsLookupTool(new StubTelegramNyxClient + { + GetChatResponse = """{"ok":true}""", + }); + + using var _ = new AgentToolRequestMetadataScope("token-abc"); + var result = await tool.ExecuteAsync("""{"chat_id":"99"}"""); + + using var document = JsonDocument.Parse(result); + document.RootElement.GetProperty("success").GetBoolean().Should().BeTrue(); + document.RootElement.GetProperty("chat_id").GetString().Should().Be("99"); + } + + [Fact] + public async Task ChatsLookup_surfaces_proxy_and_telegram_errors_with_chat_id() + { + var nyxErrorTool = new TelegramChatsLookupTool(new StubTelegramNyxClient + { + GetChatResponse = """{"error":true,"status":502,"body":"upstream timed out"}""", + }); + using (new AgentToolRequestMetadataScope("token-abc")) + { + var result = await nyxErrorTool.ExecuteAsync("""{"chat_id":"99"}"""); + result.Should().Contain("nyx_proxy_error status=502"); + result.Should().Contain("upstream timed out"); + result.Should().Contain("\"chat_id\":\"99\""); + } + + var tgErrorTool = new TelegramChatsLookupTool(new StubTelegramNyxClient + { + GetChatResponse = """{"ok":false,"error_code":400,"description":"chat not found"}""", + }); + using (new AgentToolRequestMetadataScope("token-abc")) + { + var result = await tgErrorTool.ExecuteAsync("""{"chat_id":"99"}"""); + result.Should().Contain("telegram_error_code=400"); + result.Should().Contain("chat not found"); + } + } + [Fact] public async Task ChatsLookup_validates_inputs() { @@ -184,6 +365,72 @@ public async Task ToolSource_respects_disable_flags() tools.Should().BeEmpty(); } + [Fact] + public async Task ToolSource_emits_only_send_when_chat_lookup_disabled() + { + var source = new TelegramAgentToolSource( + new TelegramToolOptions { EnableChatLookup = false }, + new NyxId.NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, + new StubTelegramNyxClient()); + + var tools = await source.DiscoverToolsAsync(); + tools.Select(t => t.Name).Should().BeEquivalentTo("telegram_messages_send"); + } + + [Fact] + public async Task ToolSource_emits_only_lookup_when_send_disabled() + { + var source = new TelegramAgentToolSource( + new TelegramToolOptions { EnableMessageSend = false }, + new NyxId.NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, + new StubTelegramNyxClient()); + + var tools = await source.DiscoverToolsAsync(); + tools.Select(t => t.Name).Should().BeEquivalentTo("telegram_chats_lookup"); + } + + [Fact] + public void AddTelegramTools_registers_options_client_and_tool_source() + { + var services = new ServiceCollection(); + services.AddSingleton(new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }); + services.AddSingleton(new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, + new HttpClient())); + + services.AddTelegramTools(o => o.ProviderSlug = "api-telegram-staging"); + + var provider = services.BuildServiceProvider(); + provider.GetRequiredService().ProviderSlug.Should().Be("api-telegram-staging"); + provider.GetRequiredService().Should().BeOfType(); + provider.GetServices().OfType().Should().ContainSingle(); + } + + [Fact] + public void AddTelegramTools_uses_default_options_when_configure_omitted() + { + var services = new ServiceCollection(); + services.AddSingleton(new NyxIdToolOptions()); + services.AddSingleton(new NyxIdApiClient(new NyxIdToolOptions(), new HttpClient())); + + services.AddTelegramTools(); + + var provider = services.BuildServiceProvider(); + provider.GetRequiredService().ProviderSlug.Should().Be("api-telegram-bot"); + } + + [Fact] + public async Task ToolSource_emits_no_tools_when_provider_slug_blank() + { + var source = new TelegramAgentToolSource( + new TelegramToolOptions { ProviderSlug = string.Empty }, + new NyxId.NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, + new StubTelegramNyxClient()); + + var tools = await source.DiscoverToolsAsync(); + tools.Should().BeEmpty(); + } + private sealed class StubTelegramNyxClient : ITelegramNyxClient { public string? SendResponse { get; set; }