From 39dc731cdac06daac3ae34c77e30e2336f4bbc76 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 00:05:36 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(growth):=20add=20Phase=200=20=E2=80=94?= =?UTF-8?q?=20daily=20tweet=20draft=20+=20X=20mention=20engagement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new Phase 0 to the growth agent cycle that runs before Reddit scanning: Phase 0a — Tweet Draft (always runs): - Gathers last 7 days of git commits - Claude drafts a single ≤280 char tweet about features, fixes, or best practices - Posts Block Kit card to #C0ARSCAP4MN with Approve/Edit/Skip buttons Phase 0b — X Mention Search (runs only if X_API_KEY is set): - x-fetch.ts searches X API v2 for Spawn/OpenRouter mentions - Claude scores mentions and drafts engagement replies - Posts engagement card to #C0ARSCAP4MN with approval buttons - Gracefully skips when no X credentials are configured All cards require human approval — nothing is ever auto-posted. New files: - tweet-prompt.md: Claude prompt for tweet generation - x-engage-prompt.md: Claude prompt for X engagement scoring - x-fetch.ts: X API v2 search client with OAuth 1.0a Modified files: - growth.sh: Phase 0a + 0b insertion, cleanup trap updates - helpers.ts: tweets table schema, TweetRow CRUD, logTweetDecision() - main.ts: TweetPayloadSchema, XEngagePayloadSchema, postTweetCard(), postXEngageCard(), 8 new Slack action handlers Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/setup-agent-team/growth.sh | 231 +++++++- .../skills/setup-agent-team/tweet-prompt.md | 75 +++ .../setup-agent-team/x-engage-prompt.md | 80 +++ .claude/skills/setup-agent-team/x-fetch.ts | 284 ++++++++++ .claude/skills/setup-spa/helpers.ts | 181 ++++++ .claude/skills/setup-spa/main.ts | 533 ++++++++++++++++++ 6 files changed, 1382 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/setup-agent-team/tweet-prompt.md create mode 100644 .claude/skills/setup-agent-team/x-engage-prompt.md create mode 100644 .claude/skills/setup-agent-team/x-fetch.ts diff --git a/.claude/skills/setup-agent-team/growth.sh b/.claude/skills/setup-agent-team/growth.sh index 0d3b0164d..69edfd9fd 100644 --- a/.claude/skills/setup-agent-team/growth.sh +++ b/.claude/skills/setup-agent-team/growth.sh @@ -1,7 +1,9 @@ #!/bin/bash set -eo pipefail -# Reddit Growth Agent — Single Cycle (Discovery Only) +# Growth Agent — Single Cycle +# Phase 0a: Draft daily tweet about Spawn features from git history +# Phase 0b: Search X for Spawn mentions + draft engagement replies (if X creds set) # Phase 1: Batch-fetch Reddit posts via reddit-fetch.ts (fast, parallel) # Phase 2: Pass results to Claude for scoring/qualification (no tool use) # Phase 3: POST candidate to SPA for Slack notification @@ -34,10 +36,19 @@ cleanup() { log "Running cleanup (exit_code=${exit_code})..." rm -f "${PROMPT_FILE:-}" "${REDDIT_DATA_FILE:-}" "${CLAUDE_STREAM_FILE:-}" \ - "${CLAUDE_OUTPUT_FILE:-}" "${SPA_AUTH_FILE:-}" "${SPA_BODY_FILE:-}" 2>/dev/null || true + "${CLAUDE_OUTPUT_FILE:-}" "${SPA_AUTH_FILE:-}" "${SPA_BODY_FILE:-}" \ + "${GIT_DATA_FILE:-}" "${TWEET_PROMPT_FILE:-}" "${TWEET_STREAM_FILE:-}" \ + "${TWEET_OUTPUT_FILE:-}" "${X_DATA_FILE:-}" "${XENG_PROMPT_FILE:-}" \ + "${XENG_STREAM_FILE:-}" "${XENG_OUTPUT_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 + if [[ -n "${TWEET_CLAUDE_PID:-}" ]] && kill -0 "${TWEET_CLAUDE_PID}" 2>/dev/null; then + kill -TERM "${TWEET_CLAUDE_PID}" 2>/dev/null || true + fi + if [[ -n "${XENG_CLAUDE_PID:-}" ]] && kill -0 "${XENG_CLAUDE_PID}" 2>/dev/null; then + kill -TERM "${XENG_CLAUDE_PID}" 2>/dev/null || true + fi log "=== Cycle Done (exit_code=${exit_code}) ===" exit ${exit_code} @@ -54,6 +65,222 @@ 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 0a: Draft daily tweet from git history --- +log "Phase 0a: Drafting tweet from recent git activity..." + +GIT_DATA_FILE=$(mktemp /tmp/growth-git-XXXXXX.json) +chmod 0600 "${GIT_DATA_FILE}" +TWEET_PROMPT_FILE=$(mktemp /tmp/growth-tweet-prompt-XXXXXX.md) +chmod 0600 "${TWEET_PROMPT_FILE}" +TWEET_STREAM_FILE=$(mktemp /tmp/growth-tweet-stream-XXXXXX.jsonl) +TWEET_OUTPUT_FILE=$(mktemp /tmp/growth-tweet-output-XXXXXX.txt) +TWEET_TEMPLATE="${SCRIPT_DIR}/tweet-prompt.md" +TWEET_DECISIONS_FILE="${HOME}/.config/spawn/tweet-decisions.md" + +# Gather git data from last 7 days +_OUT="${GIT_DATA_FILE}" bun -e ' +const { execSync } = require("child_process"); +const raw = execSync("git log --since=\"7 days ago\" --format=\"%H|%s|%an|%ad\" --date=short", { encoding: "utf-8" }); +const commits = raw.trim().split("\n").filter(Boolean).map((line) => { + const [hash, subject, author, date] = line.split("|"); + const prefix = (subject ?? "").match(/^(feat|fix|refactor|docs|test|chore|perf|ci)/)?.[1] ?? "other"; + return { hash: (hash ?? "").slice(0, 12), subject: subject ?? "", author: author ?? "", date: date ?? "", category: prefix }; +}); +await Bun.write(process.env._OUT, JSON.stringify({ commits, count: commits.length }, null, 2)); +' 2>> "${LOG_FILE}" || true + +COMMIT_COUNT=$(_DATA_FILE="${GIT_DATA_FILE}" bun -e 'const d=JSON.parse(await Bun.file(process.env._DATA_FILE).text()); console.log(d.count ?? 0)' 2>/dev/null) || COMMIT_COUNT="0" +log "Phase 0a: ${COMMIT_COUNT} commits in last 7 days" + +if [[ -f "${TWEET_TEMPLATE}" && "${COMMIT_COUNT}" -gt 0 ]]; then + # Assemble tweet prompt + _TEMPLATE="${TWEET_TEMPLATE}" _DATA_FILE="${GIT_DATA_FILE}" _DECISIONS="${TWEET_DECISIONS_FILE}" _OUT="${TWEET_PROMPT_FILE}" bun -e ' + import { existsSync } from "node:fs"; + const template = await Bun.file(process.env._TEMPLATE).text(); + const data = await Bun.file(process.env._DATA_FILE).text(); + const decisionsPath = process.env._DECISIONS; + const decisions = existsSync(decisionsPath) ? await Bun.file(decisionsPath).text() : "No past tweet decisions yet."; + const result = template + .replace("GIT_DATA_PLACEHOLDER", data.trim()) + .replace("TWEET_DECISIONS_PLACEHOLDER", decisions.trim()); + await Bun.write(process.env._OUT, result); + ' 2>> "${LOG_FILE}" || true + + # Run Claude for tweet (120s timeout — tweets are simpler) + TWEET_TIMEOUT=120 + log "Phase 0a: Running Claude for tweet draft (timeout=${TWEET_TIMEOUT}s)..." + setsid claude -p - --model sonnet --output-format stream-json --verbose < "${TWEET_PROMPT_FILE}" > "${TWEET_STREAM_FILE}" 2>> "${LOG_FILE}" & + TWEET_CLAUDE_PID=$! + TWEET_WALL_START=$(date +%s) + + while kill -0 "${TWEET_CLAUDE_PID}" 2>/dev/null; do + sleep 5 + TWEET_ELAPSED=$(( $(date +%s) - TWEET_WALL_START )) + if [[ "${TWEET_ELAPSED}" -ge "${TWEET_TIMEOUT}" ]]; then + log "Phase 0a: timeout (${TWEET_ELAPSED}s) — killing" + kill -TERM -"${TWEET_CLAUDE_PID}" 2>/dev/null || true + sleep 2 + kill -KILL -"${TWEET_CLAUDE_PID}" 2>/dev/null || true + break + fi + done + wait "${TWEET_CLAUDE_PID}" 2>/dev/null || true + + # Extract text from stream + _STREAM="${TWEET_STREAM_FILE}" _OUT="${TWEET_OUTPUT_FILE}" bun -e ' + const lines = (await Bun.file(process.env._STREAM).text()).split("\n").filter(Boolean); + const texts = []; + for (const line of lines) { + try { + const ev = JSON.parse(line); + if (ev.type === "assistant" && Array.isArray(ev.message?.content)) { + for (const block of ev.message.content) { + if (block.type === "text" && block.text) texts.push(block.text); + } + } + } catch {} + } + await Bun.write(process.env._OUT, texts.join("\n")); + ' 2>> "${LOG_FILE}" || true + + # Extract json:tweet + TWEET_JSON="" + if [[ -f "${TWEET_OUTPUT_FILE}" ]]; then + TWEET_JSON=$(_OUT="${TWEET_OUTPUT_FILE}" bun -e ' + const text = await Bun.file(process.env._OUT).text(); + const blocks = [...text.matchAll(/```json:tweet\n([\s\S]*?)\n```/g)]; + let result = ""; + for (const block of blocks) { + try { result = JSON.stringify(JSON.parse(block[1].trim())); } catch {} + } + if (result) console.log(result); + ' 2>/dev/null) || true + fi + + if [[ -n "${TWEET_JSON}" ]]; then + log "Phase 0a: Tweet JSON: ${TWEET_JSON}" + # POST to SPA + if [[ -n "${SPA_TRIGGER_URL:-}" && -n "${SPA_TRIGGER_SECRET:-}" ]]; then + TWEET_AUTH_FILE=$(mktemp /tmp/growth-tweet-auth-XXXXXX.conf) + TWEET_BODY_FILE=$(mktemp /tmp/growth-tweet-body-XXXXXX.json) + chmod 0600 "${TWEET_AUTH_FILE}" "${TWEET_BODY_FILE}" + printf 'header = "Authorization: Bearer %s"\n' "${SPA_TRIGGER_SECRET}" > "${TWEET_AUTH_FILE}" + printf '%s' "${TWEET_JSON}" > "${TWEET_BODY_FILE}" + TWEET_HTTP=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${SPA_TRIGGER_URL}/candidate" -K "${TWEET_AUTH_FILE}" -H "Content-Type: application/json" --data-binary @"${TWEET_BODY_FILE}" --max-time 30) || TWEET_HTTP="000" + rm -f "${TWEET_AUTH_FILE}" "${TWEET_BODY_FILE}" + log "Phase 0a: SPA response: HTTP ${TWEET_HTTP}" + fi + else + log "Phase 0a: No json:tweet block found" + fi +else + log "Phase 0a: Skipping (no template or no commits)" +fi + +# --- Phase 0b: Search X for mentions + draft engagement --- +if [[ -z "${X_API_KEY:-}" ]]; then + log "Phase 0b: Skipping (no X API credentials)" +else + log "Phase 0b: Searching X for Spawn mentions..." + + X_DATA_FILE=$(mktemp /tmp/growth-x-XXXXXX.json) + chmod 0600 "${X_DATA_FILE}" + XENG_PROMPT_FILE=$(mktemp /tmp/growth-xeng-prompt-XXXXXX.md) + chmod 0600 "${XENG_PROMPT_FILE}" + XENG_STREAM_FILE=$(mktemp /tmp/growth-xeng-stream-XXXXXX.jsonl) + XENG_OUTPUT_FILE=$(mktemp /tmp/growth-xeng-output-XXXXXX.txt) + XENG_TEMPLATE="${SCRIPT_DIR}/x-engage-prompt.md" + + if bun run "${SCRIPT_DIR}/x-fetch.ts" > "${X_DATA_FILE}" 2>> "${LOG_FILE}"; then + X_POST_COUNT=$(_DATA_FILE="${X_DATA_FILE}" bun -e 'const d=JSON.parse(await Bun.file(process.env._DATA_FILE).text()); console.log(d.postsScanned ?? d.posts?.length ?? 0)' 2>/dev/null) || X_POST_COUNT="0" + log "Phase 0b: ${X_POST_COUNT} tweets fetched" + + if [[ -f "${XENG_TEMPLATE}" && "${X_POST_COUNT}" -gt 0 ]]; then + # Assemble engage prompt + _TEMPLATE="${XENG_TEMPLATE}" _DATA_FILE="${X_DATA_FILE}" _DECISIONS="${TWEET_DECISIONS_FILE}" _OUT="${XENG_PROMPT_FILE}" bun -e ' + import { existsSync } from "node:fs"; + const template = await Bun.file(process.env._TEMPLATE).text(); + const data = await Bun.file(process.env._DATA_FILE).text(); + const decisionsPath = process.env._DECISIONS; + const decisions = existsSync(decisionsPath) ? await Bun.file(decisionsPath).text() : "No past tweet decisions yet."; + const result = template + .replace("X_DATA_PLACEHOLDER", data.trim()) + .replace("TWEET_DECISIONS_PLACEHOLDER", decisions.trim()); + await Bun.write(process.env._OUT, result); + ' 2>> "${LOG_FILE}" || true + + # Run Claude for engagement (120s timeout) + XENG_TIMEOUT=120 + log "Phase 0b: Running Claude for engagement draft (timeout=${XENG_TIMEOUT}s)..." + setsid claude -p - --model sonnet --output-format stream-json --verbose < "${XENG_PROMPT_FILE}" > "${XENG_STREAM_FILE}" 2>> "${LOG_FILE}" & + XENG_CLAUDE_PID=$! + XENG_WALL_START=$(date +%s) + + while kill -0 "${XENG_CLAUDE_PID}" 2>/dev/null; do + sleep 5 + XENG_ELAPSED=$(( $(date +%s) - XENG_WALL_START )) + if [[ "${XENG_ELAPSED}" -ge "${XENG_TIMEOUT}" ]]; then + log "Phase 0b: timeout (${XENG_ELAPSED}s) — killing" + kill -TERM -"${XENG_CLAUDE_PID}" 2>/dev/null || true + sleep 2 + kill -KILL -"${XENG_CLAUDE_PID}" 2>/dev/null || true + break + fi + done + wait "${XENG_CLAUDE_PID}" 2>/dev/null || true + + # Extract text from stream + _STREAM="${XENG_STREAM_FILE}" _OUT="${XENG_OUTPUT_FILE}" bun -e ' + const lines = (await Bun.file(process.env._STREAM).text()).split("\n").filter(Boolean); + const texts = []; + for (const line of lines) { + try { + const ev = JSON.parse(line); + if (ev.type === "assistant" && Array.isArray(ev.message?.content)) { + for (const block of ev.message.content) { + if (block.type === "text" && block.text) texts.push(block.text); + } + } + } catch {} + } + await Bun.write(process.env._OUT, texts.join("\n")); + ' 2>> "${LOG_FILE}" || true + + # Extract json:x_engage + XENG_JSON="" + if [[ -f "${XENG_OUTPUT_FILE}" ]]; then + XENG_JSON=$(_OUT="${XENG_OUTPUT_FILE}" bun -e ' + const text = await Bun.file(process.env._OUT).text(); + const blocks = [...text.matchAll(/```json:x_engage\n([\s\S]*?)\n```/g)]; + let result = ""; + for (const block of blocks) { + try { result = JSON.stringify(JSON.parse(block[1].trim())); } catch {} + } + if (result) console.log(result); + ' 2>/dev/null) || true + fi + + if [[ -n "${XENG_JSON}" ]]; then + log "Phase 0b: Engage JSON: ${XENG_JSON}" + if [[ -n "${SPA_TRIGGER_URL:-}" && -n "${SPA_TRIGGER_SECRET:-}" ]]; then + XENG_AUTH_FILE=$(mktemp /tmp/growth-xeng-auth-XXXXXX.conf) + XENG_BODY_FILE=$(mktemp /tmp/growth-xeng-body-XXXXXX.json) + chmod 0600 "${XENG_AUTH_FILE}" "${XENG_BODY_FILE}" + printf 'header = "Authorization: Bearer %s"\n' "${SPA_TRIGGER_SECRET}" > "${XENG_AUTH_FILE}" + printf '%s' "${XENG_JSON}" > "${XENG_BODY_FILE}" + XENG_HTTP=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${SPA_TRIGGER_URL}/candidate" -K "${XENG_AUTH_FILE}" -H "Content-Type: application/json" --data-binary @"${XENG_BODY_FILE}" --max-time 30) || XENG_HTTP="000" + rm -f "${XENG_AUTH_FILE}" "${XENG_BODY_FILE}" + log "Phase 0b: SPA response: HTTP ${XENG_HTTP}" + fi + else + log "Phase 0b: No json:x_engage block found" + fi + fi + else + log "Phase 0b: x-fetch.ts failed" + fi +fi + # --- Phase 1: Batch fetch Reddit posts --- log "Phase 1: Fetching Reddit posts..." diff --git a/.claude/skills/setup-agent-team/tweet-prompt.md b/.claude/skills/setup-agent-team/tweet-prompt.md new file mode 100644 index 000000000..56ae6cef9 --- /dev/null +++ b/.claude/skills/setup-agent-team/tweet-prompt.md @@ -0,0 +1,75 @@ +# Tweet Draft — Daily Spawn Update + +You are a developer advocate composing a single tweet (max 280 characters) about the Spawn project (). + +Spawn is a matrix of **agents x clouds** — it provisions a cloud VM, installs a coding agent (Claude Code, Codex, OpenCode, etc.), injects OpenRouter credentials, and drops you into an interactive session. One `curl | bash` command. + +## Past Tweet Decisions + +Learn from what was previously approved, edited, or skipped: + +TWEET_DECISIONS_PLACEHOLDER + +## Recent Git Activity (last 7 days) + +GIT_DATA_PLACEHOLDER + +## Your Task + +1. **Scan the git data** for the single most tweet-worthy item. Prioritize: + - New user-facing features (`feat(...)` commits) — most valuable + - Interesting bug fixes that show engineering rigor or security awareness + - Developer workflow improvements, CLI enhancements + - Best practices demonstrated in how issues were triaged and resolved + +2. **Draft exactly 1 tweet**, max 280 characters. Rules: + - Write like a developer sharing something cool, not a marketing team + - No corporate speak, no buzzwords, no "excited to announce" + - At most 1 hashtag (only if it fits naturally) + - Mention `@OpenRouterTeam` only if it fits naturally + - OK to include a short URL like `openrouter.ai/labs/spawn` + - If referencing a specific feature, be concrete ("added Hetzner support" not "expanded cloud coverage") + +3. **If nothing is tweet-worthy** (no notable changes, or all recent commits are internal/infra), output `found: false`. + +## Output Format + +First, a human-readable summary: + +``` +=== TWEET DRAFT === +Topic: {which commit/feature/fix this highlights} +Category: {feature | fix | best-practice} +Chars: {N}/280 + +Draft: +{the tweet text} +=== END TWEET === +``` + +Then a machine-readable block: + +```json:tweet +{ + "found": true, + "type": "tweet", + "tweetText": "{the tweet, max 280 chars}", + "topic": "{brief description of what the tweet is about}", + "category": "feature", + "sourceCommits": ["abc1234def"], + "charCount": 142 +} +``` + +Or if nothing tweet-worthy: + +```json:tweet +{"found": false, "type": "tweet", "reason": "no notable changes in last 7 days"} +``` + +## Rules + +- Pick exactly 1 tweet per cycle. No ties, no "here are 3 options." +- MUST be under 280 characters. Count carefully. +- Do NOT use tools. Your only input is the git data above. +- A "no tweet" result is perfectly fine — quality over quantity. diff --git a/.claude/skills/setup-agent-team/x-engage-prompt.md b/.claude/skills/setup-agent-team/x-engage-prompt.md new file mode 100644 index 000000000..10582e586 --- /dev/null +++ b/.claude/skills/setup-agent-team/x-engage-prompt.md @@ -0,0 +1,80 @@ +# X Engagement — Reply to Spawn Mentions + +You are a developer advocate monitoring X (Twitter) for conversations about Spawn, OpenRouter, or related topics (cloud coding agents, remote dev environments). + +Spawn is a matrix of **agents x clouds** — it provisions a cloud VM, installs a coding agent (Claude Code, Codex, OpenCode, etc.), injects OpenRouter credentials, and drops you into an interactive session. One `curl | bash` command. + +## Past Decisions + +Learn from what was previously approved, edited, or skipped: + +TWEET_DECISIONS_PLACEHOLDER + +## X Mentions & Conversations + +X_DATA_PLACEHOLDER + +## Your Task + +1. **Score each tweet** for engagement value (0-10): + - **Relevance (0-5)**: Is the person asking about or discussing something Spawn solves? + - **Engagement potential (0-3)**: Would a reply add genuine value? (not spam) + - **Author quality (0-2)**: Is this a real developer, not a bot or low-quality account? + +2. **Pick exactly 1 best engagement opportunity** (score 7+ to qualify). + +3. **Draft a reply** (max 280 characters): + - Be helpful first, promotional second + - Answer their question or add to the conversation + - Mention Spawn only if it genuinely fits what they are discussing + - Casual, developer-to-developer tone + - Include `openrouter.ai/labs/spawn` only if it adds value + - Disclosure: include "disclosure: i help build this" if recommending Spawn + +4. **If no good engagement opportunity** (all scores < 7), output `found: false`. + +## Output Format + +First, a human-readable summary: + +``` +=== ENGAGEMENT DRAFT === +Source: @{author} — "{tweet text snippet}" +Why engage: {1-2 sentences} +Relevance: {N}/10 +Chars: {N}/280 + +Draft reply: +{the reply text} +=== END ENGAGEMENT === +``` + +Then a machine-readable block: + +```json:x_engage +{ + "found": true, + "type": "x_engage", + "replyText": "{the reply, max 280 chars}", + "sourceTweetId": "{tweet ID}", + "sourceTweetUrl": "https://x.com/{author}/status/{id}", + "sourceTweetText": "{original tweet text}", + "sourceAuthor": "{username}", + "whyEngage": "{1-2 sentence explanation}", + "relevanceScore": 8, + "charCount": 195 +} +``` + +Or if no good opportunity: + +```json:x_engage +{"found": false, "type": "x_engage", "reason": "no high-relevance mentions found"} +``` + +## Rules + +- Pick exactly 1 engagement per cycle. No ties. +- MUST be under 280 characters. +- Do NOT use tools. +- Quality over quantity — "no engage" is a valid and common outcome. 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..9119341a8 --- /dev/null +++ b/.claude/skills/setup-agent-team/x-fetch.ts @@ -0,0 +1,284 @@ +/** + * X (Twitter) Fetch — Search for Spawn/OpenRouter mentions on X. + * + * Uses X API v2 to find tweets mentioning Spawn, OpenRouter, or related topics. + * Gracefully exits with empty results if credentials are not configured. + * + * Env vars: X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET + */ + +import { Database } from "bun:sqlite"; +import { createHmac, randomBytes } from "node:crypto"; +import { existsSync } from "node:fs"; +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 ?? ""; + +// Graceful skip if credentials are not configured +if (!API_KEY || !API_SECRET || !ACCESS_TOKEN || !ACCESS_TOKEN_SECRET) { + console.error("[x-fetch] No X API credentials configured — outputting empty results"); + console.log(JSON.stringify({ posts: [], postsScanned: 0 })); + process.exit(0); +} + +// Validate credential format — reject newlines that could corrupt headers +if (/[\r\n]/.test(API_KEY) || /[\r\n]/.test(API_SECRET)) { + console.error("Invalid X_API_KEY / X_API_SECRET: must not contain newlines"); + process.exit(1); +} + +// Search queries — shuffled each run for variety +const QUERIES = shuffle([ + "openrouter spawn", + "spawn cloud agent", + '"cloud coding agent"', + '"remote dev environment" AI', + '"claude code" remote server', + "codex CLI cloud", + "@OpenRouterTeam", +]); + +const MAX_RESULTS_PER_QUERY = 25; +const MAX_CONCURRENT = 3; + +/** X API v2 tweet schema. */ +const XTweetSchema = v.object({ + id: v.string(), + text: v.string(), + created_at: v.optional(v.string()), + author_id: v.optional(v.string()), + public_metrics: v.optional( + v.object({ + like_count: v.optional(v.number()), + retweet_count: v.optional(v.number()), + reply_count: v.optional(v.number()), + quote_count: v.optional(v.number()), + }), + ), +}); + +const XUserSchema = v.object({ + id: v.string(), + username: v.string(), +}); + +const XSearchResponseSchema = v.object({ + data: v.optional(v.array(XTweetSchema)), + includes: v.optional( + v.object({ + users: v.optional(v.array(XUserSchema)), + }), + ), + meta: v.optional( + v.object({ + result_count: v.optional(v.number()), + }), + ), +}); + +interface XPost { + tweetId: string; + text: string; + authorUsername: string; + authorId: string; + createdAt: string; + likes: number; + retweets: number; + replies: number; + url: string; +} + +/** Fisher-Yates shuffle. */ +function shuffle(arr: T[]): T[] { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +/** + * Generate OAuth 1.0a signature for X API requests. + * Reference: https://developer.x.com/en/docs/authentication/oauth-1-0a + */ +function generateOAuthHeader(method: string, url: string, params: Record): 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", + }; + + const allParams = { ...params, ...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}`; +} + +/** Search X API v2 for recent tweets matching a query. */ +async function searchTweets(query: string): Promise { + const baseUrl = "https://api.x.com/2/tweets/search/recent"; + const params: Record = { + query, + max_results: String(MAX_RESULTS_PER_QUERY), + "tweet.fields": "created_at,public_metrics,author_id", + expansions: "author_id", + "user.fields": "username", + }; + + const queryString = Object.entries(params) + .map(([k, val]) => `${encodeURIComponent(k)}=${encodeURIComponent(val)}`) + .join("&"); + const fullUrl = `${baseUrl}?${queryString}`; + + const authHeader = generateOAuthHeader("GET", baseUrl, params); + + const res = await fetch(fullUrl, { + headers: { + Authorization: authHeader, + "User-Agent": "spawn-growth/1.0", + }, + }); + + if (!res.ok) { + console.error(`[x-fetch] X API ${res.status}: ${query}`); + return []; + } + + const json: unknown = await res.json(); + const parsed = v.safeParse(XSearchResponseSchema, json); + if (!parsed.success || !parsed.output.data) return []; + + const users = new Map(); + for (const u of parsed.output.includes?.users ?? []) { + users.set(u.id, u.username); + } + + return parsed.output.data.map((tweet) => { + const username = users.get(tweet.author_id ?? "") ?? "unknown"; + return { + tweetId: tweet.id, + text: tweet.text, + authorUsername: username, + authorId: tweet.author_id ?? "", + createdAt: tweet.created_at ?? "", + likes: tweet.public_metrics?.like_count ?? 0, + retweets: tweet.public_metrics?.retweet_count ?? 0, + replies: tweet.public_metrics?.reply_count ?? 0, + url: `https://x.com/${username}/status/${tweet.id}`, + }; + }); +} + +/** Load tweet IDs already processed from the tweets DB. */ +function loadSeenTweetIds(): 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<{ source_tweet_id: string }, []>( + "SELECT source_tweet_id FROM tweets WHERE source_tweet_id IS NOT NULL", + ) + .all(); + db.close(); + return new Set(rows.map((r) => r.source_tweet_id)); + } catch { + return new Set(); + } +} + +/** Simple concurrency limiter. */ +async function pooled(tasks: Array<() => Promise>, limit: number): Promise { + const results: T[] = []; + let idx = 0; + + async function worker(): Promise { + while (idx < tasks.length) { + const i = idx++; + results[i] = await tasks[i](); + } + } + + await Promise.all( + Array.from({ length: Math.min(limit, tasks.length) }, () => worker()), + ); + return results; +} + +async function main(): Promise { + console.error("[x-fetch] Authenticated"); + + const seenIds = loadSeenTweetIds(); + console.error(`[x-fetch] ${seenIds.size} tweets already seen in DB`); + + const searchTasks = QUERIES.map( + (query) => () => searchTweets(query), + ); + + console.error(`[x-fetch] Firing ${searchTasks.length} searches (concurrency=${MAX_CONCURRENT})...`); + + const allResults = await pooled(searchTasks, MAX_CONCURRENT); + + const allPosts = new Map(); + let skippedSeen = 0; + for (const results of allResults) { + for (const post of results) { + if (seenIds.has(post.tweetId)) { + skippedSeen++; + continue; + } + if (!allPosts.has(post.tweetId)) { + allPosts.set(post.tweetId, post); + } + } + } + + console.error(`[x-fetch] Found ${allPosts.size} unique tweets (${skippedSeen} already seen, skipped)`); + + const postsArray = [...allPosts.values()]; + const filtered = postsArray.filter((p) => p.likes >= 1 || p.replies >= 1); + filtered.sort((a, b) => b.likes - a.likes); + + const output = { + posts: filtered.map((p) => ({ + tweetId: p.tweetId, + text: p.text.slice(0, 500), + authorUsername: p.authorUsername, + createdAt: p.createdAt, + likes: p.likes, + retweets: p.retweets, + replies: p.replies, + url: p.url, + })), + 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-spa/helpers.ts b/.claude/skills/setup-spa/helpers.ts index 45eab8914..cd4a71e18 100644 --- a/.claude/skills/setup-spa/helpers.ts +++ b/.claude/skills/setup-spa/helpers.ts @@ -185,6 +185,24 @@ export function openDb(path?: string): Database { created_at TEXT NOT NULL ) `); + db.run(` + CREATE TABLE IF NOT EXISTS tweets ( + tweet_id TEXT PRIMARY KEY, + tweet_text TEXT NOT NULL, + topic TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'feature', + source_commits TEXT, + source_tweet_id TEXT, + reply_to_url TEXT, + slack_channel TEXT, + slack_ts TEXT, + status TEXT NOT NULL DEFAULT 'pending', + actioned_by TEXT, + actioned_at TEXT, + posted_text TEXT, + created_at TEXT NOT NULL + ) + `); if (!path) { migrateFromJson(db); } @@ -434,6 +452,169 @@ export function readDecisions(): string { // #endregion +// #region Tweets — X/Twitter growth pipeline + +/** A tweet candidate tracked for approval. */ +export interface TweetRow { + tweetId: string; + tweetText: string; + topic: string; + category: string; + sourceCommits?: string; + sourceTweetId?: string; + replyToUrl?: string; + slackChannel?: string; + slackTs?: string; + status: "pending" | "approved" | "posted" | "skipped" | "error"; + actionedBy?: string; + actionedAt?: string; + postedText?: string; + createdAt: string; +} + +/** Raw SQLite row shape for tweets. */ +interface RawTweet { + tweet_id: string; + tweet_text: string; + topic: string; + category: string; + source_commits: string | null; + source_tweet_id: string | null; + reply_to_url: string | null; + slack_channel: string | null; + slack_ts: string | null; + status: string; + actioned_by: string | null; + actioned_at: string | null; + posted_text: string | null; + created_at: string; +} + +function rowToTweet(r: RawTweet): TweetRow { + return { + tweetId: r.tweet_id, + tweetText: r.tweet_text, + topic: r.topic, + category: r.category, + sourceCommits: r.source_commits ?? undefined, + sourceTweetId: r.source_tweet_id ?? undefined, + replyToUrl: r.reply_to_url ?? undefined, + slackChannel: r.slack_channel ?? undefined, + slackTs: r.slack_ts ?? undefined, + status: + r.status === "approved" || r.status === "posted" || r.status === "skipped" || r.status === "error" + ? r.status + : "pending", + actionedBy: r.actioned_by ?? undefined, + actionedAt: r.actioned_at ?? undefined, + postedText: r.posted_text ?? undefined, + createdAt: r.created_at, + }; +} + +/** Insert or update a tweet. On conflict (same tweet_id), updates Slack coordinates. */ +export function upsertTweet(db: Database, tweet: TweetRow): void { + db.run( + `INSERT INTO tweets (tweet_id, tweet_text, topic, category, source_commits, source_tweet_id, reply_to_url, slack_channel, slack_ts, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (tweet_id) DO UPDATE SET + slack_channel = excluded.slack_channel, + slack_ts = excluded.slack_ts`, + [ + tweet.tweetId, + tweet.tweetText, + tweet.topic, + tweet.category, + tweet.sourceCommits ?? null, + tweet.sourceTweetId ?? null, + tweet.replyToUrl ?? null, + tweet.slackChannel ?? null, + tweet.slackTs ?? null, + tweet.status, + tweet.createdAt, + ], + ); +} + +/** Look up a tweet by tweet ID. */ +export function findTweet(db: Database, tweetId: string): TweetRow | undefined { + const row = db + .query< + RawTweet, + [ + string, + ] + >("SELECT * FROM tweets WHERE tweet_id = ?") + .get(tweetId); + return row ? rowToTweet(row) : undefined; +} + +/** Update a tweet's status and related fields after an action. */ +export function updateTweetStatus( + db: Database, + tweetId: string, + update: { + status: TweetRow["status"]; + actionedBy?: string; + postedText?: string; + }, +): void { + db.run( + `UPDATE tweets SET + status = ?, + actioned_by = ?, + actioned_at = ?, + posted_text = ? + WHERE tweet_id = ?`, + [ + update.status, + update.actionedBy ?? null, + new Date().toISOString(), + update.postedText ?? null, + tweetId, + ], + ); +} + +const TWEET_DECISIONS_PATH = `${process.env.HOME ?? "/tmp"}/.config/spawn/tweet-decisions.md`; + +/** Append a decision entry to the tweet decisions log. */ +export function logTweetDecision( + tweet: TweetRow, + decision: "approved" | "edited" | "skipped", + editedText?: string, +): void { + const dir = dirname(TWEET_DECISIONS_PATH); + if (!existsSync(dir)) + mkdirSync(dir, { + recursive: true, + }); + + const date = new Date().toISOString().split("T")[0]; + const text = editedText ?? tweet.tweetText; + const entry = ` +## ${decision.toUpperCase()} — ${date} + +- **Topic**: ${tweet.topic} +- **Category**: ${tweet.category} +- **Decision**: ${decision} +${editedText ? "- **Edited**: yes\n" : ""}\ +- **Tweet**: ${text.replace(/\n/g, " ")} + +--- +`; + + appendFileSync(TWEET_DECISIONS_PATH, entry); +} + +/** Read the tweet decisions log (returns empty string if no file). */ +export function readTweetDecisions(): string { + if (!existsSync(TWEET_DECISIONS_PATH)) return ""; + return readFileSync(TWEET_DECISIONS_PATH, "utf-8"); +} + +// #endregion + // #region Claude Code stream parsing export const ResultSchema = v.object({ diff --git a/.claude/skills/setup-spa/main.ts b/.claude/skills/setup-spa/main.ts index 103d5d801..6e1453487 100644 --- a/.claude/skills/setup-spa/main.ts +++ b/.claude/skills/setup-spa/main.ts @@ -13,8 +13,10 @@ import { downloadSlackFile, findCandidate, findThread, + findTweet, formatToolStats, logDecision, + logTweetDecision, markdownToRichTextBlocks, openDb, PR_URL_REGEX, @@ -26,8 +28,10 @@ import { stripMention, updateCandidateStatus, updateThread, + updateTweetStatus, upsertCandidate, upsertThread, + upsertTweet, } from "./helpers"; type SlackClient = InstanceType["client"]; @@ -1264,6 +1268,282 @@ app.action("growth_skip", async ({ ack, body, client }) => { } }); +// --- tweet_approve: mark tweet as approved --- +app.action("tweet_approve", async ({ ack, body, client }) => { + await ack(); + const payload = toRecord("actions" in body && Array.isArray(body.actions) ? body.actions[0] : null); + const tweetId = payload && isString(payload.value) ? payload.value : ""; + if (!tweetId) return; + + const userId = "user" in body && toRecord(body.user) ? String((toRecord(body.user) ?? {}).id ?? "") : ""; + const tweet = findTweet(db, tweetId); + if (!tweet || tweet.status !== "pending") return; + + updateTweetStatus(db, tweetId, { + status: "approved", + actionedBy: userId, + }); + logTweetDecision(tweet, "approved"); + + if (tweet.slackChannel && tweet.slackTs) { + await replaceButtonsWithStatus( + client, + tweet.slackChannel, + tweet.slackTs, + `:white_check_mark: Tweet approved by <@${userId}> — ready to post on X`, + ); + } +}); + +// --- tweet_edit: open modal with tweet text for editing --- +app.action("tweet_edit", async ({ ack, body, client }) => { + await ack(); + const payload = toRecord("actions" in body && Array.isArray(body.actions) ? body.actions[0] : null); + const tweetId = payload && isString(payload.value) ? payload.value : ""; + if (!tweetId) return; + + const triggerId = "trigger_id" in body && isString(body.trigger_id) ? body.trigger_id : ""; + if (!triggerId) return; + + const tweet = findTweet(db, tweetId); + if (!tweet || tweet.status !== "pending") return; + + await client.views + .open({ + trigger_id: triggerId, + view: { + type: "modal", + callback_id: "tweet_edit_submit", + private_metadata: tweetId, + title: { + type: "plain_text", + text: "Edit Tweet", + }, + submit: { + type: "plain_text", + text: "Save", + }, + blocks: [ + { + type: "input", + block_id: "tweet_block", + label: { + type: "plain_text", + text: "Tweet text", + }, + element: { + type: "plain_text_input", + action_id: "tweet_text", + multiline: true, + max_length: 280, + initial_value: tweet.tweetText, + }, + }, + ], + }, + }) + .catch(() => {}); +}); + +// --- tweet_edit_submit: modal submitted with edited tweet --- +app.view("tweet_edit_submit", async ({ ack, view, body, client }) => { + await ack(); + const tweetId = view.private_metadata; + if (!tweetId) return; + + const tweet = findTweet(db, tweetId); + if (!tweet || tweet.status !== "pending") return; + + const tweetBlock = toRecord(view.state?.values?.tweet_block?.tweet_text); + const editedText = tweetBlock && isString(tweetBlock.value) ? tweetBlock.value : ""; + if (!editedText || editedText.length > 280) return; + + const userId = toRecord(body.user) ? String((toRecord(body.user) ?? {}).id ?? "") : ""; + + 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); + + 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`, + ); + } +}); + +// --- tweet_skip: skip this tweet --- +app.action("tweet_skip", async ({ ack, body, client }) => { + await ack(); + const payload = toRecord("actions" in body && Array.isArray(body.actions) ? body.actions[0] : null); + const tweetId = payload && isString(payload.value) ? payload.value : ""; + if (!tweetId) return; + + const userId = "user" in body && toRecord(body.user) ? String((toRecord(body.user) ?? {}).id ?? "") : ""; + const tweet = findTweet(db, tweetId); + if (!tweet || tweet.status !== "pending") return; + + updateTweetStatus(db, tweetId, { + status: "skipped", + actionedBy: userId, + }); + logTweetDecision(tweet, "skipped"); + + if (tweet.slackChannel && tweet.slackTs) { + await replaceButtonsWithStatus( + client, + tweet.slackChannel, + tweet.slackTs, + `:no_entry_sign: Skipped by <@${userId}>`, + ); + } +}); + +// --- xeng_approve: mark engagement reply as approved --- +app.action("xeng_approve", async ({ ack, body, client }) => { + await ack(); + const payload = toRecord("actions" in body && Array.isArray(body.actions) ? body.actions[0] : null); + const engageId = payload && isString(payload.value) ? payload.value : ""; + if (!engageId) return; + + const userId = "user" in body && toRecord(body.user) ? String((toRecord(body.user) ?? {}).id ?? "") : ""; + const tweet = findTweet(db, engageId); + if (!tweet || tweet.status !== "pending") return; + + updateTweetStatus(db, engageId, { + status: "approved", + actionedBy: userId, + }); + logTweetDecision(tweet, "approved"); + + if (tweet.slackChannel && tweet.slackTs) { + await replaceButtonsWithStatus( + client, + tweet.slackChannel, + tweet.slackTs, + `:white_check_mark: Reply approved by <@${userId}> — ready to post on X`, + ); + } +}); + +// --- xeng_edit: open modal with reply text for editing --- +app.action("xeng_edit", async ({ ack, body, client }) => { + await ack(); + const payload = toRecord("actions" in body && Array.isArray(body.actions) ? body.actions[0] : null); + const engageId = payload && isString(payload.value) ? payload.value : ""; + if (!engageId) return; + + const triggerId = "trigger_id" in body && isString(body.trigger_id) ? body.trigger_id : ""; + if (!triggerId) return; + + const tweet = findTweet(db, engageId); + if (!tweet || tweet.status !== "pending") return; + + await client.views + .open({ + trigger_id: triggerId, + view: { + type: "modal", + callback_id: "xeng_edit_submit", + private_metadata: engageId, + title: { + type: "plain_text", + text: "Edit Reply", + }, + submit: { + type: "plain_text", + text: "Save", + }, + blocks: [ + { + type: "input", + block_id: "reply_block", + label: { + type: "plain_text", + text: "Reply text", + }, + element: { + type: "plain_text_input", + action_id: "reply_text", + multiline: true, + max_length: 280, + initial_value: tweet.tweetText, + }, + }, + ], + }, + }) + .catch(() => {}); +}); + +// --- xeng_edit_submit: modal submitted with edited reply --- +app.view("xeng_edit_submit", async ({ ack, view, body, client }) => { + await ack(); + const engageId = view.private_metadata; + if (!engageId) return; + + const tweet = findTweet(db, engageId); + if (!tweet || tweet.status !== "pending") return; + + const replyBlock = toRecord(view.state?.values?.reply_block?.reply_text); + const editedText = replyBlock && isString(replyBlock.value) ? replyBlock.value : ""; + if (!editedText || editedText.length > 280) return; + + const userId = toRecord(body.user) ? String((toRecord(body.user) ?? {}).id ?? "") : ""; + + db.run("UPDATE tweets SET tweet_text = ? WHERE tweet_id = ?", [editedText, engageId]); + + updateTweetStatus(db, engageId, { + status: "approved", + actionedBy: userId, + postedText: editedText, + }); + logTweetDecision(tweet, "edited", editedText); + + if (tweet.slackChannel && tweet.slackTs) { + await replaceButtonsWithStatus( + client, + tweet.slackChannel, + tweet.slackTs, + `:white_check_mark: Reply edited & approved by <@${userId}> — ready to post on X`, + ); + } +}); + +// --- xeng_skip: skip this engagement opportunity --- +app.action("xeng_skip", async ({ ack, body, client }) => { + await ack(); + const payload = toRecord("actions" in body && Array.isArray(body.actions) ? body.actions[0] : null); + const engageId = payload && isString(payload.value) ? payload.value : ""; + if (!engageId) return; + + const userId = "user" in body && toRecord(body.user) ? String((toRecord(body.user) ?? {}).id ?? "") : ""; + const tweet = findTweet(db, engageId); + if (!tweet || tweet.status !== "pending") return; + + updateTweetStatus(db, engageId, { + status: "skipped", + actionedBy: userId, + }); + logTweetDecision(tweet, "skipped"); + + if (tweet.slackChannel && tweet.slackTs) { + await replaceButtonsWithStatus( + client, + tweet.slackChannel, + tweet.slackTs, + `:no_entry_sign: Skipped by <@${userId}>`, + ); + } +}); + /** Replace the actions block in a candidate card with a status context line. */ async function replaceButtonsWithStatus( client: SlackClient, @@ -1330,6 +1610,31 @@ const CandidatePayloadSchema = v.object({ postsScanned: v.optional(v.number()), }); +const TweetPayloadSchema = v.object({ + found: v.boolean(), + type: v.literal("tweet"), + tweetText: v.optional(v.string()), + topic: v.optional(v.string()), + category: v.optional(v.string()), + sourceCommits: v.optional(v.array(v.string())), + charCount: v.optional(v.number()), + reason: v.optional(v.string()), +}); + +const XEngagePayloadSchema = v.object({ + found: v.boolean(), + type: v.literal("x_engage"), + replyText: v.optional(v.string()), + sourceTweetId: v.optional(v.string()), + sourceTweetUrl: v.optional(v.string()), + sourceTweetText: v.optional(v.string()), + sourceAuthor: v.optional(v.string()), + whyEngage: v.optional(v.string()), + relevanceScore: v.optional(v.number()), + charCount: v.optional(v.number()), + reason: v.optional(v.string()), +}); + /** Timing-safe auth for the HTTP trigger endpoint. */ function isHttpAuthed(req: Request): boolean { if (!TRIGGER_SECRET) return false; @@ -1534,6 +1839,218 @@ async function postCandidateCard( }); } +/** Post a tweet draft card to Slack for approval. */ +async function postTweetCard( + client: SlackClient, + payload: typeof TweetPayloadSchema._types.output, +): Promise { + const db = openDb(); + + if (!payload.found) { + const text = payload.reason ?? "No tweet-worthy content this cycle."; + await client.chat.postMessage({ + channel: SLACK_CHANNEL_ID, + text, + }); + return Response.json({ ok: true, action: "no_tweet" }); + } + + const tweetText = payload.tweetText ?? ""; + const topic = payload.topic ?? "Spawn update"; + const category = payload.category ?? "feature"; + const commits = payload.sourceCommits ?? []; + const charCount = payload.charCount ?? tweetText.length; + const now = new Date(); + const tweetId = `tweet_${now.toISOString().replace(/[-:T]/g, "").slice(0, 15)}`; + + const commitLinks = commits + .slice(0, 5) + .map((h) => ``) + .join(", "); + + 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 }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `>${tweetText.replace(/\n/g, "\n>")}`, + }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `${categoryIcon} *${category}* | ${charCount}/280 chars${commitLinks ? ` | Commits: ${commitLinks}` : ""}`, + }, + ], + }, + { + type: "section", + text: { type: "mrkdwn", text: `*Topic:* ${topic}` }, + }, + { + type: "actions", + elements: [ + { + type: "button", + 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 }, + action_id: "tweet_edit", + value: tweetId, + }, + { + type: "button", + text: { type: "plain_text", text: "Skip", emoji: true }, + style: "danger", + action_id: "tweet_skip", + value: tweetId, + }, + ], + }, + ]; + + const msg = await client.chat.postMessage({ + channel: SLACK_CHANNEL_ID, + text: `🐦 Tweet draft: ${topic}`, + blocks, + }); + + upsertTweet(db, { + tweetId, + tweetText, + topic, + category, + sourceCommits: commits.length > 0 ? JSON.stringify(commits) : undefined, + slackChannel: SLACK_CHANNEL_ID, + slackTs: msg.ts ?? undefined, + status: "pending", + createdAt: now.toISOString(), + }); + + return Response.json({ ok: true, action: "posted", tweetId }); +} + +/** Post an X engagement opportunity card to Slack for approval. */ +async function postXEngageCard( + client: SlackClient, + payload: typeof XEngagePayloadSchema._types.output, +): Promise { + const db = openDb(); + + if (!payload.found) { + const text = payload.reason ?? "No engagement opportunities this cycle."; + await client.chat.postMessage({ + channel: SLACK_CHANNEL_ID, + text, + }); + return Response.json({ ok: true, action: "no_engage" }); + } + + const replyText = payload.replyText ?? ""; + const sourceUrl = payload.sourceTweetUrl ?? ""; + const sourceText = payload.sourceTweetText ?? ""; + const sourceAuthor = payload.sourceAuthor ?? "unknown"; + const whyEngage = payload.whyEngage ?? ""; + const relevance = payload.relevanceScore ?? 0; + const charCount = payload.charCount ?? replyText.length; + const now = new Date(); + const engageId = `xeng_${now.toISOString().replace(/[-:T]/g, "").slice(0, 15)}`; + + const blocks: KnownBlock[] = [ + { + type: "header", + text: { type: "plain_text", text: "🔍 X Mention — Engagement Opportunity", emoji: true }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `*<${sourceUrl}|@${sourceAuthor}>:*\n>${sourceText.slice(0, 500).replace(/\n/g, "\n>")}`, + }, + }, + { + type: "section", + text: { type: "mrkdwn", text: `*Why engage:* ${whyEngage}` }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `*Draft reply:*\n>${replyText.replace(/\n/g, "\n>")}`, + }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `Relevance: ${relevance}/10 | ${charCount}/280 chars`, + }, + ], + }, + { + type: "actions", + elements: [ + { + type: "button", + 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 }, + action_id: "xeng_edit", + value: engageId, + }, + { + type: "button", + text: { type: "plain_text", text: "Skip", emoji: true }, + style: "danger", + action_id: "xeng_skip", + value: engageId, + }, + ], + }, + ]; + + const msg = await client.chat.postMessage({ + channel: SLACK_CHANNEL_ID, + text: `🔍 X engagement: @${sourceAuthor}`, + blocks, + }); + + upsertTweet(db, { + tweetId: engageId, + tweetText: replyText, + topic: `Reply to @${sourceAuthor}`, + category: "engage", + sourceTweetId: payload.sourceTweetId ?? undefined, + replyToUrl: sourceUrl || undefined, + slackChannel: SLACK_CHANNEL_ID, + slackTs: msg.ts ?? undefined, + status: "pending", + createdAt: now.toISOString(), + }); + + return Response.json({ ok: true, action: "posted", engageId }); +} + /** Get a Reddit OAuth access token. */ async function getRedditToken(): Promise { if (!REDDIT_CLIENT_ID || !REDDIT_CLIENT_SECRET || !REDDIT_USERNAME || !REDDIT_PASSWORD) { @@ -1684,6 +2201,22 @@ function startHttpServer(client: SlackClient): void { ); } + const bodyObj = toRecord(body); + if (bodyObj && bodyObj.type === "tweet") { + const parsed = v.safeParse(TweetPayloadSchema, body); + if (!parsed.success) { + 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 postXEngageCard(client, parsed.output); + } + const parsed = v.safeParse(CandidatePayloadSchema, body); if (!parsed.success) { return Response.json( From f2a7c4d4930dfc4594d73506b31d98b71db1b2e4 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 16 Apr 2026 17:38:59 -0700 Subject: [PATCH 2/3] Update URL format in tweet prompt guidelines Signed-off-by: Ahmed Abushagur --- .claude/skills/setup-agent-team/tweet-prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/setup-agent-team/tweet-prompt.md b/.claude/skills/setup-agent-team/tweet-prompt.md index 56ae6cef9..885782166 100644 --- a/.claude/skills/setup-agent-team/tweet-prompt.md +++ b/.claude/skills/setup-agent-team/tweet-prompt.md @@ -27,7 +27,7 @@ GIT_DATA_PLACEHOLDER - No corporate speak, no buzzwords, no "excited to announce" - At most 1 hashtag (only if it fits naturally) - Mention `@OpenRouterTeam` only if it fits naturally - - OK to include a short URL like `openrouter.ai/labs/spawn` + - OK to include a short URL like `https://openrouter.ai/spawn` - If referencing a specific feature, be concrete ("added Hetzner support" not "expanded cloud coverage") 3. **If nothing is tweet-worthy** (no notable changes, or all recent commits are internal/infra), output `found: false`. From 1bee659eec7b61cfeef6ef66193b4ae4edc07b04 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Thu, 16 Apr 2026 17:39:25 -0700 Subject: [PATCH 3/3] Update URL for Spawn reference in engagement prompt Signed-off-by: Ahmed Abushagur --- .claude/skills/setup-agent-team/x-engage-prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/setup-agent-team/x-engage-prompt.md b/.claude/skills/setup-agent-team/x-engage-prompt.md index 10582e586..5340766f1 100644 --- a/.claude/skills/setup-agent-team/x-engage-prompt.md +++ b/.claude/skills/setup-agent-team/x-engage-prompt.md @@ -28,7 +28,7 @@ X_DATA_PLACEHOLDER - Answer their question or add to the conversation - Mention Spawn only if it genuinely fits what they are discussing - Casual, developer-to-developer tone - - Include `openrouter.ai/labs/spawn` only if it adds value + - Include `https://openrouter.ai/spawn` only if it adds value - Disclosure: include "disclosure: i help build this" if recommending Spawn 4. **If no good engagement opportunity** (all scores < 7), output `found: false`.