Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,16 @@ message TransportExtras {
string nyx_user_access_token = 5;
// The platform-native message id of the originating user message when Nyx relay can recover it.
string nyx_platform_message_id = 6;
// The Lark `union_id` (`on_*`) of the inbound sender. Tenant-stable and cross-app safe, so
// outbound delivery can target the user via `receive_id_type=union_id` even when the inbound
// event was relayed through a different Lark app than the outbound sender. Empty when the
// platform is not Lark or the relay payload could not surface a `union_id`.
string nyx_lark_union_id = 7;
// The Lark `chat_id` (`oc_*`) of the inbound conversation as observed by the relay-side Lark
// app. Cross-app safe within the tenant for groups/threads/channels (any app added to the
// chat can address it via `receive_id_type=chat_id`). For p2p DMs the chat_id is bot-specific
// and not cross-app safe; downstream senders prefer `nyx_lark_union_id` for p2p targets.
string nyx_lark_chat_id = 8;
}

// Represents one normalized activity flowing through the channel pipeline.
Expand Down
7 changes: 7 additions & 0 deletions agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderCardFlow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,13 @@ private static bool TryResolve(
decision = AgentBuilderFlowDecision.ToolCall(ListAgentsAction, """{"action":"list_agents"}""");
return true;

case ListTemplatesAction:
// The /agents card surfaces a `Templates` button (also reachable via the
// text-flow `/templates` slash command). Without this branch, clicking the
// button leaves the user with an unhandled card action and no feedback.
decision = AgentBuilderFlowDecision.ToolCall(ListTemplatesAction, """{"action":"list_templates"}""");
return true;

case AgentStatusAction:
if (!TryBuildAgentActionArguments(evt, "agent_status", out argumentsJson, out validationError))
{
Expand Down
50 changes: 42 additions & 8 deletions agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,7 @@ private async Task<string> CreateDailyReportAgentAsync(
?? await actorRuntime.CreateAsync<SkillRunnerGAgent>(agentId, ct);

var versionBefore = await queryPort.GetStateVersionAsync(agentId, ct) ?? -1;
var deliveryTarget = LarkConversationTargets.BuildFromInbound(
AgentToolRequestContext.TryGet(ChannelMetadataKeys.ChatType),
conversationId,
AgentToolRequestContext.TryGet(ChannelMetadataKeys.SenderId));
var deliveryTarget = ResolveDeliveryTarget(conversationId, agentId);
var initialize = new InitializeSkillRunnerCommand
{
SkillName = templateSpec.SkillName,
Expand Down Expand Up @@ -383,10 +380,7 @@ private async Task<string> CreateSocialMediaAgentAsync(
?? await actorRuntime.CreateAsync<WorkflowAgentGAgent>(agentId, ct);

var versionBefore = await queryPort.GetStateVersionAsync(agentId, ct) ?? -1;
var deliveryTarget = LarkConversationTargets.BuildFromInbound(
AgentToolRequestContext.TryGet(ChannelMetadataKeys.ChatType),
conversationId,
AgentToolRequestContext.TryGet(ChannelMetadataKeys.SenderId));
var deliveryTarget = ResolveDeliveryTarget(conversationId, agentId);
var initialize = new InitializeWorkflowAgentCommand
{
WorkflowId = workflowUpsert.Workflow.WorkflowId,
Expand Down Expand Up @@ -1417,6 +1411,46 @@ private static string NormalizeScopeId(string? value) =>
return normalized.Length == 0 ? null : normalized;
}

/// <summary>
/// Builds the typed Lark delivery target from the current AgentToolRequestContext and emits
/// a LogDebug breadcrumb when <see cref="LarkConversationTargets.BuildFromInbound"/> falls
/// back from the cross-app safe pair (union_id / chat_id) to the legacy open_id /
/// conversation_id path. The fallback flag is intentionally NOT persisted on
/// <c>SkillRunnerOutboundConfig</c> / <c>InitializeWorkflowAgentCommand</c> because the
/// downstream <see cref="LarkConversationTargets.Resolve"/> path treats any populated typed
/// pair as authoritative — so this is the only place the cross-app risk surfaces. Operators
/// correlating Lark <c>code:99992361 open_id cross app</c> rejections need this log line to
/// confirm whether the relay surfaced <c>union_id</c> at agent-create time.
/// </summary>
private LarkReceiveTarget ResolveDeliveryTarget(string conversationId, string agentId)
{
var chatType = AgentToolRequestContext.TryGet(ChannelMetadataKeys.ChatType);
var senderId = AgentToolRequestContext.TryGet(ChannelMetadataKeys.SenderId);
var unionId = AgentToolRequestContext.TryGet(ChannelMetadataKeys.LarkUnionId);
var chatId = AgentToolRequestContext.TryGet(ChannelMetadataKeys.LarkChatId);

var target = LarkConversationTargets.BuildFromInbound(
chatType,
conversationId,
senderId,
unionId,
chatId);

if (target.FellBackToPrefixInference)
{
_logger?.LogDebug(
"Agent builder fell back to legacy delivery target inference for {AgentId}: chatType={ChatType}, hasUnionId={HasUnionId}, hasLarkChatId={HasLarkChatId}, hasSenderId={HasSenderId}, resolvedReceiveIdType={ReceiveIdType}. Cross-app outbound (e.g. customer api-lark-bot) may surface Lark `99992361 open_id cross app` until the relay propagates union_id.",
agentId,
chatType ?? string.Empty,
!string.IsNullOrWhiteSpace(unionId),
!string.IsNullOrWhiteSpace(chatId),
!string.IsNullOrWhiteSpace(senderId),
target.ReceiveIdType);
}

return target;
}

private sealed class BuilderArgs
{
private readonly Dictionary<string, JsonElement> _properties;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,18 @@ private static IReadOnlyDictionary<string, string> BuildReplyMetadata(
if (!string.IsNullOrWhiteSpace(platformMessageId))
metadata[ChannelMetadataKeys.PlatformMessageId] = platformMessageId;

// Lark cross-app outbound delivery: agent-builder consumers prefer the tenant-stable
// union_id / chat_id captured at ingress over the relay-app-scoped open_id, so a
// mismatch between the relay-side Lark app and the customer's outbound Lark app does
// not surface as `code:99992361 open_id cross app` rejections at send time.
var larkUnionId = NormalizeOptional(activity?.TransportExtras?.NyxLarkUnionId);
if (!string.IsNullOrWhiteSpace(larkUnionId))
metadata[ChannelMetadataKeys.LarkUnionId] = larkUnionId;

var larkChatId = NormalizeOptional(activity?.TransportExtras?.NyxLarkChatId);
if (!string.IsNullOrWhiteSpace(larkChatId))
metadata[ChannelMetadataKeys.LarkChatId] = larkChatId;

return metadata;
}

Expand Down
15 changes: 15 additions & 0 deletions agents/Aevatar.GAgents.ChannelRuntime/ChannelMetadataKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,19 @@ public static class ChannelMetadataKeys
public const string MessageId = "channel.message_id";
public const string PlatformMessageId = "channel.platform_message_id";
public const string ChatType = "channel.chat_type";
/// <summary>
/// Lark <c>union_id</c> (<c>on_*</c>) of the inbound sender. Tenant-stable and cross-app safe;
/// downstream Lark senders prefer this over <see cref="SenderId"/> (<c>open_id</c>) for p2p
/// outbound delivery so a relay-app vs outbound-app mismatch does not produce
/// <c>open_id cross app</c> rejections from Lark. Empty when the platform is not Lark or the
/// relay did not surface a <c>union_id</c>.
/// </summary>
public const string LarkUnionId = "channel.lark.union_id";
/// <summary>
/// Lark <c>chat_id</c> (<c>oc_*</c>) as observed by the relay-side Lark app. Cross-app safe
/// within the tenant for groups/threads/channels. Downstream Lark senders prefer this for
/// non-p2p outbound delivery instead of inferring a chat_id from the routing
/// <see cref="ConversationId"/> (which may be a NyxID-internal route id).
/// </summary>
public const string LarkChatId = "channel.lark.chat_id";
}
Original file line number Diff line number Diff line change
Expand Up @@ -359,13 +359,30 @@ private async Task SendMessageAsync(

if (LarkProxyResponse.TryGetError(result, out var larkCode, out var detail))
{
throw new InvalidOperationException(
larkCode is { } code
? $"{failurePrefix} (code={code}): {detail}"
: $"{failurePrefix}: {detail}");
throw new InvalidOperationException(BuildLarkRejectionMessage(failurePrefix, larkCode, detail));
}
}

private static string BuildLarkRejectionMessage(string failurePrefix, int? larkCode, string detail)
{
if (larkCode == LarkBotErrorCodes.OpenIdCrossApp)
{
// Mirrors the SkillRunnerGAgent recovery hint: the workflow agent's catalog target
// was captured before union_id ingress existed and the persisted typed pair is
// permanently relay-app-scoped. Surface the recreate-the-agent instruction inside
// the exception message so it ends up in `/agent-status`'s `last_error` field
// instead of the cryptic Lark `99992361 open_id cross app`.
return
$"{failurePrefix} (code={larkCode}): {detail}. " +
"This workflow agent was created before cross-app union_id ingress existed; " +
"delete and recreate it (`/agents` → Delete → `/social-media`) to pick up the cross-app safe target.";
}

return larkCode is { } code
? $"{failurePrefix} (code={code}): {detail}"
: $"{failurePrefix}: {detail}";
}

private static bool SupportsApproveReject(HumanInteractionRequest request) =>
string.Equals(request.SuspensionType, "human_approval", StringComparison.OrdinalIgnoreCase) ||
(request.Options.Contains("approve", StringComparer.OrdinalIgnoreCase) &&
Expand Down
11 changes: 11 additions & 0 deletions agents/Aevatar.GAgents.ChannelRuntime/LarkBotErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,15 @@ internal static class LarkBotErrorCodes
/// config gap when the bot's app scope is missing the reaction permission.
/// </summary>
public const int NoPermissionToReact = 231002;

/// <summary>
/// "open_id cross app" — Lark <c>open_id</c> is app-scoped (each Lark app issues its own
/// <c>ou_*</c> for the same user). When relay-side ingress (e.g. NyxID's Lark app) and
/// outbound (e.g. customer's <c>api-lark-bot</c>) are different apps, sending to a
/// <c>receive_id_type=open_id</c> with the relay-app-scoped <c>ou_*</c> is rejected. Surfaces
/// on legacy SkillRunner / human-interaction state captured before <c>union_id</c> ingress
/// existed; rebuild the agent (e.g. <c>/agents</c> → Delete → <c>/daily</c>) to pin the new
/// cross-app safe pair.
/// </summary>
public const int OpenIdCrossApp = 99992361;
}
71 changes: 55 additions & 16 deletions agents/Aevatar.GAgents.ChannelRuntime/LarkConversationTargets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,29 +57,68 @@ public static string ResolveReceiveIdType(string? conversationId)
}

/// <summary>
/// Builds the typed receive-target for a Lark inbound captured at agent creation. For p2p we
/// store the user's open_id (always <c>ou_*</c>) so outbound DMs do not depend on the relay
/// also propagating an underlying chat_id; for everything else we send to the originating
/// chat via its <c>oc_*</c> chat_id, which Lark accepts uniformly for groups, threads, and
/// channels.
/// Builds the typed receive-target for a Lark inbound captured at agent creation.
///
/// If the inbound is p2p but the relay omitted <c>SenderId</c>, returning a typed pair would
/// silently re-create the original /daily 400 (typing the user open_id as <c>chat_id</c>).
/// Instead, return an empty typed pair with <c>FellBackToPrefixInference=true</c> so
/// <see cref="Resolve"/> falls back to the legacy prefix path and call sites emit a Debug
/// breadcrumb. The relay always emits <c>Sender.PlatformId</c> in production, so this path
/// is defensive.
/// <para>
/// <b>p2p (DM):</b> prefer the tenant-stable <c>union_id</c> (<c>on_*</c>) when the relay
/// surfaces it. <c>union_id</c> is cross-app safe within the tenant — Lark accepts it as a
/// <c>receive_id_type=union_id</c> target regardless of whether the relay-side ingress app
/// matches the customer's outbound app. Without union_id we fall back to the sender
/// <c>open_id</c> (<c>ou_*</c>), which is app-scoped and produces
/// <c>code:99992361 open_id cross app</c> when the two apps differ; the fallback flips
/// <c>FellBackToPrefixInference=true</c> so the call site emits a Debug breadcrumb and
/// operators can correlate Lark rejections with missing-union_id ingress.
/// </para>
///
/// <para>
/// <b>group / channel / thread:</b> prefer the inbound Lark <c>chat_id</c> (<c>oc_*</c>) which
/// is tenant-scoped — any app added to the chat can address it via
/// <c>receive_id_type=chat_id</c>. Without an explicit Lark chat_id the helper falls back to
/// the routing <paramref name="conversationId"/>, which works only when the routing id is
/// itself a Lark <c>oc_*</c>; otherwise the outbound proxy will surface a Lark validation
/// failure that the call site logs and retries.
/// </para>
///
/// <para>
/// If the inbound is p2p but the relay omitted both <c>union_id</c> and <c>senderId</c>,
/// returning a typed pair would silently re-create the original /daily 400 (typing the user
/// open_id as <c>chat_id</c>). Instead, return an empty typed pair with
/// <c>FellBackToPrefixInference=true</c> so <see cref="Resolve"/> falls back to the legacy
/// prefix path and call sites emit a Debug breadcrumb.
/// </para>
/// </summary>
public static LarkReceiveTarget BuildFromInbound(string? chatType, string? conversationId, string? senderId)
public static LarkReceiveTarget BuildFromInbound(
string? chatType,
string? conversationId,
string? senderId,
string? larkUnionId = null,
string? larkChatId = null)
{
var trimmedSender = (senderId ?? string.Empty).Trim();
if (IsDirectMessage(chatType))
{
return string.IsNullOrEmpty(trimmedSender)
? new LarkReceiveTarget(string.Empty, string.Empty, FellBackToPrefixInference: true)
: new LarkReceiveTarget(trimmedSender, OpenIdReceiveIdType, FellBackToPrefixInference: false);
// Cross-app safe: tenant-stable user identifier, accepted by any Lark app.
var trimmedUnion = (larkUnionId ?? string.Empty).Trim();
if (!string.IsNullOrEmpty(trimmedUnion))
return new LarkReceiveTarget(trimmedUnion, UnionIdReceiveIdType, FellBackToPrefixInference: false);

// Fallback: app-scoped open_id. Will surface `code:99992361 open_id cross app` from
// Lark when the relay-side ingress app does not match the customer's outbound app.
// Flag the fallback so call sites can LogDebug for incident correlation.
var trimmedSender = (senderId ?? string.Empty).Trim();
if (!string.IsNullOrEmpty(trimmedSender))
return new LarkReceiveTarget(trimmedSender, OpenIdReceiveIdType, FellBackToPrefixInference: true);

return new LarkReceiveTarget(string.Empty, string.Empty, FellBackToPrefixInference: true);
}

// group / channel / thread: prefer the inbound Lark chat_id (cross-app within tenant).
var trimmedChat = (larkChatId ?? string.Empty).Trim();
if (!string.IsNullOrEmpty(trimmedChat))
return new LarkReceiveTarget(trimmedChat, DefaultReceiveIdType, FellBackToPrefixInference: false);

// Fallback: assume the routing conversation_id is a Lark `oc_*` (legacy behavior pre
// ingress-side chat_id capture). If it is not, the proxy will reject and the call site
// logs the surfaced Lark error.
var trimmedConversation = (conversationId ?? string.Empty).Trim();
return new LarkReceiveTarget(trimmedConversation, DefaultReceiveIdType, FellBackToPrefixInference: false);
}
Expand Down
Loading
Loading