mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
feat(growth): add Phase 0 — daily tweet draft + X mention engagement (#3316)
* feat(growth): add Phase 0 — daily tweet draft + X mention engagement 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) <noreply@anthropic.com> * Update URL format in tweet prompt guidelines Signed-off-by: Ahmed Abushagur <ahmed@abushagur.com> * Update URL for Spawn reference in engagement prompt Signed-off-by: Ahmed Abushagur <ahmed@abushagur.com> --------- Signed-off-by: Ahmed Abushagur <ahmed@abushagur.com> Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Ahmed Abushagur <ahmed@abushagur.com>
This commit is contained in:
parent
21fd1949d5
commit
e0f37f0753
6 changed files with 1382 additions and 2 deletions
|
|
@ -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..."
|
||||
|
||||
|
|
|
|||
75
.claude/skills/setup-agent-team/tweet-prompt.md
Normal file
75
.claude/skills/setup-agent-team/tweet-prompt.md
Normal file
|
|
@ -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 (<https://github.com/OpenRouterTeam/spawn>).
|
||||
|
||||
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 `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`.
|
||||
|
||||
## 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.
|
||||
80
.claude/skills/setup-agent-team/x-engage-prompt.md
Normal file
80
.claude/skills/setup-agent-team/x-engage-prompt.md
Normal file
|
|
@ -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 `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`.
|
||||
|
||||
## 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.
|
||||
284
.claude/skills/setup-agent-team/x-fetch.ts
Normal file
284
.claude/skills/setup-agent-team/x-fetch.ts
Normal file
|
|
@ -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<T>(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, 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",
|
||||
};
|
||||
|
||||
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<XPost[]> {
|
||||
const baseUrl = "https://api.x.com/2/tweets/search/recent";
|
||||
const params: Record<string, string> = {
|
||||
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<string, string>();
|
||||
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<string> {
|
||||
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<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
let idx = 0;
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
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<void> {
|
||||
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<string, XPost>();
|
||||
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);
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<typeof App>["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<Response> {
|
||||
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) => `<https://github.com/${GITHUB_REPO}/commit/${h}|${h.slice(0, 7)}>`)
|
||||
.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<Response> {
|
||||
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<string | null> {
|
||||
if (!REDDIT_CLIENT_ID || !REDDIT_CLIENT_SECRET || !REDDIT_USERNAME || !REDDIT_PASSWORD) {
|
||||
|
|
@ -1691,6 +2208,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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue