Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ac9e9b1
Add Telegram channel adapter
eanzhao Apr 21, 2026
a787a26
Fix Telegram adapter review issues
eanzhao Apr 21, 2026
dca90f8
Merge branch 'dev' into feat/2026-04-21_issue-262-telegram-channel-ad…
eanzhao Apr 22, 2026
8400d18
Refactor Telegram channel adapter architecture
eanzhao Apr 23, 2026
f281d56
Merge branch 'dev' into feat/2026-04-21_issue-262-telegram-channel-ad…
eanzhao Apr 23, 2026
3d5d475
Merge remote-tracking branch 'origin/dev' into feat/2026-04-21_issue-…
eanzhao Apr 27, 2026
5081bb7
Restructure Telegram to platform-rendering package
eanzhao Apr 27, 2026
f7eb23d
Add Telegram bot provisioning + relay platform routing
eanzhao Apr 27, 2026
cf9dc21
Add chat-only Telegram agent tool provider
eanzhao Apr 27, 2026
6626aa8
Document Telegram on the unified inbound backbone
eanzhao Apr 27, 2026
68cbd46
Drop dead Telegram ConversationReference helper test
eanzhao Apr 27, 2026
9e2b473
Map missing_bot_token to 400 in registration endpoint
eanzhao Apr 27, 2026
cc9cc4b
Address pre-landing review on PR #289
eanzhao Apr 27, 2026
f70f0f1
Address second-round review on PR #289
eanzhao Apr 27, 2026
5acf8fe
Address third-round review on PR #289
eanzhao Apr 27, 2026
0e17b3e
Align Telegram adapter with NyxID relay reality
eanzhao Apr 27, 2026
437d00b
Merge remote-tracking branch 'origin/dev' into feat/2026-04-21_issue-…
eanzhao Apr 27, 2026
90392d9
Sync ADR-0013 amendment + capability matrix to no-buttons reality
eanzhao Apr 27, 2026
5650c53
Lift Telegram tool provider patch coverage from 70% to ~98%
eanzhao Apr 27, 2026
84b74af
Merge branch 'dev' into feat/2026-04-21_issue-262-telegram-channel-ad…
eanzhao Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion aevatar.platforms.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
4 changes: 4 additions & 0 deletions aevatar.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<Project Path="agents\Aevatar.GAgents.Channel.Abstractions\Aevatar.GAgents.Channel.Abstractions.csproj" />
<Project Path="agents\channels\Aevatar.GAgents.Channel.NyxIdRelay\Aevatar.GAgents.Channel.NyxIdRelay.csproj" />
<Project Path="agents\platforms\Aevatar.GAgents.Platform.Lark\Aevatar.GAgents.Platform.Lark.csproj" />
<Project Path="agents\platforms\Aevatar.GAgents.Platform.Telegram\Aevatar.GAgents.Platform.Telegram.csproj" />
<Project Path="agents\Aevatar.GAgents.Channel.Runtime\Aevatar.GAgents.Channel.Runtime.csproj" />
<Project Path="agents\Aevatar.GAgents.ChannelRuntime\Aevatar.GAgents.ChannelRuntime.csproj" />
<Project Path="agents\Aevatar.GAgents.Household\Aevatar.GAgents.Household.csproj" />
Expand Down Expand Up @@ -44,6 +45,7 @@
<Project Path="src/Aevatar.AI.ToolProviders.ChronoStorage/Aevatar.AI.ToolProviders.ChronoStorage.csproj" />
<Project Path="src/Aevatar.AI.ToolProviders.Channel/Aevatar.AI.ToolProviders.Channel.csproj" />
<Project Path="src/Aevatar.AI.ToolProviders.Lark/Aevatar.AI.ToolProviders.Lark.csproj" />
<Project Path="src/Aevatar.AI.ToolProviders.Telegram/Aevatar.AI.ToolProviders.Telegram.csproj" />
<Project Path="src/Aevatar.AI.ToolProviders.NyxId/Aevatar.AI.ToolProviders.NyxId.csproj" />
<Project Path="src/Aevatar.CQRS.Projection.Runtime.Abstractions/Aevatar.CQRS.Projection.Runtime.Abstractions.csproj" />
<Project Path="src/Aevatar.Foundation.ExternalLinks.WebSocket/Aevatar.Foundation.ExternalLinks.WebSocket.csproj" />
Expand Down Expand Up @@ -150,6 +152,7 @@
<Project Path="test\Aevatar.AI.Tests\Aevatar.AI.Tests.csproj" />
<Project Path="test\Aevatar.AI.ToolProviders.ServiceInvoke.Tests\Aevatar.AI.ToolProviders.ServiceInvoke.Tests.csproj" />
<Project Path="test\Aevatar.AI.ToolProviders.Lark.Tests\Aevatar.AI.ToolProviders.Lark.Tests.csproj" />
<Project Path="test\Aevatar.AI.ToolProviders.Telegram.Tests\Aevatar.AI.ToolProviders.Telegram.Tests.csproj" />
<Project Path="test\Aevatar.AI.ToolProviders.Workflow.Tests\Aevatar.AI.ToolProviders.Workflow.Tests.csproj" />
<Project Path="test\Aevatar.AI.ToolProviders.Binding.Tests\Aevatar.AI.ToolProviders.Binding.Tests.csproj" />
<Project Path="test\Aevatar.AI.Infrastructure.Local.Tests\Aevatar.AI.Infrastructure.Local.Tests.csproj" />
Expand All @@ -176,6 +179,7 @@
<Project Path="test\Aevatar.Architecture.Tests\Aevatar.Architecture.Tests.csproj" />
<Project Path="test\Aevatar.GAgents.Platform.Lark.Tests\Aevatar.GAgents.Platform.Lark.Tests.csproj" />
<Project Path="test\Aevatar.GAgents.Channel.Protocol.Tests\Aevatar.GAgents.Channel.Protocol.Tests.csproj" />
<Project Path="test\Aevatar.GAgents.Platform.Telegram.Tests\Aevatar.GAgents.Platform.Telegram.Tests.csproj" />
<Project Path="test\Aevatar.GAgents.Channel.Testing\Aevatar.GAgents.Channel.Testing.csproj" />
<Project Path="test\Aevatar.GAgents.ChannelRuntime.Tests\Aevatar.GAgents.ChannelRuntime.Tests.csproj" />
<Project Path="test\Aevatar.GAgents.Household.Tests\Aevatar.GAgents.Household.Tests.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<ProjectReference Include="..\Aevatar.GAgents.Channel.Runtime\Aevatar.GAgents.Channel.Runtime.csproj" />
<ProjectReference Include="..\channels\Aevatar.GAgents.Channel.NyxIdRelay\Aevatar.GAgents.Channel.NyxIdRelay.csproj" />
<ProjectReference Include="..\platforms\Aevatar.GAgents.Platform.Lark\Aevatar.GAgents.Platform.Lark.csproj" />
<ProjectReference Include="..\platforms\Aevatar.GAgents.Platform.Telegram\Aevatar.GAgents.Platform.Telegram.csproj" />
<ProjectReference Include="..\..\src\platform\Aevatar.GAgentService.Abstractions\Aevatar.GAgentService.Abstractions.csproj" />
<ProjectReference Include="..\..\src\workflow\Aevatar.Workflow.Application.Abstractions\Aevatar.Workflow.Application.Abstractions.csproj" />
<ProjectReference Include="..\Aevatar.GAgents.NyxidChat\Aevatar.GAgents.NyxidChat.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,26 @@ protected override ChannelBotRegistrationStoreState TransitionState(ChannelBotRe

// ─── Commands ───

/// <summary>
/// Platforms whose registrations are allowed to land in the local mirror. Aligned with the
/// set of <c>INyxChannelBotProvisioningService</c> 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.
/// </summary>
private static readonly HashSet<string> 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;
Expand Down
53 changes: 45 additions & 8 deletions agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,7 @@ private static async Task<IResult> 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();

Expand All @@ -132,7 +128,8 @@ private static async Task<IResult> 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
Expand All @@ -141,7 +138,7 @@ private static async Task<IResult> 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,
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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<string, string>? Credentials,
string? Label);

private static IReadOnlyDictionary<string, string>? BuildCredentialsMap(
string platform,
RegistrationRequest request)
{
var bag = new Dictionary<string, string>(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;
}

/// <summary>
/// Builds the default Nyx provider slug echoed back to the client when the registration request
/// did not pin <c>nyx_provider_slug</c>. The convention is <c>api-{platform}-bot</c>, so adding
/// a new platform doesn't need a new switch arm and a future <c>discord</c> registration would
/// surface <c>api-discord-bot</c> rather than silently echoing <c>api-lark-bot</c>.
/// </summary>
private static string ResolveDefaultProviderSlug(string platform) =>
$"api-{platform}-bot";
}
171 changes: 171 additions & 0 deletions agents/Aevatar.GAgents.ChannelRuntime/NyxApiResponseHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;

namespace Aevatar.GAgents.ChannelRuntime;

/// <summary>
/// Shared parsing / rollback helpers for the Nyx-side responses consumed by per-platform
/// provisioning services (<see cref="NyxLarkProvisioningService"/>, <see cref="NyxTelegramProvisioningService"/>,
/// 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.
/// </summary>
internal static class NyxApiResponseHelper
{
/// <summary>
/// Returns the trimmed <c>id</c> field from a Nyx create-resource response, or throws
/// <see cref="InvalidOperationException"/> with a controlled error code suffix derived from
/// <paramref name="resourceName"/>. Wraps <see cref="LooksLikeErrorEnvelope"/> + <see cref="ExtractErrorDetail"/>.
/// </summary>
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);
}
}

/// <summary>
/// Returns the trimmed <c>id</c> field from a Nyx api-key creation response. Distinct from
/// <see cref="ExtractRequiredId"/> because the legacy error code surface uses the
/// <c>api_key_id_request_failed</c> prefix specifically.
/// </summary>
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);
}
}

/// <summary>
/// Returns true when the response either is unparseable or carries a top-level <c>"error":true</c>
/// envelope (the wrapping shape Nyx applies to non-2xx HTTP responses from the upstream platform).
/// </summary>
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;
}
}

/// <summary>
/// Builds a single-line diagnostic string from a Nyx error envelope, surfacing the
/// <c>status</c>, <c>body</c>, and <c>message</c> fields when present.
/// </summary>
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";
}
}

/// <summary>
/// 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.
/// </summary>
public static async Task TryRollbackAsync(
Func<Task<string>> 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);
}
}

/// <summary>
/// Returns a client-safe failure reason. <see cref="InvalidOperationException"/> instances
/// thrown by the helpers in this class carry controlled, structured error codes (e.g.
/// <c>channel_bot_id_request_failed nyx_status=401 body=invalid app secret</c>) so they are
/// safe to surface verbatim. Anything else (HTTP transport errors, generic exceptions)
/// collapses to <c>provisioning_failed</c> 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.
/// </summary>
public static string SanitizeFailureReason(Exception ex) =>
ex is InvalidOperationException ? ex.Message : "provisioning_failed";
}
Loading
Loading