diff --git a/.claude/skills/setup-agent-team/growth-prompt.md b/.claude/skills/setup-agent-team/growth-prompt.md index 43222cc55..2a8a6c7a1 100644 --- a/.claude/skills/setup-agent-team/growth-prompt.md +++ b/.claude/skills/setup-agent-team/growth-prompt.md @@ -1,8 +1,8 @@ -You are the Reddit growth discovery agent for Spawn (https://github.com/OpenRouterTeam/spawn). +You are the growth discovery agent for Spawn (https://github.com/OpenRouterTeam/spawn). Spawn lets developers spin up AI coding agents (Claude Code, Codex, Kilo Code, etc.) on cloud servers with one command: `curl -fsSL openrouter.ai/labs/spawn | bash` -Your job: from the pre-fetched Reddit posts below, find the ONE best thread where someone is asking for something Spawn solves, verify the poster looks like a real developer, and output a structured summary. You do NOT post replies. You only score and report. +Your job: from the pre-fetched posts below (from Reddit and X), find the ONE best thread where someone is asking for something Spawn solves, verify the poster looks like a real developer, and output a structured summary. You do NOT post replies. You only score and report. **IMPORTANT: Do NOT use any tools.** All data is provided below. Your entire response should be plain text output — no bash commands, no file reads, no tool calls. Just analyze the data and respond with your findings. @@ -14,12 +14,15 @@ The team has reviewed previous candidates. Learn from these patterns — what go DECISIONS_PLACEHOLDER ``` -## Pre-fetched Reddit data +## Pre-fetched post data (Reddit + X) -The following posts were fetched automatically. Each post includes the title, selftext, subreddit, engagement stats, and the poster's recent comment history. +The following posts were fetched automatically from Reddit and X (Twitter). Each post includes a `platform` field (`"reddit"` or `"x"`), the title/text, engagement stats, and author context. + +For Reddit posts: `authorComments` contains recent comment history from their profile. +For X posts: `authorComments` contains their bio/description and follower count (we don't fetch tweet history to stay within API budget). ```json -REDDIT_DATA_PLACEHOLDER +POST_DATA_PLACEHOLDER ``` ## Step 1: Score for relevance @@ -40,7 +43,7 @@ For each post, score it on these criteria: - "Is there a way to deploy multiple AI coding tools without configuring each one?" **Is the thread alive?** (0-2 points) -- 2: Posted in last 48h with 3+ comments or 5+ upvotes +- 2: Posted in last 48h with 3+ comments/replies or 5+ upvotes/likes - 1: Posted in last week, some engagement - 0: Dead thread or very old @@ -54,9 +57,9 @@ Only consider posts scoring 7+ out of 10. ## Step 2: Qualify the poster -For the top candidates (scored 7+), check the poster's comment history (provided in `authorComments`). +For the top candidates (scored 7+), check the poster's context (provided in `authorComments`). -**Positive signals (look for ANY of these):** +**Positive signals for Reddit posters (look for ANY of these):** - Mentions cloud providers (AWS, Hetzner, GCP, DigitalOcean, Azure, Vultr, Linode) - Mentions SSH, VPS, servers, self-hosting, Docker, containers - Posts in developer subreddits (r/programming, r/webdev, r/devops, r/SelfHosted) @@ -64,14 +67,22 @@ For the top candidates (scored 7+), check the poster's comment history (provided - Has technical vocabulary in their comments - Mentions paying for services or having accounts -**Disqualifying signals:** -- Account only posts in non-tech subreddits -- Posting history suggests they're not a developer +**Positive signals for X posters (look for ANY of these):** +- Bio mentions developer, engineer, DevOps, SRE, or similar technical role +- Bio mentions cloud providers, infrastructure, or dev tools +- High follower-to-following ratio (suggests active content creator) +- Bio links to GitHub, personal blog, or tech company +- Technical vocabulary in the tweet itself + +**Disqualifying signals (both platforms):** +- Account only posts in non-tech contexts +- Posting history/bio suggests they're not a developer - Already uses Spawn or OpenRouter (check for mentions) +- For X: bot-like account (excessive hashtags, no bio, suspicious engagement ratio) ## Step 3: Pick the ONE best candidate -From all qualified, high-scoring posts, pick exactly 1. The best one. If nothing scores 7+ after qualification, that's fine. Say "no candidates this cycle" and stop. +From all qualified, high-scoring posts across both platforms, pick exactly 1. The best one. If nothing scores 7+ after qualification, that's fine. Say "no candidates this cycle" and stop. ## Step 4: Output summary @@ -81,10 +92,11 @@ Print a structured summary of what you found. ``` === GROWTH CANDIDATE FOUND === -Thread: {post_title} -URL: https://reddit.com{permalink} -Subreddit: r/{subreddit} -Upvotes: {score} | Comments: {num_comments} +Platform: {reddit or x} +Thread: {post_title or first 100 chars of tweet} +URL: {full URL} +Source: {r/subreddit for Reddit, or @username for X} +Engagement: {upvotes/likes} | {comments/replies} Posted: {time_ago} What they asked: @@ -94,12 +106,12 @@ Why Spawn fits: {1-2 sentences} Poster qualification: -{signals found in their history} +{signals found in their history/bio} Relevance score: {score}/10 Draft reply: -{a short casual reply the team could use, written like a real dev on reddit. 2-3 sentences, no em dashes, no corporate speak, lowercase ok. end with "disclosure: i help build this" if mentioning spawn} +{a short casual reply the team could use, written like a real dev on that platform. 2-3 sentences, no em dashes, no corporate speak, lowercase ok. end with "disclosure: i help build this" if mentioning spawn. for X: keep under 280 chars if possible} === END CANDIDATE === ``` @@ -109,13 +121,14 @@ Draft reply: ```json:candidate { "found": true, - "title": "{post_title}", - "url": "https://reddit.com{permalink}", - "permalink": "{permalink}", - "subreddit": "{subreddit}", - "postId": "{thing fullname, e.g. t3_abc123}", - "upvotes": {score}, - "numComments": {num_comments}, + "platform": "{reddit or x}", + "title": "{post_title or first 100 chars}", + "url": "{full URL}", + "permalink": "{permalink or full URL}", + "subreddit": "{subreddit or x}", + "postId": "{thing fullname for Reddit e.g. t3_abc123, or tweet_12345 for X}", + "upvotes": {score_or_likes}, + "numComments": {comments_or_replies}, "postedAgo": "{time_ago}", "whatTheyAsked": "{brief summary}", "whySpawnFits": "{1-2 sentences}", @@ -147,6 +160,6 @@ And the machine-readable JSON: ## Safety rules 1. **Pick exactly 1 candidate per cycle.** No more. -2. **Do NOT post replies to Reddit.** You only score and report. +2. **Do NOT post replies.** You only score and report. 3. **No candidates is a valid outcome.** Don't force bad matches. 4. **Don't surface threads from Spawn/OpenRouter team members.** diff --git a/.claude/skills/setup-agent-team/growth.sh b/.claude/skills/setup-agent-team/growth.sh index 9dfb7ee77..84241f4ec 100644 --- a/.claude/skills/setup-agent-team/growth.sh +++ b/.claude/skills/setup-agent-team/growth.sh @@ -1,9 +1,10 @@ #!/bin/bash set -eo pipefail -# Reddit Growth Agent — Single Cycle (Discovery Only) -# Phase 1: Batch-fetch Reddit posts via reddit-fetch.ts (fast, parallel) -# Phase 2: Pass results to Claude for scoring/qualification (no tool use) +# Growth Agent — Single Cycle (Discovery Only) +# Phase 1a: Batch-fetch Reddit posts via reddit-fetch.ts (fast, parallel) +# Phase 1b: Batch-fetch X posts via x-fetch.ts (if X_BEARER_TOKEN is set) +# Phase 2: Pass merged results to Claude for scoring/qualification (no tool use) # Phase 3: POST candidate to SPA for Slack notification SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -33,7 +34,7 @@ cleanup() { local exit_code=$? log "Running cleanup (exit_code=${exit_code})..." - rm -f "${PROMPT_FILE:-}" "${REDDIT_DATA_FILE:-}" "${CLAUDE_STREAM_FILE:-}" 2>/dev/null || true + rm -f "${PROMPT_FILE:-}" "${REDDIT_DATA_FILE:-}" "${X_DATA_FILE:-}" "${MERGED_DATA_FILE:-}" "${CLAUDE_STREAM_FILE:-}" 2>/dev/null || true if [[ -n "${CLAUDE_PID:-}" ]] && kill -0 "${CLAUDE_PID}" 2>/dev/null; then kill -TERM "${CLAUDE_PID}" 2>/dev/null || true fi @@ -53,8 +54,8 @@ log "Fetching latest refs..." git fetch --prune origin 2>&1 | tee -a "${LOG_FILE}" || true git reset --hard origin/main 2>&1 | tee -a "${LOG_FILE}" || true -# --- Phase 1: Batch fetch Reddit posts --- -log "Phase 1: Fetching Reddit posts..." +# --- Phase 1a: Batch fetch Reddit posts --- +log "Phase 1a: Fetching Reddit posts..." REDDIT_DATA_FILE=$(mktemp /tmp/growth-reddit-XXXXXX.json) chmod 0600 "${REDDIT_DATA_FILE}" @@ -64,8 +65,54 @@ if ! bun run "${SCRIPT_DIR}/reddit-fetch.ts" > "${REDDIT_DATA_FILE}" 2>> "${LOG_ exit 1 fi -POST_COUNT=$(bun -e "const d=JSON.parse(await Bun.file('${REDDIT_DATA_FILE}').text()); console.log(d.postsScanned ?? d.posts?.length ?? 0)") -log "Phase 1 done: ${POST_COUNT} posts fetched" +REDDIT_COUNT=$(bun -e "const d=JSON.parse(await Bun.file('${REDDIT_DATA_FILE}').text()); console.log(d.postsScanned ?? d.posts?.length ?? 0)") +log "Phase 1a done: ${REDDIT_COUNT} Reddit posts fetched" + +# --- Phase 1b: Batch fetch X (Twitter) posts (if X_BEARER_TOKEN is set) --- +X_DATA_FILE="" +X_COUNT=0 +if [[ -n "${X_BEARER_TOKEN:-}" ]]; then + log "Phase 1b: Fetching X posts..." + X_DATA_FILE=$(mktemp /tmp/growth-x-XXXXXX.json) + chmod 0600 "${X_DATA_FILE}" + + if bun run "${SCRIPT_DIR}/x-fetch.ts" > "${X_DATA_FILE}" 2>> "${LOG_FILE}"; then + X_COUNT=$(bun -e "const d=JSON.parse(await Bun.file('${X_DATA_FILE}').text()); console.log(d.postsScanned ?? d.posts?.length ?? 0)") + log "Phase 1b done: ${X_COUNT} X posts fetched" + else + log "WARN: x-fetch.ts failed, continuing with Reddit only" + rm -f "${X_DATA_FILE}" 2>/dev/null || true + X_DATA_FILE="" + fi +else + log "Phase 1b: Skipping X fetch (X_BEARER_TOKEN not set)" +fi + +# --- Merge Reddit + X data --- +MERGED_DATA_FILE=$(mktemp /tmp/growth-merged-XXXXXX.json) +chmod 0600 "${MERGED_DATA_FILE}" +_X_DATA_FILE="${X_DATA_FILE}" bun -e " +const reddit = JSON.parse(await Bun.file('${REDDIT_DATA_FILE}').text()); +for (const p of reddit.posts ?? []) { p.platform = p.platform ?? 'reddit'; } + +let xPosts: unknown[] = []; +const xPath = process.env._X_DATA_FILE ?? ''; +if (xPath) { + try { + const x = JSON.parse(await Bun.file(xPath).text()); + xPosts = x.posts ?? []; + } catch {} +} + +const merged = { + posts: [...(reddit.posts ?? []), ...xPosts], + postsScanned: (reddit.postsScanned ?? 0) + xPosts.length, +}; +await Bun.write('${MERGED_DATA_FILE}', JSON.stringify(merged)); +" 2>> "${LOG_FILE}" + +POST_COUNT=$(bun -e "const d=JSON.parse(await Bun.file('${MERGED_DATA_FILE}').text()); console.log(d.postsScanned ?? 0)") +log "Phase 1 done: ${POST_COUNT} total posts (Reddit: ${REDDIT_COUNT}, X: ${X_COUNT})" # --- Phase 2: Score with Claude --- log "Phase 2: Scoring with Claude..." @@ -79,18 +126,17 @@ if [[ ! -f "$PROMPT_TEMPLATE" ]]; then exit 1 fi -# Inject Reddit data into prompt template -REDDIT_JSON=$(cat "${REDDIT_DATA_FILE}") +# Inject merged data into prompt template # Use bun for safe substitution to avoid sed escaping issues with JSON DECISIONS_FILE="${HOME}/.config/spawn/growth-decisions.md" bun -e " import { existsSync } from 'node:fs'; const template = await Bun.file('${PROMPT_TEMPLATE}').text(); -const data = await Bun.file('${REDDIT_DATA_FILE}').text(); +const data = await Bun.file('${MERGED_DATA_FILE}').text(); const decisionsPath = '${DECISIONS_FILE}'; const decisions = existsSync(decisionsPath) ? await Bun.file(decisionsPath).text() : 'No past decisions yet.'; const result = template - .replace('REDDIT_DATA_PLACEHOLDER', data.trim()) + .replace('POST_DATA_PLACEHOLDER', data.trim()) .replace('DECISIONS_PLACEHOLDER', decisions.trim()); await Bun.write('${PROMPT_FILE}', result); " diff --git a/.claude/skills/setup-agent-team/trigger-server.ts b/.claude/skills/setup-agent-team/trigger-server.ts index 71604d927..8f908b44c 100644 --- a/.claude/skills/setup-agent-team/trigger-server.ts +++ b/.claude/skills/setup-agent-team/trigger-server.ts @@ -182,6 +182,7 @@ process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); process.on("SIGINT", () => gracefulShutdown("SIGINT")); const REPLY_SCRIPT = resolve(SKILL_DIR, "reply.sh"); +const X_REPLY_SCRIPT = resolve(SKILL_DIR, "x-reply.sh"); const REPLY_SECRET = process.env.REPLY_SECRET ?? TRIGGER_SECRET; /** Check auth against a given secret (timing-safe). */ @@ -256,6 +257,68 @@ async function handleReply(req: Request): Promise { } } +/** + * Handle POST /x-reply — post a reply tweet via x-reply.sh. + * This is synchronous: it waits for x-reply.sh to finish and returns the result. + */ +async function handleXReply(req: Request): Promise { + if (!isAuthedWith(req, REPLY_SECRET)) { + return Response.json({ error: "unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return Response.json({ error: "invalid JSON body" }, { status: 400 }); + } + + const obj = typeof body === "object" && body !== null ? (body as Record) : null; + const tweetId = obj && typeof obj.tweetId === "string" ? obj.tweetId : ""; + const replyText = obj && typeof obj.replyText === "string" ? obj.replyText : ""; + + if (!tweetId || !replyText) { + return Response.json({ error: "tweetId and replyText are required" }, { status: 400 }); + } + + // Validate tweetId format (numeric string) + if (!/^\d{1,20}$/.test(tweetId)) { + return Response.json({ error: "invalid tweetId format (must be numeric)" }, { status: 400 }); + } + + console.log(`[trigger] X reply request: tweetId=${tweetId}, replyText=${replyText.slice(0, 80)}...`); + + const proc = Bun.spawn(["bash", X_REPLY_SCRIPT], { + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + TWEET_ID: tweetId, + REPLY_TEXT: replyText, + }, + }); + + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + console.error(`[trigger] x-reply.sh failed (exit=${exitCode}): ${stderr}`); + return Response.json({ error: "x reply failed", stderr: stderr.slice(0, 500) }, { status: 502 }); + } + + // Parse x-reply.sh JSON output + try { + const result = JSON.parse(stdout.trim()); + console.log(`[trigger] X reply posted: ${JSON.stringify(result)}`); + return Response.json(result); + } catch { + return Response.json({ ok: true, raw: stdout.trim() }); + } +} + /** * Spawn the target script and return immediately with a JSON response. * Script stdout/stderr are piped to the server console (journalctl). @@ -355,6 +418,13 @@ const server = Bun.serve({ return handleReply(req); } + if (req.method === "POST" && url.pathname === "/x-reply") { + if (shuttingDown) { + return Response.json({ error: "server is shutting down" }, { status: 503 }); + } + return handleXReply(req); + } + if (req.method === "POST" && url.pathname === "/trigger") { if (shuttingDown) { return Response.json( diff --git a/.claude/skills/setup-agent-team/x-fetch.ts b/.claude/skills/setup-agent-team/x-fetch.ts new file mode 100644 index 000000000..46600c42d --- /dev/null +++ b/.claude/skills/setup-agent-team/x-fetch.ts @@ -0,0 +1,231 @@ +/** + * X (Twitter) Fetch — Batch scanner for the growth agent. + * + * Authenticates with X API v2 Bearer token, runs search queries, + * deduplicates (including against SPA's candidate DB), and outputs JSON + * to stdout in the same shape as reddit-fetch.ts. + * + * Env vars: X_BEARER_TOKEN + * + * Budget: ~$0.30/day (3 queries x 20 results = 60 tweet reads at $0.005 each) + */ + +import { Database } from "bun:sqlite"; +import { existsSync } from "node:fs"; + +const BEARER_TOKEN = process.env.X_BEARER_TOKEN ?? ""; + +if (!BEARER_TOKEN) { + console.error("Missing X_BEARER_TOKEN"); + process.exit(1); +} + +// Search queries — broad to maximize signal with limited budget (3 queries x 20 results) +const QUERIES = [ + '("coding agent" OR "AI coding") (cloud OR server OR VPS OR deploy) -is:retweet', + '("Claude Code" OR "Codex CLI" OR "Kilo Code" OR "coding assistant") (remote OR "self-host") -is:retweet', + "(openrouter OR spawn) (coding OR agent OR deploy) -is:retweet", +]; + +const MAX_RESULTS = 20; + +interface XPost { + title: string; + permalink: string; + subreddit: string; + postId: string; + score: number; + numComments: number; + createdUtc: number; + selftext: string; + authorName: string; + authorComments: string[]; + platform: "x"; +} + +interface TweetPublicMetrics { + retweet_count: number; + reply_count: number; + like_count: number; + quote_count: number; + impression_count: number; +} + +interface TweetData { + id: string; + text: string; + created_at: string; + author_id: string; + conversation_id: string; + public_metrics: TweetPublicMetrics; +} + +interface UserData { + id: string; + username: string; + name: string; + description: string; + public_metrics: { + followers_count: number; + following_count: number; + tweet_count: number; + listed_count: number; + }; +} + +interface SearchResponse { + data?: TweetData[]; + includes?: { + users?: UserData[]; + }; + meta?: { + newest_id: string; + oldest_id: string; + result_count: number; + }; +} + +/** Load post IDs already seen by SPA from the candidates DB. */ +function loadSeenPostIds(): Set { + const dbPath = `${process.env.HOME ?? "/tmp"}/.config/spawn/state.db`; + if (!existsSync(dbPath)) return new Set(); + try { + const db = new Database(dbPath, { readonly: true }); + const rows = db + .query<{ post_id: string }, []>("SELECT post_id FROM candidates") + .all(); + db.close(); + return new Set(rows.map((r) => r.post_id)); + } catch { + return new Set(); + } +} + +/** Fetch from X API v2 with Bearer auth. */ +async function xGet(path: string): Promise { + const res = await fetch(`https://api.twitter.com${path}`, { + headers: { + Authorization: `Bearer ${BEARER_TOKEN}`, + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + console.error(`X API ${res.status}: ${path} — ${body.slice(0, 200)}`); + return null; + } + return res.json(); +} + +/** Run a single search query and extract posts. */ +async function searchTweets(query: string): Promise> { + const posts = new Map(); + const params = new URLSearchParams({ + query, + max_results: String(MAX_RESULTS), + "tweet.fields": "created_at,public_metrics,conversation_id,text,author_id", + expansions: "author_id", + "user.fields": "description,public_metrics,username,name", + }); + + const data = (await xGet(`/2/tweets/search/recent?${params}`)) as SearchResponse | null; + if (!data?.data) return posts; + + // Build author lookup from includes + const authors = new Map(); + if (data.includes?.users) { + for (const user of data.includes.users) { + authors.set(user.id, user); + } + } + + for (const tweet of data.data) { + const postId = `tweet_${tweet.id}`; + if (posts.has(postId)) continue; + + const author = authors.get(tweet.author_id); + const username = author?.username ?? ""; + const authorBio = author?.description ?? ""; + const followers = author?.public_metrics?.followers_count ?? 0; + + posts.set(postId, { + title: tweet.text.slice(0, 100), + permalink: `https://x.com/${username}/status/${tweet.id}`, + subreddit: "x", + postId, + score: tweet.public_metrics?.like_count ?? 0, + numComments: tweet.public_metrics?.reply_count ?? 0, + createdUtc: Math.floor(new Date(tweet.created_at).getTime() / 1000), + selftext: tweet.text, + authorName: username, + authorComments: authorBio + ? [`[bio] ${authorBio}`, `[followers: ${followers}]`] + : [], + platform: "x", + }); + } + + return posts; +} + +async function main(): Promise { + console.error("[x-fetch] Authenticating with Bearer token"); + + // Load already-seen post IDs from SPA's DB + const seenIds = loadSeenPostIds(); + console.error(`[x-fetch] ${seenIds.size} posts already seen in DB`); + + // Run all search queries sequentially (be nice to rate limits) + const allPosts = new Map(); + let skippedSeen = 0; + + for (const query of QUERIES) { + console.error(`[x-fetch] Searching: ${query.slice(0, 60)}...`); + const results = await searchTweets(query); + for (const [id, post] of results) { + if (seenIds.has(id)) { + skippedSeen++; + continue; + } + if (!allPosts.has(id)) { + allPosts.set(id, post); + } + } + } + + console.error( + `[x-fetch] Found ${allPosts.size} unique tweets (${skippedSeen} already seen, skipped)`, + ); + + // Filter to tweets with some engagement, sort by likes descending + const postsArray = [...allPosts.values()]; + const filtered = postsArray.filter( + (p) => p.score >= 1 || p.numComments >= 1, + ); + filtered.sort((a, b) => b.score - a.score); + + // Output JSON to stdout + const output = { + posts: filtered.map((p) => ({ + title: p.title, + permalink: p.permalink, + subreddit: p.subreddit, + postId: p.postId, + score: p.score, + numComments: p.numComments, + createdUtc: p.createdUtc, + selftext: p.selftext.slice(0, 500), + authorName: p.authorName, + authorComments: p.authorComments, + platform: p.platform, + })), + postsScanned: allPosts.size, + }; + + console.log(JSON.stringify(output)); + console.error(`[x-fetch] Done — ${filtered.length} tweets output`); +} + +main().catch((err) => { + console.error("Fatal:", err); + process.exit(1); +}); diff --git a/.claude/skills/setup-agent-team/x-reply.sh b/.claude/skills/setup-agent-team/x-reply.sh new file mode 100644 index 000000000..4f2a9c159 --- /dev/null +++ b/.claude/skills/setup-agent-team/x-reply.sh @@ -0,0 +1,136 @@ +#!/bin/bash +set -eo pipefail + +# X (Twitter) Reply — Posts a reply tweet to a thread. +# Called by trigger-server.ts via POST /x-reply. +# +# Required env vars: +# TWEET_ID — Tweet ID to reply to (numeric string) +# REPLY_TEXT — Reply text to post +# X_API_KEY — X OAuth 1.0a consumer key +# X_API_SECRET — X OAuth 1.0a consumer secret +# X_ACCESS_TOKEN — X OAuth 1.0a access token +# X_ACCESS_SECRET — X OAuth 1.0a access token secret + +if [[ -z "${TWEET_ID:-}" ]]; then + echo '{"ok":false,"error":"TWEET_ID env var is required"}' >&2 + exit 1 +fi + +if [[ -z "${REPLY_TEXT:-}" ]]; then + echo '{"ok":false,"error":"REPLY_TEXT env var is required"}' >&2 + exit 1 +fi + +if [[ -z "${X_API_KEY:-}" || -z "${X_API_SECRET:-}" || -z "${X_ACCESS_TOKEN:-}" || -z "${X_ACCESS_SECRET:-}" ]]; then + echo '{"ok":false,"error":"X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, and X_ACCESS_SECRET are all required"}' >&2 + exit 1 +fi + +# Use bun for OAuth 1.0a signing + HTTP request (HMAC-SHA1 is non-trivial in bash) +REPLY_SCRIPT=$(mktemp /tmp/x-reply-XXXXXX.ts) +chmod 0600 "${REPLY_SCRIPT}" +cat > "${REPLY_SCRIPT}" <<'EOSCRIPT' +import { createHmac, randomBytes } from "node:crypto"; + +const apiKey = process.env.X_API_KEY!; +const apiSecret = process.env.X_API_SECRET!; +const accessToken = process.env.X_ACCESS_TOKEN!; +const accessSecret = process.env.X_ACCESS_SECRET!; +const tweetId = process.env.TWEET_ID!; +const replyText = process.env.REPLY_TEXT!; + +const API_URL = "https://api.twitter.com/2/tweets"; + +/** Percent-encode per RFC 3986 (OAuth 1.0a requirement). */ +function percentEncode(str: string): string { + return encodeURIComponent(str).replace(/[!'()*]/g, (c) => + `%${c.charCodeAt(0).toString(16).toUpperCase()}` + ); +} + +/** Generate OAuth 1.0a Authorization header. */ +function buildOAuthHeader(method: string, url: string): string { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const nonce = randomBytes(16).toString("hex"); + + const oauthParams: Record = { + oauth_consumer_key: apiKey, + oauth_nonce: nonce, + oauth_signature_method: "HMAC-SHA1", + oauth_timestamp: timestamp, + oauth_token: accessToken, + oauth_version: "1.0", + }; + + // Build signature base string (no body params for JSON content type) + const paramString = Object.keys(oauthParams) + .sort() + .map((k) => `${percentEncode(k)}=${percentEncode(oauthParams[k])}`) + .join("&"); + + const baseString = `${method}&${percentEncode(url)}&${percentEncode(paramString)}`; + const signingKey = `${percentEncode(apiSecret)}&${percentEncode(accessSecret)}`; + const signature = createHmac("sha1", signingKey) + .update(baseString) + .digest("base64"); + + oauthParams.oauth_signature = signature; + + const header = Object.keys(oauthParams) + .sort() + .map((k) => `${percentEncode(k)}="${percentEncode(oauthParams[k])}"`) + .join(", "); + + return `OAuth ${header}`; +} + +// Post the reply +const authHeader = buildOAuthHeader("POST", API_URL); + +const body = JSON.stringify({ + text: replyText, + reply: { + in_reply_to_tweet_id: tweetId, + }, +}); + +const res = await fetch(API_URL, { + method: "POST", + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + }, + body, +}); + +if (!res.ok) { + const errBody = await res.text(); + console.log( + JSON.stringify({ + ok: false, + error: `X API reply failed: ${res.status}`, + body: errBody.slice(0, 500), + }), + ); + process.exit(1); +} + +const data = (await res.json()) as Record; +const tweetData = data.data as Record | undefined; +const newTweetId = typeof tweetData?.id === "string" ? tweetData.id : ""; + +console.log( + JSON.stringify({ + ok: true, + tweetId: newTweetId, + tweetUrl: newTweetId + ? `https://x.com/i/status/${newTweetId}` + : "", + }), +); +EOSCRIPT + +cleanup_reply() { rm -f "${REPLY_SCRIPT}" 2>/dev/null || true; } +trap cleanup_reply EXIT +exec bun run "${REPLY_SCRIPT}" diff --git a/.claude/skills/setup-spa/helpers.ts b/.claude/skills/setup-spa/helpers.ts index 45eab8914..5c1992d4f 100644 --- a/.claude/skills/setup-spa/helpers.ts +++ b/.claude/skills/setup-spa/helpers.ts @@ -182,9 +182,22 @@ export function openDb(path?: string): Database { actioned_at TEXT, posted_reply TEXT, reddit_comment_url TEXT, + reply_url TEXT, + platform TEXT NOT NULL DEFAULT 'reddit', created_at TEXT NOT NULL ) `); + // Migration: add platform and reply_url columns if missing (existing DBs) + try { + db.run("ALTER TABLE candidates ADD COLUMN platform TEXT NOT NULL DEFAULT 'reddit'"); + } catch { + // column already exists + } + try { + db.run("ALTER TABLE candidates ADD COLUMN reply_url TEXT"); + } catch { + // column already exists + } if (!path) { migrateFromJson(db); } @@ -275,7 +288,7 @@ export function updateThread( // #region Candidates — Reddit growth pipeline -/** A Reddit growth candidate tracked for approval. */ +/** A growth candidate tracked for approval (Reddit or X). */ export interface CandidateRow { postId: string; permalink: string; @@ -289,6 +302,8 @@ export interface CandidateRow { actionedAt?: string; postedReply?: string; redditCommentUrl?: string; + replyUrl?: string; + platform: "reddit" | "x"; createdAt: string; } @@ -306,6 +321,8 @@ interface RawCandidate { actioned_at: string | null; posted_reply: string | null; reddit_comment_url: string | null; + reply_url: string | null; + platform: string; created_at: string; } @@ -326,6 +343,8 @@ function rowToCandidate(r: RawCandidate): CandidateRow { actionedAt: r.actioned_at ?? undefined, postedReply: r.posted_reply ?? undefined, redditCommentUrl: r.reddit_comment_url ?? undefined, + replyUrl: r.reply_url ?? undefined, + platform: r.platform === "x" ? "x" : "reddit", createdAt: r.created_at, }; } @@ -333,8 +352,8 @@ function rowToCandidate(r: RawCandidate): CandidateRow { /** Insert or update a candidate. On conflict (same post_id), updates Slack coordinates. */ export function upsertCandidate(db: Database, candidate: CandidateRow): void { db.run( - `INSERT INTO candidates (post_id, permalink, title, subreddit, draft_reply, slack_channel, slack_ts, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `INSERT INTO candidates (post_id, permalink, title, subreddit, draft_reply, slack_channel, slack_ts, status, platform, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (post_id) DO UPDATE SET slack_channel = excluded.slack_channel, slack_ts = excluded.slack_ts`, @@ -347,12 +366,13 @@ export function upsertCandidate(db: Database, candidate: CandidateRow): void { candidate.slackChannel ?? null, candidate.slackTs ?? null, candidate.status, + candidate.platform, candidate.createdAt, ], ); } -/** Look up a candidate by Reddit post ID. */ +/** Look up a candidate by post ID (Reddit or X). */ export function findCandidate(db: Database, postId: string): CandidateRow | undefined { const row = db .query< @@ -374,6 +394,7 @@ export function updateCandidateStatus( actionedBy?: string; postedReply?: string; redditCommentUrl?: string; + replyUrl?: string; }, ): void { db.run( @@ -382,7 +403,8 @@ export function updateCandidateStatus( actioned_by = ?, actioned_at = ?, posted_reply = ?, - reddit_comment_url = ? + reddit_comment_url = ?, + reply_url = ? WHERE post_id = ?`, [ update.status, @@ -390,6 +412,7 @@ export function updateCandidateStatus( new Date().toISOString(), update.postedReply ?? null, update.redditCommentUrl ?? null, + update.replyUrl ?? null, postId, ], ); @@ -411,11 +434,17 @@ export function logDecision( const date = new Date().toISOString().split("T")[0]; const reply = editedReply ?? candidate.draftReply; + const isX = candidate.platform === "x"; + const postUrl = isX + ? candidate.permalink + : `https://reddit.com${candidate.permalink}`; + const source = isX ? `@${candidate.subreddit}` : `r/${candidate.subreddit}`; const entry = ` ## ${decision.toUpperCase()} — ${date} -- **Post**: [${candidate.title}](https://reddit.com${candidate.permalink}) -- **Subreddit**: r/${candidate.subreddit} +- **Platform**: ${candidate.platform} +- **Post**: [${candidate.title}](${postUrl}) +- **Source**: ${source} - **Decision**: ${decision} ${editedReply ? "- **Edited**: yes (original draft was modified)\n" : ""}\ - **Reply**: ${reply.replace(/\n/g, " ")} diff --git a/.claude/skills/setup-spa/main.ts b/.claude/skills/setup-spa/main.ts index 103d5d801..54b2946be 100644 --- a/.claude/skills/setup-spa/main.ts +++ b/.claude/skills/setup-spa/main.ts @@ -47,6 +47,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_SECRET = process.env.X_ACCESS_SECRET ?? ""; for (const [name, value] of Object.entries({ SLACK_BOT_TOKEN, @@ -1028,39 +1032,46 @@ app.action("growth_approve", async ({ ack, body, client }) => { actionedBy: userId, }); - // POST to growth VM to send the Reddit reply + // POST to growth VM to send the reply (Reddit or X) if (!GROWTH_TRIGGER_URL) { await client.chat .postMessage({ channel: candidate.slackChannel ?? "", thread_ts: candidate.slackTs ?? undefined, - text: ":x: GROWTH_TRIGGER_URL not configured — cannot post to Reddit", + text: ":x: GROWTH_TRIGGER_URL not configured — cannot post reply", }) .catch(() => {}); return; } + const isXCandidate = candidate.platform === "x"; + const replyEndpoint = isXCandidate ? "/x-reply" : "/reply"; + const replyBody = isXCandidate + ? { tweetId: candidate.postId.replace(/^tweet_/, ""), replyText: candidate.draftReply } + : { postId: candidate.postId, replyText: candidate.draftReply }; + const platformName = isXCandidate ? "X" : "Reddit"; + try { - const res = await fetch(`${GROWTH_TRIGGER_URL}/reply`, { + const res = await fetch(`${GROWTH_TRIGGER_URL}${replyEndpoint}`, { method: "POST", headers: { Authorization: `Bearer ${GROWTH_REPLY_SECRET}`, "Content-Type": "application/json", }, - body: JSON.stringify({ - postId: candidate.postId, - replyText: candidate.draftReply, - }), + body: JSON.stringify(replyBody), }); const result = toRecord(await res.json().catch(() => null)); if (res.ok && result && result.ok) { - const commentUrl = isString(result.commentUrl) ? result.commentUrl : ""; + const replyUrl = isXCandidate + ? (isString(result.tweetUrl) ? result.tweetUrl : "") + : (isString(result.commentUrl) ? result.commentUrl : ""); updateCandidateStatus(db, postId, { status: "posted", actionedBy: userId, postedReply: candidate.draftReply, - redditCommentUrl: commentUrl, + redditCommentUrl: isXCandidate ? undefined : replyUrl, + replyUrl, }); logDecision(candidate, "approved"); // Update the Slack message — replace buttons with confirmation @@ -1069,7 +1080,7 @@ app.action("growth_approve", async ({ ack, body, client }) => { client, candidate.slackChannel, candidate.slackTs, - `:white_check_mark: Posted by <@${userId}>${commentUrl ? ` — <${commentUrl}|view comment>` : ""}`, + `:white_check_mark: Posted by <@${userId}>${replyUrl ? ` — <${replyUrl}|view reply>` : ""}`, ); } } else { @@ -1082,7 +1093,7 @@ app.action("growth_approve", async ({ ack, body, client }) => { .postMessage({ channel: candidate.slackChannel ?? "", thread_ts: candidate.slackTs ?? undefined, - text: `:x: Reddit reply failed: ${errMsg}`, + text: `:x: ${platformName} reply failed: ${errMsg}`, }) .catch(() => {}); } @@ -1095,7 +1106,7 @@ app.action("growth_approve", async ({ ack, body, client }) => { .postMessage({ channel: candidate.slackChannel ?? "", thread_ts: candidate.slackTs ?? undefined, - text: `:x: Reddit reply failed: ${err instanceof Error ? err.message : String(err)}`, + text: `:x: ${platformName} reply failed: ${err instanceof Error ? err.message : String(err)}`, }) .catch(() => {}); } @@ -1183,27 +1194,34 @@ app.view("growth_edit_submit", async ({ ack, view, body, client }) => { if (!GROWTH_TRIGGER_URL) return; + const isXCandidate = candidate.platform === "x"; + const replyEndpoint = isXCandidate ? "/x-reply" : "/reply"; + const replyBody = isXCandidate + ? { tweetId: candidate.postId.replace(/^tweet_/, ""), replyText: editedReply } + : { postId: candidate.postId, replyText: editedReply }; + const platformName = isXCandidate ? "X" : "Reddit"; + try { - const res = await fetch(`${GROWTH_TRIGGER_URL}/reply`, { + const res = await fetch(`${GROWTH_TRIGGER_URL}${replyEndpoint}`, { method: "POST", headers: { Authorization: `Bearer ${GROWTH_REPLY_SECRET}`, "Content-Type": "application/json", }, - body: JSON.stringify({ - postId: candidate.postId, - replyText: editedReply, - }), + body: JSON.stringify(replyBody), }); const result = toRecord(await res.json().catch(() => null)); if (res.ok && result && result.ok) { - const commentUrl = isString(result.commentUrl) ? result.commentUrl : ""; + const replyUrl = isXCandidate + ? (isString(result.tweetUrl) ? result.tweetUrl : "") + : (isString(result.commentUrl) ? result.commentUrl : ""); updateCandidateStatus(db, postId, { status: "posted", actionedBy: userId, postedReply: editedReply, - redditCommentUrl: commentUrl, + redditCommentUrl: isXCandidate ? undefined : replyUrl, + replyUrl, }); logDecision(candidate, "edited", editedReply); if (candidate.slackChannel && candidate.slackTs) { @@ -1211,7 +1229,7 @@ app.view("growth_edit_submit", async ({ ack, view, body, client }) => { client, candidate.slackChannel, candidate.slackTs, - `:white_check_mark: Posted (edited) by <@${userId}>${commentUrl ? ` — <${commentUrl}|view comment>` : ""}`, + `:white_check_mark: Posted (edited) by <@${userId}>${replyUrl ? ` — <${replyUrl}|view reply>` : ""}`, ); } } else { @@ -1224,7 +1242,7 @@ app.view("growth_edit_submit", async ({ ack, view, body, client }) => { .postMessage({ channel: candidate.slackChannel, thread_ts: candidate.slackTs, - text: `:x: Reddit reply failed: ${isString(result?.error) ? result.error : `HTTP ${res.status}`}`, + text: `:x: ${platformName} reply failed: ${isString(result?.error) ? result.error : `HTTP ${res.status}`}`, }) .catch(() => {}); } @@ -1314,6 +1332,7 @@ async function replaceButtonsWithStatus( /** Valibot schema for incoming candidate JSON from growth agent. */ const CandidatePayloadSchema = v.object({ found: v.boolean(), + platform: v.optional(v.picklist(["reddit", "x"]), "reddit"), title: v.optional(v.string()), url: v.optional(v.string()), permalink: v.optional(v.string()), @@ -1390,8 +1409,10 @@ async function postCandidateCard( } // Candidate found — build Block Kit card + const platform = candidate.platform ?? "reddit"; + const isX = platform === "x"; const title = candidate.title ?? "Untitled"; - const url = candidate.url ?? `https://reddit.com${candidate.permalink ?? ""}`; + const url = candidate.url ?? (isX ? candidate.permalink ?? "" : `https://reddit.com${candidate.permalink ?? ""}`); const postId = candidate.postId ?? ""; const subreddit = candidate.subreddit ?? ""; const upvotes = candidate.upvotes ?? 0; @@ -1399,12 +1420,18 @@ async function postCandidateCard( const postedAgo = candidate.postedAgo ?? ""; const draftReply = candidate.draftReply ?? ""; + const platformLabel = isX ? "X Growth" : "Reddit Growth"; + const engagementLabel = isX + ? `${upvotes} likes | ${numComments} replies` + : `${upvotes} upvotes | ${numComments} comments`; + const sourceLabel = isX ? `@${subreddit}` : `r/${subreddit}`; + const blocks: (KnownBlock | Block)[] = [ { type: "header", text: { type: "plain_text", - text: "Reddit Growth — Candidate Found", + text: `${platformLabel} — Candidate Found`, emoji: true, }, }, @@ -1412,7 +1439,7 @@ async function postCandidateCard( type: "section", text: { type: "mrkdwn", - text: `*<${url}|${title}>*\nr/${subreddit} | ${upvotes} upvotes | ${numComments} comments | ${postedAgo}`, + text: `*<${url}|${title}>*\n${sourceLabel} | ${engagementLabel} | ${postedAgo}`, }, }, ]; @@ -1510,7 +1537,7 @@ async function postCandidateCard( const msg = await client.chat.postMessage({ channel, - text: `Reddit Growth — ${title}`, + text: `${platformLabel} — ${title}`, blocks, }); @@ -1524,6 +1551,7 @@ async function postCandidateCard( slackChannel: channel, slackTs: msg.ts ?? undefined, status: "pending", + platform, createdAt: new Date().toISOString(), }); diff --git a/.claude/skills/setup-spa/spa.test.ts b/.claude/skills/setup-spa/spa.test.ts index 5d68e5de4..37bdb53ec 100644 --- a/.claude/skills/setup-spa/spa.test.ts +++ b/.claude/skills/setup-spa/spa.test.ts @@ -1060,6 +1060,7 @@ function makeCandidate(overrides: Partial = {}): CandidateRow { subreddit: "SelfHosted", draftReply: "check out spawn, it does exactly this. disclosure: i help build this", status: "pending", + platform: "reddit", createdAt: new Date().toISOString(), ...overrides, };