diff --git a/agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj b/agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj index 7c0b51cba..b007131c8 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj +++ b/agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj @@ -31,6 +31,8 @@ + + diff --git a/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTemplates.cs b/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTemplates.cs index 9a9d63289..c1e8cf737 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTemplates.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTemplates.cs @@ -81,6 +81,8 @@ public static bool TryBuildSocialMediaSpec( string topic, string? audience, string? style, + string? deliveryProviderSlug, + string? publishProviderSlug, out SocialMediaTemplateSpec? spec, out string? error) { @@ -103,6 +105,8 @@ public static bool TryBuildSocialMediaSpec( var normalizedAudience = NormalizeOptional(audience) ?? "general followers"; var normalizedStyle = NormalizeOptional(style) ?? "clear, concise, and professional"; + var normalizedDeliverySlug = NormalizeOptional(deliveryProviderSlug) ?? "api-lark-bot"; + var normalizedPublishSlug = NormalizeOptional(publishProviderSlug) ?? "api-twitter"; var workflowId = BuildSocialMediaWorkflowId(normalizedAgentId); var workflowName = BuildSocialMediaWorkflowName(normalizedAgentId); var displayName = $"Social Media Approval {normalizedAgentId}"; @@ -117,8 +121,10 @@ public static bool TryBuildSocialMediaSpec( normalizedAgentId, normalizedTopic, normalizedAudience, - normalizedStyle), - ExecutionPrompt: executionPrompt); + normalizedStyle, + normalizedPublishSlug), + ExecutionPrompt: executionPrompt, + RequiredServiceSlugs: [normalizedDeliverySlug, normalizedPublishSlug]); return true; } @@ -133,11 +139,12 @@ private static string BuildSocialMediaWorkflowYaml( string deliveryTargetId, string topic, string audience, - string style) + string style, + string publishProviderSlug) { return $$""" name: {{workflowName}} - description: Generate a social media draft and request human approval in Feishu. + description: Generate a social media draft, request human approval in Feishu, and publish the approved post to Twitter (X). roles: - id: writer @@ -170,9 +177,19 @@ Draft one short social media post. delivery_target_id: "{{EscapeDoubleQuoted(deliveryTargetId)}}" on_reject: skip branches: - "true": done + "true": publish_to_twitter "false": done + - id: publish_to_twitter + type: twitter_publish + parameters: + publish_provider_slug: "{{EscapeDoubleQuoted(publishProviderSlug)}}" + delivery_target_id: "{{EscapeDoubleQuoted(deliveryTargetId)}}" + on_error: + strategy: skip + default_output: "twitter_publish_failed" + next: done + - id: done type: assign parameters: @@ -230,4 +247,5 @@ internal sealed record SocialMediaTemplateSpec( string WorkflowName, string DisplayName, string WorkflowYaml, - string ExecutionPrompt); + string ExecutionPrompt, + IReadOnlyList RequiredServiceSlugs); diff --git a/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs b/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs index b429a4c49..b76341277 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs @@ -100,6 +100,10 @@ public AgentBuilderTool( "type": "string", "description": "Outbound Nyx proxy slug (default: api-lark-bot)" }, + "publish_provider_slug": { + "type": "string", + "description": "Optional Nyx proxy slug used to publish approved content (default: api-twitter for the social_media template)" + }, "run_immediately": { "type": "boolean", "description": "When true, trigger one execution right after creation" @@ -373,9 +377,12 @@ private async Task CreateSocialMediaAgentAsync( return """{"error":"Could not resolve current NyxID user id"}"""; var providerSlug = (args.Str("nyx_provider_slug") ?? "api-lark-bot").Trim(); - var requiredServiceIds = await ResolveProxyServiceIdsAsync(nyxClient, token, [providerSlug], ct); - if (requiredServiceIds.errorJson != null) - return requiredServiceIds.errorJson; + // The social_media template now publishes the approved post to Twitter (X) via the + // api-twitter NyxID proxy in addition to delivering the approval card via api-lark-bot + // (issue #216). Mint the agent api-key with both slugs so a single key carries both + // entitlements; without api-twitter here, NyxID's `allowed_service_ids` enforcement + // (api_keys.rs / proxy.rs) would 403 every publish call regardless of OAuth scope. + var publishProviderSlug = (args.Str("publish_provider_slug") ?? "api-twitter").Trim(); var agentId = string.IsNullOrWhiteSpace(args.Str("agent_id")) ? WorkflowAgentDefaults.GenerateActorId() @@ -386,12 +393,27 @@ private async Task CreateSocialMediaAgentAsync( args.Str("topic") ?? string.Empty, args.Str("audience"), args.Str("style"), + providerSlug, + publishProviderSlug, out var templateSpec, out var templateError)) { return JsonSerializer.Serialize(new { error = templateError }); } + // Resolve service IDs from the spec's authoritative slug list (parity with + // daily_report's TemplateSpec.RequiredServiceSlugs — PR #461 review item #6). Inlined + // hardcoded `[providerSlug, publishProviderSlug]` was fine for two slugs but would + // drift if a third slug were ever added; route through the spec so the source of + // truth lives next to the workflow YAML. + var requiredServiceIds = await ResolveProxyServiceIdsAsync( + nyxClient, + token, + templateSpec!.RequiredServiceSlugs, + ct); + if (requiredServiceIds.errorJson != null) + return requiredServiceIds.errorJson; + var createKeyResponse = await nyxClient.CreateApiKeyAsync( token, BuildCreateApiKeyPayload(agentId, requiredServiceIds.value!), @@ -403,6 +425,18 @@ private async Task CreateSocialMediaAgentAsync( if (!TryParseApiKeyCreateResponse(createKeyResponse, out var apiKeyId, out var apiKeyValue, out var apiKeyError)) return JsonSerializer.Serialize(new { error = apiKeyError }); + // Mirror the daily_report preflight (#411 / #418) for Twitter: the user may not have + // connected Twitter at NyxID yet, or may have revoked the OAuth grant at x.com between + // connect-time and create-time. Surfacing 401/403 here keeps us from persisting a + // social_media agent whose every approved post would fail at publish time. Best-effort + // revoke the freshly minted key on failure so retries don't accumulate orphan keys. + var preflight = await PreflightTwitterProxyAsync(nyxClient, apiKeyValue!, publishProviderSlug, ct); + if (preflight is not null) + { + await BestEffortRevokeApiKeyAsync(nyxClient, token, apiKeyId!, "twitter_preflight_failed", ct); + return preflight; + } + var workflowUpsert = await workflowCommandPort.UpsertAsync( new ScopeWorkflowUpsertRequest( scopeId.Trim(), @@ -1812,6 +1846,103 @@ private static string NormalizeScopeId(string? value) => } } + /// + /// Preflights Twitter (X) proxy access using the newly created agent API key against + /// Twitter's /users/me — a cheap read-only endpoint that returns 401 when NyxID has + /// no OAuth grant for the user (or the grant was revoked) and 403 when the bound token + /// lacks tweet.write scope. Returns a structured error JSON suitable for returning + /// verbatim from the tool when access is denied; returns null on success or on + /// probe shapes we don't classify as "fundamentally broken" (rate limits, 5xx). + /// + /// + /// Mirrors (issue aevatarAI/aevatar#216 / #418). + /// Two error codes instead of one because 401 and 403 lead to different user actions: + /// 401 means "go connect Twitter at NyxID" (or re-authorize a revoked grant); 403 means + /// "the bound token is missing tweet.write — operator/seed bug, not user fixable". + /// The freshly minted api-key is best-effort revoked at the call site so retries don't + /// accumulate orphan proxy-scoped keys. + /// + private async Task PreflightTwitterProxyAsync( + NyxIdApiClient nyxClient, + string apiKey, + string nyxProviderSlug, + CancellationToken ct) + { + // Cheap read-only endpoint; succeeds with the default `users.read` scope, fails with + // 401 when no OAuth grant is bound to the user behind the api-key, and 403 when the + // bound token's scope set is too narrow. + var probe = await nyxClient.ProxyRequestAsync( + apiKey, + "api-twitter", + "/users/me", + "GET", + body: null, + extraHeaders: null, + ct); + + if (string.IsNullOrWhiteSpace(probe)) + return null; + + try + { + using var doc = JsonDocument.Parse(probe); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + return null; + + if (!root.TryGetProperty("error", out var errorProp)) + return null; + if (errorProp.ValueKind != JsonValueKind.True && errorProp.ValueKind != JsonValueKind.String) + return null; + + var status = TryReadInt32Property(root, "status") + ?? TryReadInt32Property(root, "code") + ?? 0; + if (status != (int)HttpStatusCode.Unauthorized && status != (int)HttpStatusCode.Forbidden) + return null; + + var detail = root.TryGetProperty("message", out var msgProp) && msgProp.ValueKind == JsonValueKind.String + ? msgProp.GetString() + : null; + var body = root.TryGetProperty("body", out var bodyProp) && bodyProp.ValueKind == JsonValueKind.String + ? bodyProp.GetString() + : null; + + // 401 vs 403 distinction is the actionable difference for the user. NyxID seeds + // `tweet.write` into the default scope set (provider_service.rs:405-450), so the + // realistic 401 path is "user has not connected Twitter yet at NyxID" or "the + // user revoked the grant at x.com/settings". A 403 here would mean either the + // seed regressed (ops escalation) or x.com itself denied the request body — keep + // both paths separate so the hint copy steers the right person. + if (status == (int)HttpStatusCode.Unauthorized) + { + return JsonSerializer.Serialize(new + { + error = "twitter_oauth_required", + detail = string.IsNullOrWhiteSpace(detail) ? "Twitter proxy returned 401 for the new agent API key." : detail, + http_status = status, + proxy_body = string.IsNullOrWhiteSpace(body) ? null : body, + hint = "Twitter (X) returned 401 through the NyxID proxy. The user has not connected Twitter at NyxID, or the OAuth grant was revoked at x.com/settings/connected_apps. Re-authorize the Twitter provider at NyxID before retrying agent creation.", + nyx_provider_slug = nyxProviderSlug, + }); + } + + return JsonSerializer.Serialize(new + { + error = "twitter_proxy_access_denied", + detail = string.IsNullOrWhiteSpace(detail) ? "Twitter proxy returned 403 for the new agent API key." : detail, + http_status = status, + proxy_body = string.IsNullOrWhiteSpace(body) ? null : body, + hint = "Twitter (X) returned 403 through the NyxID proxy. Default provider scope includes `tweet.write`; a 403 here usually means the seeded provider scope was downgraded or the bound token was issued before the scope was widened. Re-authorize at NyxID; if it still fails, ask ops to verify the Twitter provider seed includes `tweet.write`.", + nyx_provider_slug = nyxProviderSlug, + }); + } + catch (JsonException) + { + return null; + } + } + private static int? TryReadInt32Property(JsonElement element, string propertyName) { if (!element.TryGetProperty(propertyName, out var property) || diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ChannelMetadataKeys.cs b/agents/Aevatar.GAgents.ChannelRuntime/ChannelMetadataKeys.cs index 0ea1fac3b..dfd1fc3bf 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ChannelMetadataKeys.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/ChannelMetadataKeys.cs @@ -28,4 +28,23 @@ public static class ChannelMetadataKeys /// (which may be a NyxID-internal route id). /// public const string LarkChatId = "channel.lark.chat_id"; + /// + /// Authoritative outbound Lark receive_id for the current workflow run, captured at + /// agent-create time. Propagated via WorkflowChatRunRequest.Metadata so workflow + /// modules (e.g. TwitterPublishModule) can surface their result back into the same + /// chat without having to look up the catalog at execution time. + /// + public const string LarkReceiveId = "channel.lark.receive_id"; + /// Companion to — its receive_id_type. + public const string LarkReceiveIdType = "channel.lark.receive_id_type"; + /// + /// NyxID outbound proxy slug used to deliver Lark messages from inside a workflow run + /// (default api-lark-bot). The outbound qualifier is deliberate — this is + /// specifically the routing target for Lark send calls (e.g. + /// open-apis/im/v1/messages) initiated by the workflow runtime, not a generic Lark + /// API field. PR #461 review item #4 flagged the original name (channel.lark.proxy_slug) + /// as ambiguous between "Lark API surface" and "NyxID provider routing" — the + /// outbound_proxy_slug form makes the routing-side semantics explicit. + /// + public const string LarkOutboundProxySlug = "channel.lark.outbound_proxy_slug"; } diff --git a/agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs index 6899b4455..aa9d25ad9 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs @@ -14,6 +14,7 @@ using Aevatar.GAgents.Channel.NyxIdRelay; using Aevatar.GAgents.Platform.Lark; using Aevatar.GAgents.ChannelRuntime.Outbound; +using Aevatar.GAgents.ChannelRuntime.WorkflowModules; using Aevatar.Foundation.Abstractions.HumanInteraction; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -234,6 +235,11 @@ public static IServiceCollection AddChannelRuntime( services.TryAddSingleton(sp => sp.GetRequiredService()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); + // Register the channel-runtime-owned workflow module pack so the social_media template's + // `twitter_publish` step type resolves at workflow run time. AddWorkflowModulePack uses + // TryAddEnumerable, so calling this alongside AddAevatarWorkflow is safe and idempotent. + services.AddChannelRuntimeWorkflowExtensions(); + return services; } diff --git a/agents/Aevatar.GAgents.ChannelRuntime/WorkflowAgentGAgent.cs b/agents/Aevatar.GAgents.ChannelRuntime/WorkflowAgentGAgent.cs index 671cf012b..90f401376 100644 --- a/agents/Aevatar.GAgents.ChannelRuntime/WorkflowAgentGAgent.cs +++ b/agents/Aevatar.GAgents.ChannelRuntime/WorkflowAgentGAgent.cs @@ -188,6 +188,16 @@ private IReadOnlyDictionary BuildExecutionMetadata() }; if (!string.IsNullOrWhiteSpace(State.ScopeId)) metadata["scope_id"] = State.ScopeId; + // Propagate the outbound Lark delivery target so workflow modules that need to surface + // their own status messages back into the originating chat (e.g. TwitterPublishModule + // posting "已发布: " or "Twitter OAuth 过期…") can do so via the same api-lark-bot + // proxy this agent already uses, without re-resolving the catalog at run time. + if (!string.IsNullOrWhiteSpace(State.LarkReceiveId)) + metadata[ChannelMetadataKeys.LarkReceiveId] = State.LarkReceiveId; + if (!string.IsNullOrWhiteSpace(State.LarkReceiveIdType)) + metadata[ChannelMetadataKeys.LarkReceiveIdType] = State.LarkReceiveIdType; + if (!string.IsNullOrWhiteSpace(State.NyxProviderSlug)) + metadata[ChannelMetadataKeys.LarkOutboundProxySlug] = State.NyxProviderSlug; return metadata; } diff --git a/agents/Aevatar.GAgents.ChannelRuntime/WorkflowModules/ChannelRuntimeWorkflowModulePack.cs b/agents/Aevatar.GAgents.ChannelRuntime/WorkflowModules/ChannelRuntimeWorkflowModulePack.cs new file mode 100644 index 000000000..6e5d07e0c --- /dev/null +++ b/agents/Aevatar.GAgents.ChannelRuntime/WorkflowModules/ChannelRuntimeWorkflowModulePack.cs @@ -0,0 +1,28 @@ +using Aevatar.Workflow.Core; +using Aevatar.Workflow.Core.Composition; + +namespace Aevatar.GAgents.ChannelRuntime.WorkflowModules; + +/// +/// Workflow module pack contributed by ChannelRuntime — currently registers +/// for the social_media template's +/// twitter_publish step (issue aevatarAI/aevatar#216). Lives next to its +/// dependencies (NyxIdApiClient, , +/// ) instead of in Aevatar.Workflow.Core so the +/// generic workflow runtime stays free of channel-specific compile-time coupling. +/// +public sealed class ChannelRuntimeWorkflowModulePack : IWorkflowModulePack +{ + private static readonly IReadOnlyList ModuleRegistrations = + [ + WorkflowModuleRegistration.Create("twitter_publish"), + ]; + + public string Name => "channelruntime.workflow"; + + public IReadOnlyList Modules => ModuleRegistrations; + + public IReadOnlyList DependencyExpanders => []; + + public IReadOnlyList Configurators => []; +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/WorkflowModules/ServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.ChannelRuntime/WorkflowModules/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..cf24b10a0 --- /dev/null +++ b/agents/Aevatar.GAgents.ChannelRuntime/WorkflowModules/ServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Aevatar.Workflow.Core; +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.GAgents.ChannelRuntime.WorkflowModules; + +/// +/// DI extension to register the ChannelRuntime workflow module pack. Hosts that compose +/// social_media template execution should call this so the twitter_publish step +/// type resolves at workflow run time. +/// +public static class ChannelRuntimeWorkflowModuleServiceCollectionExtensions +{ + /// + /// Registers alongside any other module + /// packs already added to the workflow runtime. Idempotent — uses + /// TryAddEnumerable via . + /// + public static IServiceCollection AddChannelRuntimeWorkflowExtensions(this IServiceCollection services) => + services.AddWorkflowModulePack(); +} diff --git a/agents/Aevatar.GAgents.ChannelRuntime/WorkflowModules/TwitterPublishModule.cs b/agents/Aevatar.GAgents.ChannelRuntime/WorkflowModules/TwitterPublishModule.cs new file mode 100644 index 000000000..c6d0af4b1 --- /dev/null +++ b/agents/Aevatar.GAgents.ChannelRuntime/WorkflowModules/TwitterPublishModule.cs @@ -0,0 +1,546 @@ +// ───────────────────────────────────────────────────────────── +// TwitterPublishModule — 把 social_media 模板批准后的内容发布到 X (Twitter) +// 通过 NyxID `api-twitter` 代理调用 POST /tweets,结果同步回 Lark。 +// 见 issue aevatarAI/aevatar#216 — 接续 #418 的 PreflightTwitterProxyAsync。 +// ───────────────────────────────────────────────────────────── + +using System.Net; +using System.Text.Json; +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.AI.ToolProviders.NyxId; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.EventModules; +using Aevatar.GAgents.ChannelRuntime; +using Aevatar.Workflow.Abstractions; +using Aevatar.Workflow.Abstractions.Execution; +using Aevatar.Workflow.Core.Execution; +using Aevatar.Workflow.Core.Primitives; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.ChannelRuntime.WorkflowModules; + +/// +/// Twitter (X) 发布模块。处理 step_type == "twitter_publish"。 +/// 用 social_media agent 在 NyxID 中预先 mint 的 api-key 调 api-twitter 代理把已批准 +/// 的草稿发布到 Twitter,并把结果(推文 URL 或分类好的错误文案)回写到原始 Lark 会话。 +/// +/// +/// 与 LLM/工具调用路径不同——发布是确定性的:批准的内容直接进入 POST /2/tweets,没有 +/// 模型重写余地。把这一段建在工作流 module 而不是 LLM step 里也更可重入:模型偶尔丢工具调用、 +/// 或返回非结构化文本,但发布行为必须严格 1:1。 +/// +public sealed class TwitterPublishModule : IEventModule +{ + public string Name => "twitter_publish"; + public int Priority => 5; + + public bool CanHandle(EventEnvelope envelope) => + envelope.Payload?.Is(StepRequestEvent.Descriptor) == true; + + public async Task HandleAsync(EventEnvelope envelope, IWorkflowExecutionContext ctx, CancellationToken ct) + { + var request = envelope.Payload!.Unpack(); + if (request.StepType != "twitter_publish") return; + + var content = (request.Input ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(content)) + { + await PublishFailureAsync( + ctx, + request, + code: "twitter_publish_empty_content", + message: "Approved content was empty; nothing to publish.", + logger: ctx.Logger, + ct); + return; + } + + var nyxClient = ctx.Services.GetService(); + if (nyxClient is null) + { + await PublishFailureAsync( + ctx, + request, + code: "twitter_publish_client_missing", + message: "NyxIdApiClient is not registered; cannot publish.", + logger: ctx.Logger, + ct); + return; + } + + if (!WorkflowExecutionItemsAccess.TryGetItem( + ctx, + LLMRequestMetadataKeys.NyxIdAccessToken, + out var apiKeyValue) || + string.IsNullOrWhiteSpace(apiKeyValue)) + { + await PublishFailureAsync( + ctx, + request, + code: "twitter_publish_api_key_missing", + message: "Workflow execution context did not carry a NyxID api-key. Re-create the agent so the new outbound config propagates.", + logger: ctx.Logger, + ct); + return; + } + + var requestMetadata = new Dictionary(StringComparer.Ordinal); + WorkflowRequestMetadataItemsAccess.CopyRequestMetadata(ctx, requestMetadata); + + var publishSlug = WorkflowParameterValueParser.GetString( + request.Parameters, + "api-twitter", + "publish_provider_slug", + "nyx_publish_provider_slug", + "publish_slug"); + + var deliveryTargetId = WorkflowParameterValueParser.GetString( + request.Parameters, + string.Empty, + "delivery_target_id"); + + // Twitter v2 endpoint requires `text` payload only for plain-text posts (#216 v1 scope: + // no media, no thread, no poll). Body is JSON, content-type is set by NyxIdApiClient. + // + // Idempotency caveat (PR #461 review item #1): Twitter v2 `POST /2/tweets` has no + // server-side dedup. If this step is retried (e.g. via a `retry` policy on the YAML, or + // a workflow restart that replays an in-flight `StepRequestEvent`), the same content + // will be posted twice. The social_media template intentionally does NOT define a + // `retry` policy on this step, and the `on_error: skip` policy advances to `done` + // rather than retrying. Authors customizing the YAML should keep this invariant — do + // not add `retry: { max_attempts: > 1 }` here without first wiring a client-side dedup + // key (e.g. hashing run_id+step_id+content into a NyxID-side request idempotency + // header) or accepting duplicate posts as a known risk. + var tweetBody = JsonSerializer.Serialize(new { text = content }); + + string proxyResponse; + try + { + proxyResponse = await nyxClient.ProxyRequestAsync( + apiKeyValue!, + publishSlug, + "/2/tweets", + "POST", + tweetBody, + extraHeaders: null, + ct); + } + catch (Exception ex) + { + ctx.Logger.LogWarning( + ex, + "TwitterPublish: run={RunId} step={StepId} unhandled exception while calling api-twitter", + request.RunId, + request.StepId); + await PublishFailureAsync( + ctx, + request, + code: "twitter_publish_transport_error", + message: $"NyxID proxy transport error: {ex.Message}", + logger: ctx.Logger, + ct); + await TrySendLarkAsync( + nyxClient, + requestMetadata, + apiKeyValue!, + deliveryTargetId, + $"Twitter 发布失败(网络错误):{ex.Message}", + ctx.Logger, + ct); + return; + } + + var outcome = ClassifyTwitterResponse(proxyResponse); + + if (outcome.Success && !string.IsNullOrEmpty(outcome.TweetUrl)) + { + ctx.Logger.LogInformation( + "TwitterPublish: run={RunId} step={StepId} published tweet={TweetUrl}", + request.RunId, + request.StepId, + outcome.TweetUrl); + + var successMessage = $"已发布: {outcome.TweetUrl}"; + await TrySendLarkAsync( + nyxClient, + requestMetadata, + apiKeyValue!, + deliveryTargetId, + successMessage, + ctx.Logger, + ct); + + var completed = new StepCompletedEvent + { + StepId = request.StepId, + RunId = request.RunId, + Success = true, + Output = outcome.TweetUrl!, + }; + await ctx.PublishAsync(completed, TopologyAudience.Self, ct); + return; + } + + ctx.Logger.LogWarning( + "TwitterPublish: run={RunId} step={StepId} publish failed code={Code} status={Status} detail={Detail}", + request.RunId, + request.StepId, + outcome.ErrorCode, + outcome.HttpStatus, + outcome.Detail); + + await TrySendLarkAsync( + nyxClient, + requestMetadata, + apiKeyValue!, + deliveryTargetId, + outcome.LarkMessage, + ctx.Logger, + ct); + + await PublishFailureAsync( + ctx, + request, + code: outcome.ErrorCode, + message: outcome.Detail, + logger: ctx.Logger, + ct); + } + + private static Task PublishFailureAsync( + IWorkflowExecutionContext ctx, + StepRequestEvent request, + string code, + string message, + ILogger logger, + CancellationToken ct) + { + // The social_media template's `publish_to_twitter` step routes its failure into the + // `done` terminal so the run finishes cleanly even if Twitter rejected the post — + // the failure is surfaced to Lark independently. Mark Success=false so callers / + // observability see the failed publish, but emit the error string verbatim so the + // workflow output preserves the categorized code. + var failed = new StepCompletedEvent + { + StepId = request.StepId, + RunId = request.RunId, + Success = false, + Output = $"{code}: {message}", + Error = $"{code}: {message}", + }; + return ctx.PublishAsync(failed, TopologyAudience.Self, ct); + } + + /// + /// Surfaces a status message back to the originating Lark conversation via the same NyxID + /// api-key used to publish the tweet. Best-effort: a Lark delivery failure must never abort + /// the workflow's own bookkeeping (which is what publishes StepCompletedEvent). + /// + /// + /// PR #461 review item #5: this method depends on the api-key carrying both the + /// api-twitter AND the Lark proxy slug (e.g. api-lark-bot) entitlements at + /// mint time — see CreateSocialMediaAgentAsync in AgentBuilderTool.cs, which + /// resolves both slugs through ResolveProxyServiceIdsAsync before + /// CreateApiKeyAsync. If a future change narrows the api-key to only Twitter, the + /// Lark surfacing here will silently 403 — keep the dual-scope mint contract in lock-step + /// with this method, or pass a dedicated Lark api-key through metadata. + /// + private static async Task TrySendLarkAsync( + NyxIdApiClient nyxClient, + IReadOnlyDictionary requestMetadata, + string apiKey, + string fallbackReceiveId, + string text, + ILogger logger, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(text)) + return; + + var receiveId = TryGet(requestMetadata, ChannelMetadataKeys.LarkReceiveId); + var receiveIdType = TryGet(requestMetadata, ChannelMetadataKeys.LarkReceiveIdType); + var larkSlug = TryGet(requestMetadata, ChannelMetadataKeys.LarkOutboundProxySlug) ?? "api-lark-bot"; + + // Fallback: when the workflow agent's outbound metadata is unavailable, treat the + // step's `delivery_target_id` (which is the agent_id, i.e. the Lark receive_id under + // open_id naming for p2p chats) as a best-effort target. + if (string.IsNullOrWhiteSpace(receiveId)) + { + receiveId = fallbackReceiveId; + receiveIdType = string.IsNullOrWhiteSpace(receiveIdType) ? "open_id" : receiveIdType; + } + + if (string.IsNullOrWhiteSpace(receiveId) || string.IsNullOrWhiteSpace(receiveIdType)) + { + logger.LogWarning( + "TwitterPublish: skipping Lark surfacing — outbound delivery target metadata missing (receive_id/type empty)."); + return; + } + + try + { + var body = JsonSerializer.Serialize(new + { + receive_id = receiveId, + msg_type = "text", + content = JsonSerializer.Serialize(new { text }), + }); + + var response = await nyxClient.ProxyRequestAsync( + apiKey, + larkSlug, + $"open-apis/im/v1/messages?receive_id_type={receiveIdType}", + "POST", + body, + extraHeaders: null, + ct); + + if (LarkProxyResponse.TryGetError(response, out var larkCode, out var detail)) + { + logger.LogWarning( + "TwitterPublish: Lark surfacing rejected (code={Code}): {Detail}", + larkCode, + detail); + } + } + catch (Exception ex) + { + // Lark surfacing is best-effort: a failure here must not abort the workflow's + // own bookkeeping (which is what publishes StepCompletedEvent). Log and move on. + logger.LogWarning(ex, "TwitterPublish: Lark surfacing threw"); + } + } + + private static string? TryGet(IReadOnlyDictionary map, string key) + { + if (!map.TryGetValue(key, out var value)) + return null; + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + /// + /// Classifies a NyxID proxy response from POST /api/v1/proxy/s/api-twitter/2/tweets + /// into a publish outcome. Three shapes are recognized: + /// + /// Twitter 2xx success: { "data": { "id": "<tweet-id>" } } (NyxID forwards + /// the body verbatim). + /// NyxID-wrapped non-2xx: { "error": true, "status": <http>, "body": + /// "<raw downstream body>" } (NyxIdApiClient.cs:680). + /// Twitter v2 native error: { "errors": [ { "message": "...", "code": ... } ], + /// "title": "...", "detail": "..." } — Twitter sometimes returns 4xx with this shape + /// at the top level (PR #461 review item #2). NyxID forwards verbatim, so we parse it as + /// a fallback when neither data.id nor the NyxID-wrapped envelope is present. + /// + /// + internal static TwitterPublishOutcome ClassifyTwitterResponse(string? response) + { + if (string.IsNullOrWhiteSpace(response)) + { + return TwitterPublishOutcome.Failure( + "twitter_publish_empty_response", + "NyxID proxy returned an empty response.", + httpStatus: 0, + larkMessage: "Twitter 发布失败:NyxID 代理返回空响应"); + } + + try + { + using var doc = JsonDocument.Parse(response); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return TwitterPublishOutcome.Failure( + "twitter_publish_unexpected_shape", + "Response root was not a JSON object.", + httpStatus: 0, + larkMessage: "Twitter 发布失败:响应格式异常"); + } + + var hasErrorFlag = root.TryGetProperty("error", out var errorProp) && + (errorProp.ValueKind == JsonValueKind.True || + errorProp.ValueKind == JsonValueKind.String); + + // Success path: Twitter returns `{ "data": { "id": "...", "text": "..." } }`. NyxID + // forwards 2xx bodies verbatim, so the absence of an `error` field combined with a + // present `data.id` is the success signal. + if (!hasErrorFlag && + root.TryGetProperty("data", out var dataProp) && + dataProp.ValueKind == JsonValueKind.Object && + dataProp.TryGetProperty("id", out var idProp) && + idProp.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(idProp.GetString())) + { + var tweetId = idProp.GetString()!; + // Twitter accepts `https://x.com/i/web/status/` without a handle; resolves + // to the canonical `/status/` URL after redirect. The issue calls + // for a `users/me` lookup to resolve the handle, but that's an extra round-trip + // that can also 401 (and we already have a tweet id at this point). Fall back + // to the no-handle URL — the user always lands on the right tweet either way. + return TwitterPublishOutcome.Successful($"https://x.com/i/web/status/{tweetId}"); + } + + // Failure path A: NyxID wraps non-2xx as { error: true, status: , body: }. + if (hasErrorFlag) + { + var nyxStatus = TryReadInt32(root, "status") ?? TryReadInt32(root, "code") ?? 0; + var nyxDetail = TryReadString(root, "message") ?? TryReadString(root, "body") ?? "Twitter publish failed"; + var nyxBody = TryReadString(root, "body"); + return ClassifyByStatus(nyxStatus, nyxDetail, nyxBody); + } + + // Failure path B (PR #461 review item #2): Twitter v2 native error shape, forwarded + // by NyxID without a wrap envelope. Common for content-policy and duplicate-tweet + // rejections, e.g. `{"title":"Conflict","detail":"...","errors":[{"message":"...", + // "code":187}]}`. We don't have an HTTP status here (NyxID swallowed it), so the + // classification falls through to a generic `twitter_publish_rejected`, but we + // surface the rich Twitter error text so users can read the actual reason. + if (TryParseTwitterNativeError(root, out var nativeOutcome)) + return nativeOutcome; + + return TwitterPublishOutcome.Failure( + "twitter_publish_unexpected_shape", + "Response did not match success, NyxID-wrapped, or Twitter-native error shapes.", + httpStatus: 0, + larkMessage: "Twitter 发布失败:响应格式异常,请联系 ops 检查 NyxID 代理日志。"); + } + catch (JsonException) + { + return TwitterPublishOutcome.Failure( + "twitter_publish_unparseable_response", + "NyxID proxy returned a non-JSON response.", + httpStatus: 0, + larkMessage: "Twitter 发布失败:响应不是合法 JSON"); + } + } + + /// + /// Parses a Twitter v2 native error shape (no NyxID wrap envelope). Twitter returns these + /// at the top level for some 4xx rejections (content-policy violations, duplicate tweets, + /// permission issues): { "title": "...", "detail": "...", "errors": [ { "message": + /// "...", "code": 187 } ] }. Returns false when the shape doesn't match so the caller + /// can fall through to the unexpected-shape branch. + /// + private static bool TryParseTwitterNativeError(JsonElement root, out TwitterPublishOutcome outcome) + { + outcome = default; + if (!root.TryGetProperty("errors", out var errorsProp) || + errorsProp.ValueKind != JsonValueKind.Array || + errorsProp.GetArrayLength() == 0) + { + // Sometimes Twitter omits the `errors` array but still returns `title`/`detail` + // directly (Problem Details RFC 7807 — what Twitter v2 calls `tweet_create_error`). + // Treat that as a native error too. + var detailText = TryReadString(root, "detail"); + var titleText = TryReadString(root, "title"); + if (string.IsNullOrEmpty(detailText) && string.IsNullOrEmpty(titleText)) + return false; + + var combined = string.IsNullOrEmpty(detailText) ? titleText! : detailText!; + outcome = TwitterPublishOutcome.Failure( + "twitter_publish_rejected", + combined, + httpStatus: 0, + larkMessage: $"Twitter 发布失败:{combined}"); + return true; + } + + var firstError = errorsProp[0]; + var message = TryReadString(firstError, "message") + ?? TryReadString(root, "detail") + ?? TryReadString(root, "title") + ?? "Twitter rejected the publish request."; + var twitterCode = TryReadInt32(firstError, "code"); + var detailWithCode = twitterCode is { } c + ? $"{message} (twitter code={c})" + : message; + + outcome = TwitterPublishOutcome.Failure( + "twitter_publish_rejected", + detailWithCode, + httpStatus: 0, + larkMessage: $"Twitter 发布失败:{detailWithCode}"); + return true; + } + + private static TwitterPublishOutcome ClassifyByStatus(int status, string detail, string? rawBody) + { + // Categorization matches issue #216's surfacing matrix: + // 201 → success (handled in caller) + // 401 → OAuth expired/missing — actionable, no retry + // 403 → scope downgraded or seed misconfig — actionable, no retry + // 429 → rate-limited — could retry, but #216 v1 scope says fail with hint + // 5xx → upstream/proxy fault — could retry; v1 scope: fail with hint + // 4xx other → unknown rejection — surface verbatim so user can debug + return status switch + { + (int)HttpStatusCode.Unauthorized => TwitterPublishOutcome.Failure( + "twitter_oauth_required", + detail, + status, + "Twitter OAuth 过期或未授权,请到 NyxID 重新授权 Twitter(providers/twitter)后再试。"), + (int)HttpStatusCode.Forbidden => TwitterPublishOutcome.Failure( + "twitter_proxy_access_denied", + detail, + status, + "Twitter 拒绝发布(403):scope 不足或推文内容被策略拦截。请联系 ops 检查 tweet.write scope。"), + (int)HttpStatusCode.TooManyRequests => TwitterPublishOutcome.Failure( + "twitter_rate_limited", + detail, + status, + "Twitter 发布命中速率限制(429),请稍后重试。"), + >= 500 and <= 599 => TwitterPublishOutcome.Failure( + "twitter_upstream_error", + detail, + status, + $"Twitter 上游服务异常(HTTP {status}),请稍后重试。"), + _ => TwitterPublishOutcome.Failure( + "twitter_publish_rejected", + detail, + status, + BuildGenericFailureMessage(status, detail, rawBody)), + }; + } + + private static string BuildGenericFailureMessage(int status, string detail, string? rawBody) + { + var truncated = rawBody is { Length: > 200 } ? rawBody.Substring(0, 200) + "…" : rawBody; + return string.IsNullOrEmpty(truncated) + ? $"Twitter 发布失败(HTTP {status}):{detail}" + : $"Twitter 发布失败(HTTP {status}):{detail}(body: {truncated})"; + } + + private static int? TryReadInt32(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var prop) || + prop.ValueKind != JsonValueKind.Number || + !prop.TryGetInt32(out var value)) + { + return null; + } + return value; + } + + private static string? TryReadString(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var prop) || prop.ValueKind != JsonValueKind.String) + return null; + var raw = prop.GetString(); + return string.IsNullOrWhiteSpace(raw) ? null : raw; + } +} + +internal readonly record struct TwitterPublishOutcome( + bool Success, + string? TweetUrl, + string ErrorCode, + string Detail, + int HttpStatus, + string LarkMessage) +{ + public static TwitterPublishOutcome Successful(string tweetUrl) => + new(true, tweetUrl, string.Empty, string.Empty, 201, string.Empty); + + public static TwitterPublishOutcome Failure(string code, string detail, int httpStatus, string larkMessage) => + new(false, null, code, detail, httpStatus, larkMessage); +} diff --git a/src/workflow/Aevatar.Workflow.Core/Properties/InternalsVisibleTo.cs b/src/workflow/Aevatar.Workflow.Core/Properties/InternalsVisibleTo.cs index 943f2c089..927c1d848 100644 --- a/src/workflow/Aevatar.Workflow.Core/Properties/InternalsVisibleTo.cs +++ b/src/workflow/Aevatar.Workflow.Core/Properties/InternalsVisibleTo.cs @@ -3,3 +3,11 @@ [assembly: InternalsVisibleTo("Aevatar.Workflow.Core.Tests")] [assembly: InternalsVisibleTo("Aevatar.Integration.Tests")] [assembly: InternalsVisibleTo("Aevatar.Workflow.Host.Api.Tests")] +// ChannelRuntime owns the social_media `twitter_publish` workflow module (issue #216): its +// implementation needs to read the per-request NyxID api-key + Lark delivery target out of the +// workflow execution context's items / request-metadata bag to call NyxID proxies and surface +// the result back to the originating chat. Those bag accessors are internal by design (they are +// not a free-form public extension surface), so the channel-runtime module is granted +// internals-visible the same way the workflow test projects are. +[assembly: InternalsVisibleTo("Aevatar.GAgents.ChannelRuntime")] +[assembly: InternalsVisibleTo("Aevatar.GAgents.ChannelRuntime.Tests")] diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderToolTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderToolTests.cs index 7d81bdeb6..22639911a 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderToolTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderToolTests.cs @@ -1657,14 +1657,24 @@ public async Task ExecuteAsync_CreateAgent_SocialMedia_UpsertsWorkflowAndInitial var handler = new RoutingJsonHandler(); handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); + // Issue #216: social_media now requires both api-lark-bot (delivery) AND api-twitter + // (publish) so the agent api-key carries both entitlements. The api-twitter slug entry + // is what gates `service_not_connected` at create time; without it the user gets a + // structured error pointing them at NyxID's connect-twitter flow. handler.Add(HttpMethod.Get, "/api/v1/user-services", """ { "services": [ - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} + {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}}, + {"id":"svc-twitter","slug":"api-twitter","is_active":true,"credential_source":{"type":"personal"}} ] } """); handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-2","full_key":"full-key-2"}"""); + // Twitter preflight (#216 mirror of #418 GitHub preflight): GET /users/me with the + // freshly minted key must succeed before the workflow gets upserted. NyxID forwards + // the Twitter v2 user payload verbatim on success (no `error` envelope). + handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-twitter/users/me", + """{"data":{"id":"123456","name":"Alice","username":"alice"}}"""); var nyxClient = new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, @@ -1750,15 +1760,37 @@ await activationService.Received(1).EnsureAsync( .ContainSingle(x => x.Method == HttpMethod.Post && x.Path == "/api/v1/api-keys") .Subject; using var apiKeyDoc = JsonDocument.Parse(apiKeyRequest.Body!); + // Issue #216: api-key now carries both `svc-lark` (approval delivery) and + // `svc-twitter` (publish). Order is irrelevant — `BeEquivalentTo` ignores it. apiKeyDoc.RootElement.GetProperty("allowed_service_ids").EnumerateArray() .Select(static item => item.GetString()) .Should() - .BeEquivalentTo(["svc-lark"]); + .BeEquivalentTo(["svc-lark", "svc-twitter"]); // PR #418 review (4175529548): NyxID's `allow_all_services` defaults to `true` // (api_keys.rs:105) and proxy enforcement only fires when `!allow_all_services` // (proxy.rs:1030). Pin that the field is *present* and `false` so the resolved // `allowed_service_ids` actually constrains the key's reach. apiKeyDoc.RootElement.GetProperty("allow_all_services").GetBoolean().Should().BeFalse(); + + // Workflow YAML must now route the approval `true` branch to the new + // `publish_to_twitter` step instead of straight to `done` — the publish step is + // what fulfills issue #216's "approve → publish to X" path. PR #461 review fix: + // also pin `on_error: skip` so a Twitter-side rejection (401/403/429/5xx) advances + // the run to `done` instead of terminating the entire workflow as failed; the + // module already surfaces categorized errors to Lark independently. + await workflowCommandPort.Received(1).UpsertAsync( + Arg.Is(request => + request.WorkflowYaml.Contains("type: twitter_publish", StringComparison.Ordinal) && + request.WorkflowYaml.Contains("publish_provider_slug: \"api-twitter\"", StringComparison.Ordinal) && + request.WorkflowYaml.Contains("\"true\": publish_to_twitter", StringComparison.Ordinal) && + request.WorkflowYaml.Contains("strategy: skip", StringComparison.Ordinal)), + Arg.Any()); + + // Twitter preflight must fire with the freshly minted api-key against /users/me + // before the workflow is upserted (mirror of GitHub preflight in #418). + handler.Requests.Should().Contain(r => + r.Method == HttpMethod.Get && + r.Path == "/api/v1/proxy/s/api-twitter/users/me"); } finally { @@ -2553,6 +2585,255 @@ await workflowAgentActor.Received(1).HandleEventAsync( } } + [Fact] + public async Task ExecuteAsync_CreateAgent_SocialMedia_FailsClosed_When_TwitterProxyReturns401() + { + // Issue aevatarAI/aevatar#216: social_media now publishes approved drafts to Twitter via + // NyxID's api-twitter proxy. Mirror of the GitHub preflight (#418): probe /users/me with + // the freshly minted api-key; if NyxID has no OAuth grant for the user (401), abort + // creation, return a structured `twitter_oauth_required` error, and best-effort revoke + // the orphan key so retries don't accumulate. + var queryPort = Substitute.For(); + queryPort.GetStateVersionAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + + var workflowAgentActor = Substitute.For(); + workflowAgentActor.Id.Returns("workflow-agent-twitter-401"); + var actorRuntime = Substitute.For(); + actorRuntime.GetAsync("workflow-agent-twitter-401").Returns(Task.FromResult(null)); + actorRuntime.CreateAsync("workflow-agent-twitter-401", Arg.Any()) + .Returns(Task.FromResult(workflowAgentActor)); + + var workflowCommandPort = Substitute.For(); + + var handler = new RoutingJsonHandler(); + handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); + handler.Add(HttpMethod.Get, "/api/v1/user-services", """ + { + "services": [ + {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}}, + {"id":"svc-twitter","slug":"api-twitter","is_active":true,"credential_source":{"type":"personal"}} + ] + } + """); + handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-401","full_key":"full-key-401"}"""); + // 401 from /users/me through NyxID — common when the user has not connected Twitter + // yet at NyxID, or when the OAuth grant was revoked at x.com/settings. + handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-twitter/users/me", + """{"error": true, "status": 401, "body": "{\"title\":\"Unauthorized\",\"detail\":\"Authenticating with OAuth 2.0 Application-Only is forbidden for this endpoint.\"}"}"""); + // Pin the orphan-key revocation: per #418's pattern, every preflight failure must + // best-effort delete the api-key so retries don't pile up keys in the user's account. + handler.Add(HttpMethod.Delete, "/api/v1/api-keys/key-401", """{"deleted":true}"""); + + var nyxClient = new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, + new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); + + var services = new ServiceCollection(); + services.AddSingleton(queryPort); + services.AddSingleton(actorRuntime); + services.AddSingleton(workflowCommandPort); + services.AddSingleton(nyxClient); + var tool = new AgentBuilderTool(services.BuildServiceProvider()); + + AgentToolRequestContext.CurrentMetadata = new Dictionary + { + [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", + [ChannelMetadataKeys.ChatType] = "p2p", + [ChannelMetadataKeys.ConversationId] = "oc_chat_1", + [ChannelMetadataKeys.SenderId] = "ou_user_1", + ["scope_id"] = "scope-1", + }; + try + { + var result = await tool.ExecuteAsync(""" + { + "action": "create_agent", + "template": "social_media", + "agent_id": "workflow-agent-twitter-401", + "topic": "Launch update", + "schedule_cron": "0 9 * * *", + "schedule_timezone": "UTC" + } + """); + + using var doc = JsonDocument.Parse(result); + doc.RootElement.GetProperty("error").GetString().Should().Be("twitter_oauth_required"); + doc.RootElement.GetProperty("http_status").GetInt32().Should().Be(401); + doc.RootElement.GetProperty("hint").GetString()!.ToLowerInvariant().Should().Contain("re-authorize"); + + // Workflow upsert and actor init must NOT have run — preflight aborts before that. + await workflowCommandPort.DidNotReceiveWithAnyArgs().UpsertAsync(default!, default); + await workflowAgentActor.DidNotReceive().HandleEventAsync( + Arg.Any(), + Arg.Any()); + + // Orphan-key revocation fires (mirror of #418 r3141846175 for daily_report). + handler.Requests.Should().Contain(r => + r.Method == HttpMethod.Delete && + r.Path == "/api/v1/api-keys/key-401"); + } + finally + { + AgentToolRequestContext.CurrentMetadata = null; + } + } + + [Fact] + public async Task ExecuteAsync_CreateAgent_SocialMedia_FailsClosed_When_TwitterProxyReturns403() + { + // 403 here means "the OAuth token reached Twitter but tweet.write was not in scope". + // Default NyxID seed includes tweet.write (provider_service.rs:405-450), so a 403 in + // production typically means a regression on the seed side or the bound token was + // issued before tweet.write was added — surface this as `twitter_proxy_access_denied` + // (distinct from 401) so the user-facing hint can steer ops vs the user. + var queryPort = Substitute.For(); + queryPort.GetStateVersionAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + + var workflowAgentActor = Substitute.For(); + workflowAgentActor.Id.Returns("workflow-agent-twitter-403"); + var actorRuntime = Substitute.For(); + actorRuntime.GetAsync("workflow-agent-twitter-403").Returns(Task.FromResult(null)); + actorRuntime.CreateAsync("workflow-agent-twitter-403", Arg.Any()) + .Returns(Task.FromResult(workflowAgentActor)); + + var workflowCommandPort = Substitute.For(); + + var handler = new RoutingJsonHandler(); + handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); + handler.Add(HttpMethod.Get, "/api/v1/user-services", """ + { + "services": [ + {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}}, + {"id":"svc-twitter","slug":"api-twitter","is_active":true,"credential_source":{"type":"personal"}} + ] + } + """); + handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-403","full_key":"full-key-403"}"""); + handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-twitter/users/me", + """{"error": true, "status": 403, "body": "{\"title\":\"Forbidden\",\"detail\":\"Your client app is not configured with the appropriate oauth2 app permissions.\"}"}"""); + handler.Add(HttpMethod.Delete, "/api/v1/api-keys/key-403", """{"deleted":true}"""); + + var nyxClient = new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, + new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); + + var services = new ServiceCollection(); + services.AddSingleton(queryPort); + services.AddSingleton(actorRuntime); + services.AddSingleton(workflowCommandPort); + services.AddSingleton(nyxClient); + var tool = new AgentBuilderTool(services.BuildServiceProvider()); + + AgentToolRequestContext.CurrentMetadata = new Dictionary + { + [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", + [ChannelMetadataKeys.ChatType] = "p2p", + [ChannelMetadataKeys.ConversationId] = "oc_chat_1", + [ChannelMetadataKeys.SenderId] = "ou_user_1", + ["scope_id"] = "scope-1", + }; + try + { + var result = await tool.ExecuteAsync(""" + { + "action": "create_agent", + "template": "social_media", + "agent_id": "workflow-agent-twitter-403", + "topic": "Launch update", + "schedule_cron": "0 9 * * *", + "schedule_timezone": "UTC" + } + """); + + using var doc = JsonDocument.Parse(result); + doc.RootElement.GetProperty("error").GetString().Should().Be("twitter_proxy_access_denied"); + doc.RootElement.GetProperty("http_status").GetInt32().Should().Be(403); + doc.RootElement.GetProperty("hint").GetString()!.ToLowerInvariant().Should().Contain("tweet.write"); + + await workflowCommandPort.DidNotReceiveWithAnyArgs().UpsertAsync(default!, default); + await workflowAgentActor.DidNotReceive().HandleEventAsync( + Arg.Any(), + Arg.Any()); + handler.Requests.Should().Contain(r => + r.Method == HttpMethod.Delete && + r.Path == "/api/v1/api-keys/key-403"); + } + finally + { + AgentToolRequestContext.CurrentMetadata = null; + } + } + + [Fact] + public async Task ExecuteAsync_CreateAgent_SocialMedia_FailsClosed_When_TwitterServiceNotConnected() + { + // The flip side of the preflight: if api-twitter is not present in user-services at all, + // the existing ResolveProxyServiceIdsAsync path returns `service_not_connected` BEFORE + // we mint the api-key. This is the "user has not added Twitter at NyxID at all" signal. + var queryPort = Substitute.For(); + var actorRuntime = Substitute.For(); + var workflowCommandPort = Substitute.For(); + + var handler = new RoutingJsonHandler(); + handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); + // Notice: no api-twitter row. + handler.Add(HttpMethod.Get, "/api/v1/user-services", """ + { + "services": [ + {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} + ] + } + """); + + var nyxClient = new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, + new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); + + var services = new ServiceCollection(); + services.AddSingleton(queryPort); + services.AddSingleton(actorRuntime); + services.AddSingleton(workflowCommandPort); + services.AddSingleton(nyxClient); + var tool = new AgentBuilderTool(services.BuildServiceProvider()); + + AgentToolRequestContext.CurrentMetadata = new Dictionary + { + [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", + [ChannelMetadataKeys.ChatType] = "p2p", + [ChannelMetadataKeys.ConversationId] = "oc_chat_1", + [ChannelMetadataKeys.SenderId] = "ou_user_1", + ["scope_id"] = "scope-1", + }; + try + { + var result = await tool.ExecuteAsync(""" + { + "action": "create_agent", + "template": "social_media", + "agent_id": "workflow-agent-no-twitter", + "topic": "Launch update", + "schedule_cron": "0 9 * * *", + "schedule_timezone": "UTC" + } + """); + + using var doc = JsonDocument.Parse(result); + doc.RootElement.GetProperty("error").GetString().Should().Be("service_not_connected"); + doc.RootElement.GetProperty("slug").GetString().Should().Be("api-twitter"); + // Critical invariant: no api-key was ever minted because the slug check failed up + // front. Catching this here matters because the daily_report tests already pin the + // same invariant for api-github — keep parity. + handler.Requests.Should().NotContain(r => + r.Method == HttpMethod.Post && r.Path == "/api/v1/api-keys"); + } + finally + { + AgentToolRequestContext.CurrentMetadata = null; + } + } + [Fact] public async Task ToolSource_Always_ReturnsTool() { diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowModules/TwitterPublishOutcomeTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowModules/TwitterPublishOutcomeTests.cs new file mode 100644 index 000000000..ea3b47b20 --- /dev/null +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowModules/TwitterPublishOutcomeTests.cs @@ -0,0 +1,186 @@ +using Aevatar.GAgents.ChannelRuntime.WorkflowModules; +using FluentAssertions; +using Xunit; + +namespace Aevatar.GAgents.ChannelRuntime.Tests.WorkflowModules; + +/// +/// Pins the response classification matrix for against the +/// 5 NyxID-proxy shapes the issue (#216) calls out. The module wiring (item resolution, Lark +/// surfacing) is exercised in higher-level integration tests; this file is the unit-level +/// contract for "given a downstream response, what user-facing classification falls out". +/// +public sealed class TwitterPublishOutcomeTests +{ + [Fact] + public void ClassifyTwitterResponse_ReturnsTweetUrl_When_Twitter201Success() + { + // Twitter v2 returns `{ "data": { "id": "", "text": "..." } }` on success; NyxID + // forwards verbatim, so the absence of `error` plus a present `data.id` is the success + // signal. The URL uses the no-handle form so we don't need a separate /users/me call. + var response = """{"data":{"id":"1234567890","text":"hello world"}}"""; + + var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); + + outcome.Success.Should().BeTrue(); + outcome.TweetUrl.Should().Be("https://x.com/i/web/status/1234567890"); + outcome.ErrorCode.Should().BeEmpty(); + outcome.HttpStatus.Should().Be(201); + } + + [Fact] + public void ClassifyTwitterResponse_ReturnsOauthRequired_When_Proxy401() + { + // NyxID wraps 4xx as `{ "error": true, "status": , "body": "" }`. 401 is the + // common "user has not connected Twitter at NyxID" path; the Lark message must steer + // them at NyxID's re-authorization flow rather than asking ops to look at scopes. + var response = """{"error": true, "status": 401, "body": "{\"title\":\"Unauthorized\"}"}"""; + + var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); + + outcome.Success.Should().BeFalse(); + outcome.ErrorCode.Should().Be("twitter_oauth_required"); + outcome.HttpStatus.Should().Be(401); + outcome.LarkMessage.ToLowerInvariant().Should().Contain("oauth"); + } + + [Fact] + public void ClassifyTwitterResponse_ReturnsAccessDenied_When_Proxy403() + { + var response = """{"error": true, "status": 403, "body": "{\"detail\":\"client app missing oauth permissions\"}"}"""; + + var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); + + outcome.Success.Should().BeFalse(); + outcome.ErrorCode.Should().Be("twitter_proxy_access_denied"); + outcome.HttpStatus.Should().Be(403); + } + + [Fact] + public void ClassifyTwitterResponse_ReturnsRateLimited_When_Proxy429() + { + var response = """{"error": true, "status": 429, "body": "{\"title\":\"Too Many Requests\"}"}"""; + + var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); + + outcome.Success.Should().BeFalse(); + outcome.ErrorCode.Should().Be("twitter_rate_limited"); + outcome.HttpStatus.Should().Be(429); + // Rate-limit Lark message should include the numerical hint so users self-serve a retry. + outcome.LarkMessage.Should().Contain("429"); + } + + [Theory] + [InlineData(500)] + [InlineData(502)] + [InlineData(503)] + [InlineData(504)] + public void ClassifyTwitterResponse_ReturnsUpstreamError_When_Proxy5xx(int status) + { + var response = $$"""{"error": true, "status": {{status}}, "body": "{\"title\":\"Server Error\"}"}"""; + + var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); + + outcome.Success.Should().BeFalse(); + outcome.ErrorCode.Should().Be("twitter_upstream_error"); + outcome.HttpStatus.Should().Be(status); + outcome.LarkMessage.Should().Contain(status.ToString()); + } + + [Fact] + public void ClassifyTwitterResponse_ReturnsGenericRejection_When_OtherStatus() + { + // 422 (Unprocessable Entity) is what Twitter returns for things like duplicate-tweet + // and content-policy violations. Don't bucket as 401/403/429/5xx — surface verbatim so + // the user can read the actual rejection reason (e.g. "duplicate content"). + var response = """{"error": true, "status": 422, "body": "{\"title\":\"You attempted to create a Tweet with content that has already been posted recently.\"}"}"""; + + var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); + + outcome.Success.Should().BeFalse(); + outcome.ErrorCode.Should().Be("twitter_publish_rejected"); + outcome.HttpStatus.Should().Be(422); + outcome.LarkMessage.Should().Contain("422"); + } + + [Fact] + public void ClassifyTwitterResponse_HandlesEmptyResponse() + { + // An empty proxy body should not silently look like success; surface as failure with a + // distinct code so logs don't conflate "Twitter accepted but didn't return a body" with + // "publish actually went through". + var outcome = TwitterPublishModule.ClassifyTwitterResponse(string.Empty); + + outcome.Success.Should().BeFalse(); + outcome.ErrorCode.Should().Be("twitter_publish_empty_response"); + } + + [Fact] + public void ClassifyTwitterResponse_HandlesUnparseableJson() + { + // NyxID is supposed to return JSON, but if a transport-layer error returned plain text + // we should not crash — emit a categorized failure code and the test verifies the + // module's robustness against malformed input. + var outcome = TwitterPublishModule.ClassifyTwitterResponse("internal error"); + + outcome.Success.Should().BeFalse(); + outcome.ErrorCode.Should().Be("twitter_publish_unparseable_response"); + } + + [Fact] + public void ClassifyTwitterResponse_RecognizesTwitterNativeErrorsArrayShape() + { + // PR #461 review item #2: Twitter v2 sometimes returns the native error shape with no + // NyxID-wrap envelope, e.g. duplicate-tweet (code 187) or content-policy violations. + // The classifier must surface the Twitter `message` text in the Lark surfacing so the + // user reads the actual rejection reason, not a generic "publish failed". + var response = """ + { + "title": "Conflict", + "detail": "You attempted to create a Tweet with content that has already been posted recently.", + "errors": [ + {"message": "duplicate content", "code": 187} + ] + } + """; + + var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); + + outcome.Success.Should().BeFalse(); + outcome.ErrorCode.Should().Be("twitter_publish_rejected"); + outcome.LarkMessage.Should().Contain("duplicate content"); + outcome.LarkMessage.Should().Contain("187"); + } + + [Fact] + public void ClassifyTwitterResponse_RecognizesTwitterNativeRfc7807Shape_WithoutErrorsArray() + { + // RFC 7807 Problem Details — Twitter v2 occasionally omits the `errors` array but + // still provides `title` / `detail`. Don't fall through to "unexpected_shape" in this + // case; treat as a native rejection so the user sees Twitter's text. + var response = """ + { + "title": "tweet_create_error", + "detail": "Your account is temporarily restricted from creating Tweets." + } + """; + + var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); + + outcome.Success.Should().BeFalse(); + outcome.ErrorCode.Should().Be("twitter_publish_rejected"); + outcome.LarkMessage.Should().Contain("temporarily restricted"); + } + + [Fact] + public void ClassifyTwitterResponse_FailsWithUnexpectedShape_When_NoSuccessNoErrorEnvelope() + { + // Empty object — neither success nor any of the recognized error shapes. Must not + // silently look like success; classify as `twitter_publish_unexpected_shape` so logs + // surface the anomaly. + var outcome = TwitterPublishModule.ClassifyTwitterResponse("{}"); + + outcome.Success.Should().BeFalse(); + outcome.ErrorCode.Should().Be("twitter_publish_unexpected_shape"); + } +}