feat(social_media): publish approved drafts to X via NyxID api-twitter#461
feat(social_media): publish approved drafts to X via NyxID api-twitter#461
Conversation
Closes #216. After PR #193 shipped the Feishu approval card flow for social_media agents, approved content stayed unpublished. This wires the last hop: - `PreflightTwitterProxyAsync` (mirror of #418's GitHub probe) catches 401/403 at agent-create time so we never persist an agent whose every scheduled run would fail at publish; orphan api-keys are best-effort revoked. 401 → `twitter_oauth_required` (user re-auths at NyxID), 403 → `twitter_proxy_access_denied` (ops checks `tweet.write` seed). - `social_media` template now requires both `api-lark-bot` (delivery) and `api-twitter` (publish), so the agent api-key carries both entitlements via NyxID's `allowed_service_ids` enforcement. - New `twitter_publish` workflow step + `TwitterPublishModule`, owned by ChannelRuntime so workflow.core stays free of Twitter-specific coupling. The module reads the api-key from execution items, calls `POST /api/v1/proxy/s/api-twitter/2/tweets`, classifies the response per #216's matrix (201/401/403/429/5xx), surfaces the tweet URL or categorized error to Lark via `api-lark-bot`, and emits a `StepCompletedEvent` so the workflow engine can advance to `done`. - `WorkflowAgentGAgent.BuildExecutionMetadata` now propagates the outbound Lark target (`receive_id`/`receive_id_type`/proxy slug) so the publish module can surface its result without re-resolving the catalog at runtime. Mainnet smoke test still requires ops to seed Twitter app credentials on the `twitter` provider record (NyxID admin action) — the issue explicitly carves this out as ops-only and not blocking on this PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ea42cb29d7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| var probe = await nyxClient.ProxyRequestAsync( | ||
| apiKey, | ||
| "api-twitter", | ||
| "/users/me", |
There was a problem hiding this comment.
Probe Twitter preflight with configured publish proxy slug
CreateSocialMediaAgentAsync passes publish_provider_slug into PreflightTwitterProxyAsync, but the preflight call ignores that argument and hardcodes "api-twitter". If a caller configures a non-default publish proxy slug, agent creation can fail on the wrong service (or report the wrong remediation) even though the configured slug is valid, because service-id resolution and workflow YAML use one slug while preflight probes another.
Useful? React with 👍 / 👎.
| Success = false, | ||
| Output = $"{code}: {message}", | ||
| Error = $"{code}: {message}", |
There was a problem hiding this comment.
Let publish failures advance workflow to terminal done step
PublishFailureAsync marks the step completion as Success = false, but the generated publish_to_twitter step only has next: done and no on_error path. In WorkflowExecutionKernel, failed steps without retry/on_error terminate the run, so any classified Twitter failure (401/403/429/5xx/etc.) will stop the workflow instead of progressing to done, contradicting this module’s own comment and the intended “surface error to Lark then finish” behavior.
Useful? React with 👍 / 👎.
Codecov Report✅ All modified and coverable lines are covered by tests. @@ Coverage Diff @@
## dev #461 +/- ##
==========================================
+ Coverage 70.50% 70.90% +0.39%
==========================================
Files 1198 1209 +11
Lines 86188 87045 +857
Branches 11297 11411 +114
==========================================
+ Hits 60764 61715 +951
+ Misses 21037 20882 -155
- Partials 4387 4448 +61
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
eanzhao
left a comment
There was a problem hiding this comment.
PR Review Summary
Overall this is a well-structured PR. The preflight pattern mirrors #418 faithfully, test coverage is thorough, and the module-pack isolation keeps Twitter coupling out of Workflow.Core. A few issues to address:
1. Duplicate tweet risk — no idempotency guard on POST /2/tweets
File: TwitterPublishModule.cs:158-170
If the workflow engine retries the twitter_publish step (e.g. after a 5xx that actually succeeded on Twitter's side, or a StepCompletedEvent publish that failed), the same content gets posted twice. Twitter v2 has no built-in dedup for POST /2/tweets.
Suggestion: Either check for a previous StepCompletedEvent with Success=true for the same step_id before calling Twitter, or document that the social_media workflow YAML must not have retry semantics on the publish_to_twitter step. At minimum, a code comment acknowledging this gap so it's tracked.
Severity: Medium — real operational risk in production.
2. ClassifyTwitterResponse doesn't handle Twitter v2 errors array shape
File: TwitterPublishModule.cs:317-332
Twitter v2 error responses often look like:
{"errors":[{"message":"You are not permitted to use this endpoint","code":453}]}or
{"title":"Conflict","detail":"...","errors":[{"message":"...","code":187}]}The current parser reads root.status and root.code (the NyxID envelope level), which is correct for the NyxID-wrapped case. But when NyxID forwards a Twitter body verbatim that contains an errors array without an error: true flag at the top level, the classifier would skip the data.id check, fail to find status/code, and fall through to ClassifyByStatus(0, ...) returning a generic twitter_publish_rejected with httpStatus: 0.
Consider adding a check for root.TryGetProperty("errors", ...) as a fallback classification path before defaulting to ClassifyByStatus.
Severity: Medium — edge case but plausible in production.
3. nyxClient! null-forgiving after explicit null check — redundant
File: TwitterPublishModule.cs:152 and 170
After the null check with early return on line 91, the compiler knows nyxClient is non-null. The ! operator on line 170 is unnecessary and slightly misleading. Remove it.
Severity: Nit.
4. LarkProxySlug metadata key name conflates Lark with NyxID proxy routing
File: ChannelMetadataKeys.cs:39
public const string LarkProxySlug = "channel.lark.proxy_slug";This key carries the NyxID provider slug (e.g. api-lark-bot), which is NyxID-specific routing, not a Lark concern. The channel.lark.* namespace suggests it's a Lark API field. If the outbound proxy changes or a non-Lark delivery path is added, this name conflates two separate concepts. Consider channel.outbound.proxy_slug or similar.
Severity: Low — naming, not a bug.
5. TrySendLarkAsync uses the same api-key for both Twitter and Lark — implicit coupling
File: TwitterPublishModule.cs:230-276
The module reads LLMRequestMetadataKeys.NyxIdAccessToken from execution items and uses it for both the Twitter POST /2/tweets call and the Lark POST open-apis/im/v1/messages call. This works because the key was minted with allowed_service_ids: [svc-lark, svc-twitter]. However, this coupling is implicit — if someone changes the key minting to exclude svc-lark, Lark surfacing silently breaks.
Worth a comment in TrySendLarkAsync noting this dependency on dual-scope key minting.
Severity: Low — works today but fragile to future changes.
6. SocialMediaTemplateSpec doesn't expose RequiredServiceSlugs — parity gap with DailyReportTemplateSpec
File: AgentBuilderTemplates.cs:239-244
DailyReportTemplateSpec carries RequiredServiceSlugs (used to resolve service IDs before key creation). SocialMediaTemplateSpec does not — the slugs are hardcoded inline in CreateSocialMediaAgentAsync as [providerSlug, publishProviderSlug]. Fine today with two fixed slugs, but if a third service is added the template spec should own the list. Consider adding for parity.
Severity: Low — consistency nit.
Verdict
The high-risk item is #1 (duplicate tweet). The medium-risk item is #2 (Twitter v2 error shape). Everything else is naming/consistency. Suggest addressing #1 and #2 before merge; the rest can be follow-ups.
eanzhao
left a comment
There was a problem hiding this comment.
Found one additional publish-path issue.
| proxyResponse = await nyxClient!.ProxyRequestAsync( | ||
| apiKeyValue!, | ||
| publishSlug, | ||
| "/2/tweets", |
There was a problem hiding this comment.
P1: Use the api-twitter base-relative tweet path
NyxIdServiceApiHints documents the Twitter proxy base URL as https://api.x.com/2 and explicitly says not to add /2 to proxy paths (POST /tweets). This call sends "/2/tweets", so the configured api-twitter service will hit /2/2/tweets and every approved publish will fail even when OAuth and scopes are valid. Please send "/tweets" here and add a module-level test that pins the outbound proxy path.
eanzhao
left a comment
There was a problem hiding this comment.
Additional analysis on the P1 (workflow termination on publish failure)
Confirmed. The codex P1 about StepCompletedEvent { Success = false } terminating the entire workflow run instead of advancing to done is correct. Here is the execution path:
PublishFailureAsyncemitsStepCompletedEvent { Success = false }(line 214-222 ofTwitterPublishModule.cs)WorkflowExecutionKernel.HandleStepCompletedAsync(line 311-356) sees!evt.Successand checks:- Is it a timeout error? → No
- Does the step have a retry policy? →
publish_to_twitterhas noretryblock →TryRetryAsyncreturns false - Does the step have an
on_errorpolicy? →publish_to_twitterhas noon_errorblock →TryOnErrorAsyncreturns false - Result:
CleanupRunAsync+PublishWorkflowCompletedAsync { Success = false }— the entire workflow run terminates as failed.
Fix: Add on_error: { strategy: skip } to the publish_to_twitter step YAML in AgentBuilderTemplates.cs:180-185. The kernel already supports StepErrorPolicy.Strategy = "skip" (see StepDefinition.cs:79-86 and TryOnErrorAsync at WorkflowExecutionKernel.cs:655-687), which advances the failed step output to next and continues the run cleanly.
- id: publish_to_twitter
type: twitter_publish
parameters:
...
on_error:
strategy: skip
default_output: "twitter_publish_rejected: ..."
next: doneThe default_output field allows carrying the categorized error code forward as the step output (which then becomes $input for the done step), preserving observability even when the step is skipped.
Side note: The ConnectorCallModule at ConnectorCallModule.cs:82 already implements a similar pattern with on_error: continue via step parameters — the YAML-level on_error: skip is the more architecturally correct way (StepErrorPolicy is part of the workflow definition model, not an ad-hoc parameter).
P1 — workflow termination on publish failure (reviewer's critical):
`PublishFailureAsync` emits `StepCompletedEvent { Success = false }`,
which the kernel routes through `TryRetryAsync` → `TryOnErrorAsync` →
fail. With no retry/on_error policy on `publish_to_twitter`, a Twitter
401/403/429/5xx terminated the entire workflow run as failed. Add
`on_error: { strategy: skip, default_output: "twitter_publish_failed" }`
to the YAML so the run advances to `done` cleanly; the module already
surfaces categorized errors to Lark independently.
#2 — Twitter v2 native error shape: `ClassifyTwitterResponse` now
recognizes the third response shape NyxID can forward verbatim:
`{ "title": "...", "detail": "...", "errors": [...] }` (Twitter's
native problem-details for content-policy / duplicate-tweet rejections).
Falls through to `twitter_publish_rejected` with the Twitter `message`
text in the Lark surfacing so users read the actual rejection reason.
#1 — Duplicate tweet risk: documented in code comment that
`POST /2/tweets` has no server-side dedup; the social_media template
intentionally has no `retry` policy on this step, and `on_error: skip`
advances rather than retrying. Authors customizing the YAML must keep
this invariant.
#3 — Removed redundant `nyxClient!` null-forgiving (no-op cleanup).
#4 — Renamed `ChannelMetadataKeys.LarkProxySlug` →
`LarkOutboundProxySlug` (`channel.lark.outbound_proxy_slug`) to
disambiguate "Lark API surface" from "NyxID provider routing".
#5 — Added xml-doc on `TrySendLarkAsync` documenting the dual-scope
api-key dependency (key must carry both api-twitter AND api-lark-bot
entitlements) so future callers don't silently break Lark surfacing
when narrowing the key's scope.
#6 — Added `RequiredServiceSlugs` field to `SocialMediaTemplateSpec`
for parity with `DailyReportTemplateSpec`; `CreateSocialMediaAgentAsync`
now reads slugs from the spec instead of inlining the list.
Tests:
- 3 new `ClassifyTwitterResponse` tests for the Twitter native error
shapes (errors-array, RFC-7807 title/detail-only, empty-object
unexpected-shape).
- Existing social_media test now also asserts `strategy: skip` lands in
the upserted YAML.
- 482 channel-runtime + 236 workflow.core tests pass; full solution
builds with 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Closes #216. After PR #193 shipped the Feishu approval flow for social_media agents, approved content stayed unpublished. This PR wires the last hop — publishing the approved draft to X (Twitter) via NyxID's
api-twitterproxy, with the same fail-closed creation-time preflight pattern used fordaily_report(#418).PreflightTwitterProxyAsyncprobesGET /users/mewith the freshly minted agent api-key.401returns a structuredtwitter_oauth_required(user re-authorizes at NyxID);403returnstwitter_proxy_access_denied(ops checkstweet.writeseed). On failure, the api-key is best-effort revoked so retries don't pile up orphan keys.social_mediatemplate now requires bothapi-lark-bot(delivery) andapi-twitter(publish), so a single api-key carries both entitlements through NyxID'sallowed_service_idsenforcement.twitter_publishworkflow step type implemented byTwitterPublishModule(owned byAevatar.GAgents.ChannelRuntimeto keepAevatar.Workflow.Corefree of Twitter-specific compile-time coupling). The module reads the api-key from execution items, callsPOST /api/v1/proxy/s/api-twitter/2/tweets, classifies the response per feat: publish approved social_media content to X via NyxID #216's matrix (201/401/403/429/5xx), surfaces the tweet URL or categorized error to Lark viaapi-lark-bot, and emits aStepCompletedEventso the workflow engine can advance todone.social_mediaworkflow now routes the approvaltruebranch topublish_to_twitter(wasdone); rejection still skips todone.WorkflowAgentGAgent.BuildExecutionMetadatanow propagates the outbound Lark target (receive_id/receive_id_type/ proxy slug) so the publish module can surface its result without re-resolving the catalog at runtime.Mainnet smoke test
Issue #216 explicitly carves this out as ops-only and not blocking on PR merge — ops needs to seed the Twitter app
client_id/secreton the NyxIDtwitterprovider record before live publishing succeeds. Code-side, the agent will create cleanly today against any NyxID environment that has Twitter OAuth seeded.Test plan
dotnet build aevatar.slnx— 0 errorsdotnet test test/Aevatar.GAgents.ChannelRuntime.Tests— 479 passed (3 new preflight tests + updated social_media test + 8 new publish-classification tests)dotnet test test/Aevatar.Workflow.Core.Tests— 236 passeddotnet test test/Aevatar.Integration.Tests— 286 passed (4 skipped, baseline)bash tools/ci/test_stability_guards.sh— passedbash tools/ci/architecture_guards.sh— all guards relevant to this change pass;playground_asset_drift_guard.shfails identically ondev(pre-existing, not caused by this PR)Files of note
agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs— addsPreflightTwitterProxyAsyncand updatesCreateSocialMediaAgentAsyncto requireapi-twitterand run the preflight after key creationagents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTemplates.cs— addspublish_to_twitterstep to the social_media YAML templateagents/Aevatar.GAgents.ChannelRuntime/WorkflowModules/TwitterPublishModule.cs— the new step's runtime implementation, includingClassifyTwitterResponse(the unit-tested response-shape contract)agents/Aevatar.GAgents.ChannelRuntime/WorkflowModules/ChannelRuntimeWorkflowModulePack.cs— module pack registeringtwitter_publishagents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.cs—AddChannelRuntimenow also registers the new module pack so hosts that already wire ChannelRuntime get the new step type for freesrc/workflow/Aevatar.Workflow.Core/Properties/InternalsVisibleTo.cs— grantsAevatar.GAgents.ChannelRuntimeaccess to the workflow execution items / request-metadata bag accessors so the publish module can read NyxID/Lark context without those accessors becoming a public extension surface🤖 Generated with Claude Code