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 <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-04-20 00:02:30 -07:00 committed by GitHub
parent 165601bb46
commit 95da999efb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 476 additions and 60 deletions

View file

@ -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<string, string> = {
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<void> {
const url = "https://api.x.com/2/tweets";
const payload: Record<string, unknown> = {
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);
});

View file

@ -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<string, string> = {
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<XPostResult> {
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<string, unknown> = {
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<Response> {
async function postTweetCard(client: SlackClient, payload: typeof TweetPayloadSchema._types.output): Promise<Response> {
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) => `<https://github.com/${GITHUB_REPO}/commit/${h}|${h.slice(0, 7)}>`)
.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<string | null> {
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<Respo
const json: unknown = await res.json();
if (!res.ok) {
const errParsed = v.safeParse(v.object({ message: v.string() }), json);
const errParsed = v.safeParse(
v.object({
message: v.string(),
}),
json,
);
const errMsg = errParsed.success ? errParsed.output.message : `HTTP ${res.status}`;
console.error(`[spa] Reddit reply failed: ${errMsg}`);
return Response.json(
@ -2119,7 +2344,9 @@ async function postRedditReply(postId: string, replyText: string): Promise<Respo
jquery: v.array(v.unknown()),
});
const JqueryInnerSchema = v.object({
data: v.object({ permalink: v.string() }),
data: v.object({
permalink: v.string(),
}),
});
let commentUrl = "";
@ -2145,10 +2372,19 @@ async function postRedditReply(postId: string, replyText: string): Promise<Respo
}
/** Simple token-bucket rate limiter: max 10 requests per minute per endpoint. */
const rateLimitBuckets = new Map<string, { count: number; resetAt: number }>();
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({