Skip to content

feat(social_media): publish approved drafts to X via NyxID api-twitter#461

Open
eanzhao wants to merge 2 commits intodevfrom
feat/2026-04-27_twitter-publish-via-nyxid
Open

feat(social_media): publish approved drafts to X via NyxID api-twitter#461
eanzhao wants to merge 2 commits intodevfrom
feat/2026-04-27_twitter-publish-via-nyxid

Conversation

@eanzhao
Copy link
Copy Markdown
Contributor

@eanzhao eanzhao commented Apr 27, 2026

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-twitter proxy, with the same fail-closed creation-time preflight pattern used for daily_report (#418).

  • Preflight: PreflightTwitterProxyAsync probes GET /users/me with the freshly minted agent api-key. 401 returns a structured twitter_oauth_required (user re-authorizes at NyxID); 403 returns twitter_proxy_access_denied (ops checks tweet.write seed). On failure, the api-key is best-effort revoked so retries don't pile up orphan keys.
  • api-key scope: social_media template now requires both api-lark-bot (delivery) and api-twitter (publish), so a single api-key carries both entitlements through NyxID's allowed_service_ids enforcement.
  • Publish step: New twitter_publish workflow step type implemented by TwitterPublishModule (owned by Aevatar.GAgents.ChannelRuntime to keep Aevatar.Workflow.Core free of Twitter-specific compile-time coupling). The module reads the api-key from execution items, calls POST /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 via api-lark-bot, and emits a StepCompletedEvent so the workflow engine can advance to done.
  • Workflow YAML: social_media workflow now routes the approval true branch to publish_to_twitter (was done); rejection still skips to done.
  • Metadata propagation: 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

Issue #216 explicitly carves this out as ops-only and not blocking on PR merge — ops needs to seed the Twitter app client_id/secret on the NyxID twitter provider 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 errors
  • dotnet 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 passed
  • dotnet test test/Aevatar.Integration.Tests — 286 passed (4 skipped, baseline)
  • bash tools/ci/test_stability_guards.sh — passed
  • bash tools/ci/architecture_guards.sh — all guards relevant to this change pass; playground_asset_drift_guard.sh fails identically on dev (pre-existing, not caused by this PR)
  • Mainnet smoke test (deferred, ops dependency on Twitter app credentials seeded into NyxID)

Files of note

  • agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs — adds PreflightTwitterProxyAsync and updates CreateSocialMediaAgentAsync to require api-twitter and run the preflight after key creation
  • agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTemplates.cs — adds publish_to_twitter step to the social_media YAML template
  • agents/Aevatar.GAgents.ChannelRuntime/WorkflowModules/TwitterPublishModule.cs — the new step's runtime implementation, including ClassifyTwitterResponse (the unit-tested response-shape contract)
  • agents/Aevatar.GAgents.ChannelRuntime/WorkflowModules/ChannelRuntimeWorkflowModulePack.cs — module pack registering twitter_publish
  • agents/Aevatar.GAgents.ChannelRuntime/ServiceCollectionExtensions.csAddChannelRuntime now also registers the new module pack so hosts that already wire ChannelRuntime get the new step type for free
  • src/workflow/Aevatar.Workflow.Core/Properties/InternalsVisibleTo.cs — grants Aevatar.GAgents.ChannelRuntime access 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

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>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +1867 to +1870
var probe = await nyxClient.ProxyRequestAsync(
apiKey,
"api-twitter",
"/users/me",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +218 to +220
Success = false,
Output = $"{code}: {message}",
Error = $"{code}: {message}",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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
Copy link
Copy Markdown

codecov Bot commented Apr 27, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 70.90%. Comparing base (114ab79) to head (781c5bd).
⚠️ Report is 59 commits behind head on dev.

@@            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     
Flag Coverage Δ
ci 70.90% <ø> (+0.39%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 32 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor Author

@eanzhao eanzhao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

@eanzhao eanzhao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found one additional publish-path issue.

proxyResponse = await nyxClient!.ProxyRequestAsync(
apiKeyValue!,
publishSlug,
"/2/tweets",
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

@eanzhao eanzhao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. PublishFailureAsync emits StepCompletedEvent { Success = false } (line 214-222 of TwitterPublishModule.cs)
  2. WorkflowExecutionKernel.HandleStepCompletedAsync (line 311-356) sees !evt.Success and checks:
    • Is it a timeout error? → No
    • Does the step have a retry policy? → publish_to_twitter has no retry block → TryRetryAsync returns false
    • Does the step have an on_error policy? → publish_to_twitter has no on_error block → TryOnErrorAsync returns 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: done

The 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: publish approved social_media content to X via NyxID

1 participant