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();
}