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");
+ }
+}