From 95da999efba80a8de62dd3eb74191e5c1e44d7a7 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:02:30 -0700 Subject: [PATCH] feat(growth): add X/Twitter auto-posting on tweet approval (#3328) - Add x-post.ts script for posting tweets via X API v2 (OAuth 1.0a) - Wire postToX() into SPA's tweet_approve and tweet_edit_submit handlers - Approved tweets now post directly to X instead of just marking "ready" - Slack card updates with link to live tweet on success, error msg on failure - Add X_API_KEY/SECRET/ACCESS_TOKEN/SECRET env vars to SPA environment Co-authored-by: Claude Co-authored-by: Claude Opus 4.6 (1M context) --- .claude/skills/setup-agent-team/x-post.ts | 145 ++++++++ .claude/skills/setup-spa/main.ts | 391 ++++++++++++++++++---- 2 files changed, 476 insertions(+), 60 deletions(-) create mode 100644 .claude/skills/setup-agent-team/x-post.ts diff --git a/.claude/skills/setup-agent-team/x-post.ts b/.claude/skills/setup-agent-team/x-post.ts new file mode 100644 index 00000000..79b24351 --- /dev/null +++ b/.claude/skills/setup-agent-team/x-post.ts @@ -0,0 +1,145 @@ +/** + * X (Twitter) Post — Post a tweet via X API v2. + * + * Uses OAuth 1.0a to authenticate and POST /2/tweets. + * Can post standalone tweets or replies (pass in_reply_to_tweet_id). + * + * Usage: + * X_API_KEY=... X_API_SECRET=... X_ACCESS_TOKEN=... X_ACCESS_TOKEN_SECRET=... \ + * TWEET_TEXT="Hello world" bun run x-post.ts + * + * Optional env: + * REPLY_TO_TWEET_ID — if set, the tweet is posted as a reply to this tweet ID + * + * Outputs JSON: { "id": "...", "text": "..." } on success, exits 1 on failure. + */ + +import { createHmac, randomBytes } from "node:crypto"; +import * as v from "valibot"; + +const API_KEY = process.env.X_API_KEY ?? ""; +const API_SECRET = process.env.X_API_SECRET ?? ""; +const ACCESS_TOKEN = process.env.X_ACCESS_TOKEN ?? ""; +const ACCESS_TOKEN_SECRET = process.env.X_ACCESS_TOKEN_SECRET ?? ""; +const TWEET_TEXT = process.env.TWEET_TEXT ?? ""; +const REPLY_TO = process.env.REPLY_TO_TWEET_ID ?? ""; + +if (!API_KEY || !API_SECRET || !ACCESS_TOKEN || !ACCESS_TOKEN_SECRET) { + console.error("[x-post] Missing X API credentials"); + process.exit(1); +} + +if (!TWEET_TEXT) { + console.error("[x-post] TWEET_TEXT is empty"); + process.exit(1); +} + +if (TWEET_TEXT.length > 280) { + console.error(`[x-post] Tweet too long (${TWEET_TEXT.length} chars, max 280)`); + process.exit(1); +} + +const PostResponseSchema = v.object({ + data: v.object({ + id: v.string(), + text: v.string(), + }), +}); + +const ErrorResponseSchema = v.object({ + detail: v.optional(v.string()), + title: v.optional(v.string()), + errors: v.optional( + v.array( + v.object({ + message: v.optional(v.string()), + }), + ), + ), +}); + +/** + * Generate OAuth 1.0a Authorization header for X API requests. + */ +function generateOAuthHeader(method: string, url: string, body?: string): string { + const oauthParams: Record = { + oauth_consumer_key: API_KEY, + oauth_nonce: randomBytes(16).toString("hex"), + oauth_signature_method: "HMAC-SHA1", + oauth_timestamp: String(Math.floor(Date.now() / 1000)), + oauth_token: ACCESS_TOKEN, + oauth_version: "1.0", + }; + + // For POST with JSON body, only OAuth params go into the signature base + const allParams = { + ...oauthParams, + }; + const sortedKeys = Object.keys(allParams).sort(); + const paramString = sortedKeys.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(allParams[k])}`).join("&"); + + const signatureBase = `${method.toUpperCase()}&${encodeURIComponent(url)}&${encodeURIComponent(paramString)}`; + const signingKey = `${encodeURIComponent(API_SECRET)}&${encodeURIComponent(ACCESS_TOKEN_SECRET)}`; + const signature = createHmac("sha1", signingKey).update(signatureBase).digest("base64"); + + oauthParams.oauth_signature = signature; + + const headerParts = Object.keys(oauthParams) + .sort() + .map((k) => `${encodeURIComponent(k)}="${encodeURIComponent(oauthParams[k])}"`) + .join(", "); + + return `OAuth ${headerParts}`; +} + +async function postTweet(): Promise { + const url = "https://api.x.com/2/tweets"; + + const payload: Record = { + text: TWEET_TEXT, + }; + if (REPLY_TO) { + payload.reply = { + in_reply_to_tweet_id: REPLY_TO, + }; + } + + const body = JSON.stringify(payload); + const authHeader = generateOAuthHeader("POST", url, body); + + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + "User-Agent": "spawn-growth/1.0", + }, + body, + }); + + const json: unknown = await res.json(); + + if (!res.ok) { + const err = v.safeParse(ErrorResponseSchema, json); + const detail = err.success + ? (err.output.detail ?? err.output.errors?.[0]?.message ?? `HTTP ${res.status}`) + : `HTTP ${res.status}`; + console.error(`[x-post] Failed: ${detail}`); + process.exit(1); + } + + const parsed = v.safeParse(PostResponseSchema, json); + if (!parsed.success) { + console.error("[x-post] Unexpected response shape"); + console.error(JSON.stringify(json)); + process.exit(1); + } + + console.log(JSON.stringify(parsed.output.data)); + console.error(`[x-post] Posted tweet ${parsed.output.data.id}`); +} + +postTweet().catch((err) => { + console.error("Fatal:", err); + process.exit(1); +}); diff --git a/.claude/skills/setup-spa/main.ts b/.claude/skills/setup-spa/main.ts index e7b2c42f..625cbf75 100644 --- a/.claude/skills/setup-spa/main.ts +++ b/.claude/skills/setup-spa/main.ts @@ -5,7 +5,7 @@ import type { ActionsBlock, ContextBlock, KnownBlock, SectionBlock } from "@slac import type { Block } from "@slack/types"; import type { ToolCall } from "./helpers"; -import { timingSafeEqual } from "node:crypto"; +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { isString, toRecord } from "@openrouter/spawn-shared"; import { App } from "@slack/bolt"; import * as v from "valibot"; @@ -51,6 +51,10 @@ const REDDIT_CLIENT_SECRET = process.env.REDDIT_CLIENT_SECRET ?? ""; const REDDIT_USERNAME = process.env.REDDIT_USERNAME ?? ""; const REDDIT_PASSWORD = process.env.REDDIT_PASSWORD ?? ""; const REDDIT_USER_AGENT = `spawn-growth:v1.0.0 (by /u/${REDDIT_USERNAME})`; +const X_API_KEY = process.env.X_API_KEY ?? ""; +const X_API_SECRET = process.env.X_API_SECRET ?? ""; +const X_ACCESS_TOKEN = process.env.X_ACCESS_TOKEN ?? ""; +const X_ACCESS_TOKEN_SECRET = process.env.X_ACCESS_TOKEN_SECRET ?? ""; for (const [name, value] of Object.entries({ SLACK_BOT_TOKEN, @@ -64,6 +68,120 @@ for (const [name, value] of Object.entries({ // #endregion +// #region X (Twitter) posting + +interface XPostResult { + ok: boolean; + tweetId?: string; + tweetUrl?: string; + error?: string; +} + +/** Generate OAuth 1.0a Authorization header for X API. */ +function generateXOAuthHeader(method: string, url: string): string { + const oauthParams: Record = { + oauth_consumer_key: X_API_KEY, + oauth_nonce: randomBytes(16).toString("hex"), + oauth_signature_method: "HMAC-SHA1", + oauth_timestamp: String(Math.floor(Date.now() / 1000)), + oauth_token: X_ACCESS_TOKEN, + oauth_version: "1.0", + }; + + const sortedKeys = Object.keys(oauthParams).sort(); + const paramString = sortedKeys.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(oauthParams[k])}`).join("&"); + + const signatureBase = `${method.toUpperCase()}&${encodeURIComponent(url)}&${encodeURIComponent(paramString)}`; + const signingKey = `${encodeURIComponent(X_API_SECRET)}&${encodeURIComponent(X_ACCESS_TOKEN_SECRET)}`; + const signature = createHmac("sha1", signingKey).update(signatureBase).digest("base64"); + + oauthParams.oauth_signature = signature; + + const headerParts = Object.keys(oauthParams) + .sort() + .map((k) => `${encodeURIComponent(k)}="${encodeURIComponent(oauthParams[k])}"`) + .join(", "); + + return `OAuth ${headerParts}`; +} + +const XPostResponseSchema = v.object({ + data: v.object({ + id: v.string(), + text: v.string(), + }), +}); + +/** Post a tweet (or reply) to X. Returns result with tweet URL on success. */ +async function postToX(text: string, replyToTweetId?: string): Promise { + if (!X_API_KEY || !X_API_SECRET || !X_ACCESS_TOKEN || !X_ACCESS_TOKEN_SECRET) { + return { + ok: false, + error: "X API credentials not configured", + }; + } + if (!text || text.length > 280) { + return { + ok: false, + error: `Invalid tweet length (${text.length} chars)`, + }; + } + + const url = "https://api.x.com/2/tweets"; + const payload: Record = { + text, + }; + if (replyToTweetId) { + payload.reply = { + in_reply_to_tweet_id: replyToTweetId, + }; + } + + const authHeader = generateXOAuthHeader("POST", url); + + try { + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + "User-Agent": "spawn-growth/1.0", + }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + return { + ok: false, + error: `X API ${res.status}: ${errBody.slice(0, 200)}`, + }; + } + + const json: unknown = await res.json(); + const parsed = v.safeParse(XPostResponseSchema, json); + if (!parsed.success) { + return { + ok: false, + error: "Unexpected X API response shape", + }; + } + + return { + ok: true, + tweetId: parsed.output.data.id, + tweetUrl: `https://x.com/i/status/${parsed.output.data.id}`, + }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +// #endregion + // #region Bot identity let BOT_USER_ID = ""; @@ -1268,7 +1386,7 @@ app.action("growth_skip", async ({ ack, body, client }) => { } }); -// --- tweet_approve: mark tweet as approved --- +// --- tweet_approve: post tweet to X --- app.action("tweet_approve", async ({ ack, body, client }) => { await ack(); const payload = toRecord("actions" in body && Array.isArray(body.actions) ? body.actions[0] : null); @@ -1279,19 +1397,40 @@ app.action("tweet_approve", async ({ ack, body, client }) => { const tweet = findTweet(db, tweetId); if (!tweet || tweet.status !== "pending") return; - updateTweetStatus(db, tweetId, { - status: "approved", - actionedBy: userId, - }); - logTweetDecision(tweet, "approved"); + // Post to X + const xResult = await postToX(tweet.tweetText, tweet.sourceTweetId ?? undefined); - if (tweet.slackChannel && tweet.slackTs) { - await replaceButtonsWithStatus( - client, - tweet.slackChannel, - tweet.slackTs, - `:white_check_mark: Tweet approved by <@${userId}> — ready to post on X`, - ); + if (xResult.ok) { + updateTweetStatus(db, tweetId, { + status: "posted", + actionedBy: userId, + postedText: tweet.tweetText, + }); + logTweetDecision(tweet, "approved"); + + if (tweet.slackChannel && tweet.slackTs) { + await replaceButtonsWithStatus( + client, + tweet.slackChannel, + tweet.slackTs, + `:white_check_mark: Posted to X by <@${userId}> — <${xResult.tweetUrl}|view tweet>`, + ); + } + } else { + updateTweetStatus(db, tweetId, { + status: "error", + actionedBy: userId, + }); + + if (tweet.slackChannel && tweet.slackTs) { + await client.chat + .postMessage({ + channel: tweet.slackChannel, + thread_ts: tweet.slackTs, + text: `:x: Failed to post to X: ${xResult.error}`, + }) + .catch(() => {}); + } } }); @@ -1345,7 +1484,7 @@ app.action("tweet_edit", async ({ ack, body, client }) => { .catch(() => {}); }); -// --- tweet_edit_submit: modal submitted with edited tweet --- +// --- tweet_edit_submit: modal submitted with edited tweet, post to X --- app.view("tweet_edit_submit", async ({ ack, view, body, client }) => { await ack(); const tweetId = view.private_metadata; @@ -1360,22 +1499,47 @@ app.view("tweet_edit_submit", async ({ ack, view, body, client }) => { const userId = toRecord(body.user) ? String((toRecord(body.user) ?? {}).id ?? "") : ""; - db.run("UPDATE tweets SET tweet_text = ? WHERE tweet_id = ?", [editedText, tweetId]); + db.run("UPDATE tweets SET tweet_text = ? WHERE tweet_id = ?", [ + editedText, + tweetId, + ]); - updateTweetStatus(db, tweetId, { - status: "approved", - actionedBy: userId, - postedText: editedText, - }); - logTweetDecision(tweet, "edited", editedText); + // Post edited tweet to X + const xResult = await postToX(editedText, tweet.sourceTweetId ?? undefined); - if (tweet.slackChannel && tweet.slackTs) { - await replaceButtonsWithStatus( - client, - tweet.slackChannel, - tweet.slackTs, - `:white_check_mark: Tweet edited & approved by <@${userId}> — ready to post on X`, - ); + if (xResult.ok) { + updateTweetStatus(db, tweetId, { + status: "posted", + actionedBy: userId, + postedText: editedText, + }); + logTweetDecision(tweet, "edited", editedText); + + if (tweet.slackChannel && tweet.slackTs) { + await replaceButtonsWithStatus( + client, + tweet.slackChannel, + tweet.slackTs, + `:white_check_mark: Tweet edited & posted to X by <@${userId}> — <${xResult.tweetUrl}|view tweet>`, + ); + } + } else { + updateTweetStatus(db, tweetId, { + status: "error", + actionedBy: userId, + postedText: editedText, + }); + logTweetDecision(tweet, "edited", editedText); + + if (tweet.slackChannel && tweet.slackTs) { + await client.chat + .postMessage({ + channel: tweet.slackChannel, + thread_ts: tweet.slackTs, + text: `:x: Tweet edited but failed to post to X: ${xResult.error}`, + }) + .catch(() => {}); + } } }); @@ -1498,7 +1662,10 @@ app.view("xeng_edit_submit", async ({ ack, view, body, client }) => { const userId = toRecord(body.user) ? String((toRecord(body.user) ?? {}).id ?? "") : ""; - db.run("UPDATE tweets SET tweet_text = ? WHERE tweet_id = ?", [editedText, engageId]); + db.run("UPDATE tweets SET tweet_text = ? WHERE tweet_id = ?", [ + editedText, + engageId, + ]); updateTweetStatus(db, engageId, { status: "approved", @@ -1840,10 +2007,7 @@ async function postCandidateCard( } /** Post a tweet draft card to Slack for approval. */ -async function postTweetCard( - client: SlackClient, - payload: typeof TweetPayloadSchema._types.output, -): Promise { +async function postTweetCard(client: SlackClient, payload: typeof TweetPayloadSchema._types.output): Promise { const db = openDb(); if (!payload.found) { @@ -1852,7 +2016,10 @@ async function postTweetCard( channel: SLACK_CHANNEL_ID, text, }); - return Response.json({ ok: true, action: "no_tweet" }); + return Response.json({ + ok: true, + action: "no_tweet", + }); } const tweetText = payload.tweetText ?? ""; @@ -1868,13 +2035,16 @@ async function postTweetCard( .map((h) => ``) .join(", "); - const categoryIcon = - category === "fix" ? ":wrench:" : category === "best-practice" ? ":bulb:" : ":rocket:"; + const categoryIcon = category === "fix" ? ":wrench:" : category === "best-practice" ? ":bulb:" : ":rocket:"; const blocks: KnownBlock[] = [ { type: "header", - text: { type: "plain_text", text: "🐦 Tweet Draft — " + category, emoji: true }, + text: { + type: "plain_text", + text: "🐦 Tweet Draft — " + category, + emoji: true, + }, }, { type: "section", @@ -1894,27 +2064,42 @@ async function postTweetCard( }, { type: "section", - text: { type: "mrkdwn", text: `*Topic:* ${topic}` }, + text: { + type: "mrkdwn", + text: `*Topic:* ${topic}`, + }, }, { type: "actions", elements: [ { type: "button", - text: { type: "plain_text", text: "Approve", emoji: true }, + text: { + type: "plain_text", + text: "Approve", + emoji: true, + }, style: "primary", action_id: "tweet_approve", value: tweetId, }, { type: "button", - text: { type: "plain_text", text: "Edit", emoji: true }, + text: { + type: "plain_text", + text: "Edit", + emoji: true, + }, action_id: "tweet_edit", value: tweetId, }, { type: "button", - text: { type: "plain_text", text: "Skip", emoji: true }, + text: { + type: "plain_text", + text: "Skip", + emoji: true, + }, style: "danger", action_id: "tweet_skip", value: tweetId, @@ -1941,7 +2126,11 @@ async function postTweetCard( createdAt: now.toISOString(), }); - return Response.json({ ok: true, action: "posted", tweetId }); + return Response.json({ + ok: true, + action: "posted", + tweetId, + }); } /** Post an X engagement opportunity card to Slack for approval. */ @@ -1957,7 +2146,10 @@ async function postXEngageCard( channel: SLACK_CHANNEL_ID, text, }); - return Response.json({ ok: true, action: "no_engage" }); + return Response.json({ + ok: true, + action: "no_engage", + }); } const replyText = payload.replyText ?? ""; @@ -1973,7 +2165,11 @@ async function postXEngageCard( const blocks: KnownBlock[] = [ { type: "header", - text: { type: "plain_text", text: "🔍 X Mention — Engagement Opportunity", emoji: true }, + text: { + type: "plain_text", + text: "🔍 X Mention — Engagement Opportunity", + emoji: true, + }, }, { type: "section", @@ -1984,7 +2180,10 @@ async function postXEngageCard( }, { type: "section", - text: { type: "mrkdwn", text: `*Why engage:* ${whyEngage}` }, + text: { + type: "mrkdwn", + text: `*Why engage:* ${whyEngage}`, + }, }, { type: "section", @@ -2007,20 +2206,32 @@ async function postXEngageCard( elements: [ { type: "button", - text: { type: "plain_text", text: "Approve", emoji: true }, + text: { + type: "plain_text", + text: "Approve", + emoji: true, + }, style: "primary", action_id: "xeng_approve", value: engageId, }, { type: "button", - text: { type: "plain_text", text: "Edit", emoji: true }, + text: { + type: "plain_text", + text: "Edit", + emoji: true, + }, action_id: "xeng_edit", value: engageId, }, { type: "button", - text: { type: "plain_text", text: "Skip", emoji: true }, + text: { + type: "plain_text", + text: "Skip", + emoji: true, + }, style: "danger", action_id: "xeng_skip", value: engageId, @@ -2048,7 +2259,11 @@ async function postXEngageCard( createdAt: now.toISOString(), }); - return Response.json({ ok: true, action: "posted", engageId }); + return Response.json({ + ok: true, + action: "posted", + engageId, + }); } /** Get a Reddit OAuth access token. */ @@ -2067,7 +2282,12 @@ async function getRedditToken(): Promise { body: `grant_type=password&username=${encodeURIComponent(REDDIT_USERNAME)}&password=${encodeURIComponent(REDDIT_PASSWORD)}`, }); const json: unknown = await res.json(); - const parsed = v.safeParse(v.object({ access_token: v.string() }), json); + const parsed = v.safeParse( + v.object({ + access_token: v.string(), + }), + json, + ); return parsed.success ? parsed.output.access_token : null; } @@ -2099,7 +2319,12 @@ async function postRedditReply(postId: string, replyText: string): Promise(); +const rateLimitBuckets = new Map< + string, + { + count: number; + resetAt: number; + } +>(); function checkRateLimit(endpoint: string): boolean { const now = Date.now(); - const bucket = rateLimitBuckets.get(endpoint) ?? { count: 0, resetAt: now + 60_000 }; + const bucket = rateLimitBuckets.get(endpoint) ?? { + count: 0, + resetAt: now + 60_000, + }; if (now > bucket.resetAt) { bucket.count = 0; bucket.resetAt = now + 60_000; @@ -2172,7 +2408,14 @@ function startHttpServer(client: SlackClient): void { if (req.method === "GET" && url.pathname === "/health") { if (!checkRateLimit("/health")) { - return Response.json({ error: "rate limit exceeded" }, { status: 429 }); + return Response.json( + { + error: "rate limit exceeded", + }, + { + status: 429, + }, + ); } return Response.json({ status: "ok", @@ -2191,7 +2434,14 @@ function startHttpServer(client: SlackClient): void { ); } if (!checkRateLimit("/candidate")) { - return Response.json({ error: "rate limit exceeded" }, { status: 429 }); + return Response.json( + { + error: "rate limit exceeded", + }, + { + status: 429, + }, + ); } let body: unknown; @@ -2212,14 +2462,28 @@ function startHttpServer(client: SlackClient): void { if (bodyObj && bodyObj.type === "tweet") { const parsed = v.safeParse(TweetPayloadSchema, body); if (!parsed.success) { - return Response.json({ error: "invalid tweet payload" }, { status: 400 }); + return Response.json( + { + error: "invalid tweet payload", + }, + { + status: 400, + }, + ); } return postTweetCard(client, parsed.output); } if (bodyObj && bodyObj.type === "x_engage") { const parsed = v.safeParse(XEngagePayloadSchema, body); if (!parsed.success) { - return Response.json({ error: "invalid engage payload" }, { status: 400 }); + return Response.json( + { + error: "invalid engage payload", + }, + { + status: 400, + }, + ); } return postXEngageCard(client, parsed.output); } @@ -2252,7 +2516,14 @@ function startHttpServer(client: SlackClient): void { ); } if (!checkRateLimit("/reply")) { - return Response.json({ error: "rate limit exceeded" }, { status: 429 }); + return Response.json( + { + error: "rate limit exceeded", + }, + { + status: 429, + }, + ); } const replySchema = v.object({