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 bd3c86746..ae5249b3f 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -13,6 +13,7 @@ + @@ -44,6 +45,7 @@ + @@ -150,6 +152,7 @@ + @@ -176,6 +179,7 @@ + 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/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 069e7ed3e..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(); @@ -132,7 +128,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 +138,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, @@ -358,7 +355,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, }; @@ -440,8 +437,48 @@ 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; + } + + /// + /// 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/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 0a4a68f66..85179c1b3 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, @@ -204,15 +205,15 @@ 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" - : ex.Message); + : NyxApiResponseHelper.SanitizeFailureReason(ex)); } } @@ -288,7 +289,7 @@ await RegisterLocalMirrorAsync( request.NyxAgentApiKeyId, request.NyxConversationRouteId); - return MirrorFailure(ex.Message); + return MirrorFailure(NyxApiResponseHelper.SanitizeFailureReason(ex)); } } @@ -333,7 +334,7 @@ private async Task CreateRelayApiKeyAsync( }), ct); - return ExtractRequiredRelayApiKeyCredentials(response); + return new RelayApiKeyCredentials(NyxApiResponseHelper.ExtractRequiredApiKeyId(response)); } private async Task RegisterChannelBotAsync( @@ -361,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( @@ -380,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( @@ -419,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 { @@ -448,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 { @@ -484,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 => @@ -637,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 new file mode 100644 index 000000000..3ece4fe25 --- /dev/null +++ b/agents/Aevatar.GAgents.ChannelRuntime/NyxTelegramProvisioningService.cs @@ -0,0 +1,300 @@ +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. 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) + { + _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 NyxApiResponseHelper.TryRollbackAsync(() => _nyxClient.DeleteConversationRouteAsync(request.AccessToken, routeId, ct), "channel_route", routeId, _logger); + if (!localMirrorAccepted && channelBotId is not null) + await NyxApiResponseHelper.TryRollbackAsync(() => _nyxClient.DeleteChannelBotAsync(request.AccessToken, channelBotId, ct), "channel_bot", channelBotId, _logger); + if (!localMirrorAccepted && apiKeyId is not null) + await NyxApiResponseHelper.TryRollbackAsync(() => _nyxClient.DeleteApiKeyAsync(request.AccessToken, apiKeyId, ct), "api_key", apiKeyId, _logger); + + return Failure(localMirrorAccepted + ? "local_mirror_accepted_remote_cleanup_skipped" + : NyxApiResponseHelper.SanitizeFailureReason(ex)); + } + } + + 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 new RelayApiKeyCredentials(NyxApiResponseHelper.ExtractRequiredApiKeyId(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 NyxApiResponseHelper.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 NyxApiResponseHelper.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 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/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/agents/platforms/Aevatar.GAgents.Platform.Telegram/Aevatar.GAgents.Platform.Telegram.csproj b/agents/platforms/Aevatar.GAgents.Platform.Telegram/Aevatar.GAgents.Platform.Telegram.csproj new file mode 100644 index 000000000..ec401b343 --- /dev/null +++ b/agents/platforms/Aevatar.GAgents.Platform.Telegram/Aevatar.GAgents.Platform.Telegram.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + 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/platforms/Aevatar.GAgents.Platform.Telegram/TelegramMessageComposer.cs b/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramMessageComposer.cs new file mode 100644 index 000000000..6dc6f4b38 --- /dev/null +++ b/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramMessageComposer.cs @@ -0,0 +1,162 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Aevatar.GAgents.Channel.Abstractions; + +namespace Aevatar.GAgents.Platform.Telegram; + +public sealed class TelegramMessageComposer : IMessageComposer +{ + private const int TelegramTextLimit = 4096; + private const int TelegramCaptionLimit = 1024; + + public static readonly ChannelCapabilities DefaultCapabilities = new() + { + SupportsEphemeral = false, + SupportsEdit = true, + SupportsDelete = true, + SupportsThread = false, + Streaming = StreamingSupport.EditLoopRateLimited, + SupportsFiles = false, + MaxMessageLength = TelegramTextLimit, + // NyxID's Telegram channel adapter (backend/src/services/channel_adapters/telegram.rs) + // does not subscribe to `callback_query` updates and parse_inbound returns empty for + // them, so an inline_keyboard's callback_data round-trip never reaches Aevatar. Until + // the relay-side adapter grows that contract end-to-end, we cannot truthfully claim + // action-button support — actions degrade into a plain-text bullet list of labels. + SupportsActionButtons = false, + SupportsConfirmDialog = false, + SupportsModal = false, + SupportsMention = false, + SupportsTyping = false, + SupportsReactions = false, + RecommendedStreamDebounceMs = 3000, + Transport = TransportMode.Webhook, + }; + + public ChannelId Channel { get; } = ChannelId.From("telegram"); + + public TelegramOutboundMessage Compose(MessageContent intent, ComposeContext context) + { + ArgumentNullException.ThrowIfNull(intent); + ArgumentNullException.ThrowIfNull(context); + + var capabilities = context.Capabilities ?? DefaultCapabilities; + var maxLength = ResolveTextLimit(capabilities.MaxMessageLength, TelegramTextLimit); + var effectiveText = BuildRenderedText(intent, maxLength); + + return new TelegramOutboundMessage( + MessageType: "text", + ContentJson: JsonSerializer.Serialize(new { text = effectiveText }), + PlainText: effectiveText, + IsInteractive: false); + } + + 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; + var capabilities = context.Capabilities ?? DefaultCapabilities; + + if (intent.Disposition == MessageDisposition.Ephemeral && !capabilities.SupportsEphemeral) + degraded = true; + if (intent.Attachments.Count > 0 && !capabilities.SupportsFiles) + return ComposeCapability.Unsupported; + // 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); + if (BuildRenderedText(intent, int.MaxValue).Length > maxLength) + degraded = true; + + return degraded ? ComposeCapability.Degraded : ComposeCapability.Exact; + } + + 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}"); + } + + // 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 escaped; + + var textInfo = new StringInfo(escaped); + if (textInfo.LengthInTextElements <= maxLength) + return escaped; + + return textInfo.SubstringByTextElements(0, 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()); + } + + /// + /// 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/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/platforms/Aevatar.GAgents.Platform.Telegram/TelegramPayloadRedactor.cs b/agents/platforms/Aevatar.GAgents.Platform.Telegram/TelegramPayloadRedactor.cs new file mode 100644 index 000000000..ebe8ea341 --- /dev/null +++ b/agents/platforms/Aevatar.GAgents.Platform.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.Platform.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/docs/canon/aevatar-channel-architecture.md b/docs/canon/aevatar-channel-architecture.md index 9d50df57f..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 | ✅ | ✅ | ✅ | ✅ | @@ -1905,14 +1905,16 @@ Lark webhook / long-connection / gateway 这类 ingress concern 属于 `Channel. ### 10.2 Telegram(`agents/platforms/Aevatar.GAgents.Platform.Telegram`) -- **底层 SDK**:[Telegram.Bot](https://github.com/TelegramBots/Telegram.Bot)(11k+ stars,最成熟的 .NET Telegram library) -- **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/decisions/0013-unified-channel-inbound-backbone.md b/docs/decisions/0013-unified-channel-inbound-backbone.md index d9c59578b..afcaed01d 100644 --- a/docs/decisions/0013-unified-channel-inbound-backbone.md +++ b/docs/decisions/0013-unified-channel-inbound-backbone.md @@ -51,3 +51,51 @@ 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. 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` / + `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..d190a48d5 --- /dev/null +++ b/docs/operations/2026-04-27-telegram-nyx-cutover-runbook.md @@ -0,0 +1,160 @@ +# 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). + 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 (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` +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` and triggers NyxID to re-register the webhook. + - Do **not** call `setWebhook` manually as part of rotation either. + +## 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` + 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 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 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. + +## 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. 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..5ba8f07fc --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.Telegram/TelegramNyxClient.cs @@ -0,0 +1,81 @@ +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)) + { + JsonNode? parsed; + try + { + parsed = JsonNode.Parse(request.ReplyMarkupJson); + } + catch (JsonException ex) + { + throw new ArgumentException( + $"{nameof(TelegramSendMessageRequest)}.{nameof(TelegramSendMessageRequest.ReplyMarkupJson)} must be valid JSON: {ex.Message}", + nameof(request), + ex); + } + + body["reply_markup"] = parsed; + } + + 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 = new JsonObject + { + ["chat_id"] = request.ChatId, + }; + + return _nyxClient.ProxyRequestAsync( + token, + _options.ProviderSlug, + "getChat", + "POST", + body.ToJsonString(JsonOptions), + 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/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.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..c83574ebf --- /dev/null +++ b/test/Aevatar.AI.ToolProviders.Telegram.Tests/Aevatar.AI.ToolProviders.Telegram.Tests.csproj @@ -0,0 +1,22 @@ + + + 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/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 new file mode 100644 index 000000000..0bec3fe66 --- /dev/null +++ b/test/Aevatar.AI.ToolProviders.Telegram.Tests/TelegramToolsTests.cs @@ -0,0 +1,475 @@ +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() + { + 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_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() + { + 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 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() + { + 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_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() + { + 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(); + } + + [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; } + 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; + } +} diff --git a/test/Aevatar.GAgents.Channel.Testing/Conformance/ChannelAdapterConformanceTests.cs b/test/Aevatar.GAgents.Channel.Testing/Conformance/ChannelAdapterConformanceTests.cs index dae879571..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(2000); + 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/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(); 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() { 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) { diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxTelegramProvisioningServiceTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxTelegramProvisioningServiceTests.cs new file mode 100644 index 000000000..dab053b5a --- /dev/null +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxTelegramProvisioningServiceTests.cs @@ -0,0 +1,262 @@ +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 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() + { + 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] diff --git a/test/Aevatar.GAgents.Platform.Telegram.Tests/Aevatar.GAgents.Platform.Telegram.Tests.csproj b/test/Aevatar.GAgents.Platform.Telegram.Tests/Aevatar.GAgents.Platform.Telegram.Tests.csproj new file mode 100644 index 000000000..4b49b5745 --- /dev/null +++ b/test/Aevatar.GAgents.Platform.Telegram.Tests/Aevatar.GAgents.Platform.Telegram.Tests.csproj @@ -0,0 +1,28 @@ + + + net10.0 + enable + enable + false + true + Aevatar.GAgents.Platform.Telegram.Tests + Aevatar.GAgents.Platform.Telegram.Tests + + + + + + + + + + + + + + + + + + + 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..811350453 --- /dev/null +++ b/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramChannelNativeMessageProducerTests.cs @@ -0,0 +1,68 @@ +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_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 + { + Kind = ActionElementKind.Button, + ActionId = "confirm", + Label = "Confirm", + Value = "yes", + }); + + var native = producer.Produce(intent, BuildContext()); + + 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] + 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..f9fea0ff8 --- /dev/null +++ b/test/Aevatar.GAgents.Platform.Telegram.Tests/TelegramMessageComposerTests.cs @@ -0,0 +1,159 @@ +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) + { + // 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(); + 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) + { + 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_degrades_buttons_to_plain_text_bullets() + { + var intent = new MessageContent + { + Text = "Choose", + }; + intent.Actions.Add(new ActionElement + { + Kind = ActionElementKind.Button, + ActionId = "confirm", + 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("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 Evaluate_with_actions_returns_degraded_because_buttons_are_unavailable() + { + var intent = new MessageContent + { + Text = "Choose", + }; + intent.Actions.Add(new ActionElement + { + Kind = ActionElementKind.Button, + ActionId = "confirm", + Label = "Confirm", + }); + + 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] + 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); + } + + [Fact] + public void DefaultCapabilities_does_not_advertise_action_button_support() + { + TelegramMessageComposer.DefaultCapabilities.SupportsActionButtons.ShouldBeFalse(); + } + + 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.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(); }