mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
feat(growth): migrate X posting from OAuth 1.0a to OAuth 2.0 PKCE (#3329)
- Replace OAuth 1.0a signing with OAuth 2.0 Bearer token auth - Add x-auth.ts: one-time PKCE authorization flow that saves tokens to state.db - Add auto-refresh: tokens refresh transparently when expired (2hr TTL) - Add x_tokens table to state.db schema (via helpers.ts openDb) - Env vars simplified: X_CLIENT_ID + X_CLIENT_SECRET (no more 4 keys) - x-post.ts rewritten to read tokens from DB, refresh if needed Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
95da999efb
commit
2306fb1914
4 changed files with 415 additions and 100 deletions
175
.claude/skills/setup-agent-team/x-auth.ts
Normal file
175
.claude/skills/setup-agent-team/x-auth.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* X OAuth 2.0 PKCE Authorization — One-time setup.
|
||||
*
|
||||
* Starts a local server, opens the X authorization URL, receives the callback,
|
||||
* exchanges the code for access + refresh tokens, and saves them to state.db.
|
||||
*
|
||||
* Usage:
|
||||
* X_CLIENT_ID=... X_CLIENT_SECRET=... bun run x-auth.ts
|
||||
*
|
||||
* After running, the SPA and growth scripts will use the stored tokens automatically.
|
||||
*/
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const CLIENT_ID = process.env.X_CLIENT_ID ?? "";
|
||||
const CLIENT_SECRET = process.env.X_CLIENT_SECRET ?? "";
|
||||
const PORT = 8739;
|
||||
const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;
|
||||
const SCOPES = "tweet.read tweet.write users.read offline.access";
|
||||
|
||||
if (!CLIENT_ID || !CLIENT_SECRET) {
|
||||
console.error("[x-auth] X_CLIENT_ID and X_CLIENT_SECRET are required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const DB_PATH = `${process.env.HOME ?? "/tmp"}/.config/spawn/state.db`;
|
||||
|
||||
function openTokenDb(): Database {
|
||||
const dir = dirname(DB_PATH);
|
||||
if (!existsSync(dir))
|
||||
mkdirSync(dir, {
|
||||
recursive: true,
|
||||
});
|
||||
const db = new Database(DB_PATH);
|
||||
db.run("PRAGMA journal_mode = WAL");
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS x_tokens (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
return db;
|
||||
}
|
||||
|
||||
function generatePKCE(): {
|
||||
verifier: string;
|
||||
challenge: string;
|
||||
} {
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return {
|
||||
verifier,
|
||||
challenge,
|
||||
};
|
||||
}
|
||||
|
||||
const { verifier, challenge } = generatePKCE();
|
||||
const state = randomBytes(16).toString("hex");
|
||||
|
||||
const authUrl = new URL("https://x.com/i/oauth2/authorize");
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("client_id", CLIENT_ID);
|
||||
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
authUrl.searchParams.set("scope", SCOPES);
|
||||
authUrl.searchParams.set("state", state);
|
||||
authUrl.searchParams.set("code_challenge", challenge);
|
||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
console.log("\n[x-auth] Open this URL in your browser to authorize:\n");
|
||||
console.log(authUrl.toString());
|
||||
console.log(`\n[x-auth] Waiting for callback on http://127.0.0.1:${PORT}...\n`);
|
||||
|
||||
const server = Bun.serve({
|
||||
port: PORT,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname !== "/callback") {
|
||||
return new Response("Not found", {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code");
|
||||
const returnedState = url.searchParams.get("state");
|
||||
|
||||
if (returnedState !== state) {
|
||||
return new Response("State mismatch — possible CSRF. Try again.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!code) {
|
||||
const error = url.searchParams.get("error") ?? "unknown";
|
||||
return new Response(`Authorization denied: ${error}`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const basicAuth = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
|
||||
const tokenRes = await fetch("https://api.x.com/2/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
const err = await tokenRes.text();
|
||||
console.error(`[x-auth] Token exchange failed: ${err}`);
|
||||
return new Response(`Token exchange failed: ${err}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
const tokens: unknown = await tokenRes.json();
|
||||
const accessToken = (tokens as Record<string, unknown>).access_token;
|
||||
const refreshToken = (tokens as Record<string, unknown>).refresh_token;
|
||||
const expiresIn = (tokens as Record<string, unknown>).expires_in;
|
||||
|
||||
if (typeof accessToken !== "string" || typeof refreshToken !== "string") {
|
||||
console.error("[x-auth] Missing tokens in response");
|
||||
return new Response("Missing tokens in response", {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + (typeof expiresIn === "number" ? expiresIn : 7200) * 1000;
|
||||
|
||||
// Save to DB
|
||||
const db = openTokenDb();
|
||||
db.run(
|
||||
`INSERT INTO x_tokens (id, access_token, refresh_token, expires_at, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
access_token = excluded.access_token,
|
||||
refresh_token = excluded.refresh_token,
|
||||
expires_at = excluded.expires_at,
|
||||
updated_at = excluded.updated_at`,
|
||||
[
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt,
|
||||
new Date().toISOString(),
|
||||
],
|
||||
);
|
||||
db.close();
|
||||
|
||||
console.log("[x-auth] Tokens saved to state.db");
|
||||
console.log("[x-auth] Done — you can close this tab.");
|
||||
|
||||
setTimeout(() => {
|
||||
server.stop();
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
|
||||
return new Response("<html><body><h1>Authorized!</h1><p>Tokens saved. You can close this tab.</p></body></html>", {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
/**
|
||||
* X (Twitter) Post — Post a tweet via X API v2.
|
||||
* X (Twitter) Post — Post a tweet via X API v2 (OAuth 2.0).
|
||||
*
|
||||
* Uses OAuth 1.0a to authenticate and POST /2/tweets.
|
||||
* Can post standalone tweets or replies (pass in_reply_to_tweet_id).
|
||||
* Reads tokens from state.db (written by x-auth.ts), auto-refreshes if expired.
|
||||
*
|
||||
* Usage:
|
||||
* X_API_KEY=... X_API_SECRET=... X_ACCESS_TOKEN=... X_ACCESS_TOKEN_SECRET=... \
|
||||
* TWEET_TEXT="Hello world" bun run x-post.ts
|
||||
* X_CLIENT_ID=... X_CLIENT_SECRET=... TWEET_TEXT="Hello world" bun run x-post.ts
|
||||
*
|
||||
* Optional env:
|
||||
* REPLY_TO_TWEET_ID — if set, the tweet is posted as a reply to this tweet ID
|
||||
|
|
@ -14,18 +12,18 @@
|
|||
* Outputs JSON: { "id": "...", "text": "..." } on success, exits 1 on failure.
|
||||
*/
|
||||
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import { Database } from "bun:sqlite";
|
||||
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 ?? "";
|
||||
const CLIENT_ID = process.env.X_CLIENT_ID ?? "";
|
||||
const CLIENT_SECRET = process.env.X_CLIENT_SECRET ?? "";
|
||||
const TWEET_TEXT = process.env.TWEET_TEXT ?? "";
|
||||
const REPLY_TO = process.env.REPLY_TO_TWEET_ID ?? "";
|
||||
const DB_PATH = `${process.env.HOME ?? "/tmp"}/.config/spawn/state.db`;
|
||||
|
||||
if (!API_KEY || !API_SECRET || !ACCESS_TOKEN || !ACCESS_TOKEN_SECRET) {
|
||||
console.error("[x-post] Missing X API credentials");
|
||||
if (!CLIENT_ID || !CLIENT_SECRET) {
|
||||
console.error("[x-post] X_CLIENT_ID and X_CLIENT_SECRET are required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
|
@ -46,53 +44,120 @@ const PostResponseSchema = v.object({
|
|||
}),
|
||||
});
|
||||
|
||||
const ErrorResponseSchema = v.object({
|
||||
detail: v.optional(v.string()),
|
||||
title: v.optional(v.string()),
|
||||
errors: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
message: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
const TokenResponseSchema = v.object({
|
||||
access_token: v.string(),
|
||||
refresh_token: v.optional(v.string()),
|
||||
expires_in: v.optional(v.number()),
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate OAuth 1.0a Authorization header for X API requests.
|
||||
*/
|
||||
function generateOAuthHeader(method: string, url: string, body?: string): string {
|
||||
const oauthParams: Record<string, string> = {
|
||||
oauth_consumer_key: API_KEY,
|
||||
oauth_nonce: randomBytes(16).toString("hex"),
|
||||
oauth_signature_method: "HMAC-SHA1",
|
||||
oauth_timestamp: String(Math.floor(Date.now() / 1000)),
|
||||
oauth_token: ACCESS_TOKEN,
|
||||
oauth_version: "1.0",
|
||||
interface StoredTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
function loadTokens(): StoredTokens | null {
|
||||
if (!existsSync(DB_PATH)) return null;
|
||||
try {
|
||||
const db = new Database(DB_PATH, {
|
||||
readonly: true,
|
||||
});
|
||||
const row = db
|
||||
.query<
|
||||
{
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_at: number;
|
||||
},
|
||||
[]
|
||||
>("SELECT access_token, refresh_token, expires_at FROM x_tokens WHERE id = 1")
|
||||
.get();
|
||||
db.close();
|
||||
if (!row) return null;
|
||||
return {
|
||||
accessToken: row.access_token,
|
||||
refreshToken: row.refresh_token,
|
||||
expiresAt: row.expires_at,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveTokens(tokens: StoredTokens): void {
|
||||
const db = new Database(DB_PATH);
|
||||
db.run(
|
||||
`INSERT INTO x_tokens (id, access_token, refresh_token, expires_at, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
access_token = excluded.access_token,
|
||||
refresh_token = excluded.refresh_token,
|
||||
expires_at = excluded.expires_at,
|
||||
updated_at = excluded.updated_at`,
|
||||
[
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken,
|
||||
tokens.expiresAt,
|
||||
new Date().toISOString(),
|
||||
],
|
||||
);
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function refreshToken(currentRefresh: string): Promise<StoredTokens | null> {
|
||||
const basicAuth = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
|
||||
const res = await fetch("https://api.x.com/2/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: currentRefresh,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`[x-post] Token refresh failed: ${res.status} ${await res.text()}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const json: unknown = await res.json();
|
||||
const parsed = v.safeParse(TokenResponseSchema, json);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const newTokens: StoredTokens = {
|
||||
accessToken: parsed.output.access_token,
|
||||
refreshToken: parsed.output.refresh_token ?? currentRefresh,
|
||||
expiresAt: Date.now() + (parsed.output.expires_in ?? 7200) * 1000,
|
||||
};
|
||||
saveTokens(newTokens);
|
||||
return newTokens;
|
||||
}
|
||||
|
||||
// For POST with JSON body, only OAuth params go into the signature base
|
||||
const allParams = {
|
||||
...oauthParams,
|
||||
};
|
||||
const sortedKeys = Object.keys(allParams).sort();
|
||||
const paramString = sortedKeys.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(allParams[k])}`).join("&");
|
||||
async function getAccessToken(): Promise<string> {
|
||||
const tokens = loadTokens();
|
||||
if (!tokens) {
|
||||
console.error("[x-post] No tokens in state.db — run x-auth.ts first");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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");
|
||||
if (Date.now() > tokens.expiresAt - 300_000) {
|
||||
console.error("[x-post] Token expired, refreshing...");
|
||||
const refreshed = await refreshToken(tokens.refreshToken);
|
||||
if (!refreshed) {
|
||||
console.error("[x-post] Refresh failed — re-run x-auth.ts");
|
||||
process.exit(1);
|
||||
}
|
||||
return refreshed.accessToken;
|
||||
}
|
||||
|
||||
oauthParams.oauth_signature = signature;
|
||||
|
||||
const headerParts = Object.keys(oauthParams)
|
||||
.sort()
|
||||
.map((k) => `${encodeURIComponent(k)}="${encodeURIComponent(oauthParams[k])}"`)
|
||||
.join(", ");
|
||||
|
||||
return `OAuth ${headerParts}`;
|
||||
return tokens.accessToken;
|
||||
}
|
||||
|
||||
async function postTweet(): Promise<void> {
|
||||
const accessToken = await getAccessToken();
|
||||
const url = "https://api.x.com/2/tweets";
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
|
|
@ -104,27 +169,20 @@ async function postTweet(): Promise<void> {
|
|||
};
|
||||
}
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const authHeader = generateOAuthHeader("POST", url, body);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "spawn-growth/1.0",
|
||||
},
|
||||
body,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const json: unknown = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
const err = v.safeParse(ErrorResponseSchema, json);
|
||||
const detail = err.success
|
||||
? (err.output.detail ?? err.output.errors?.[0]?.message ?? `HTTP ${res.status}`)
|
||||
: `HTTP ${res.status}`;
|
||||
console.error(`[x-post] Failed: ${detail}`);
|
||||
console.error(`[x-post] Failed: ${res.status} ${JSON.stringify(json).slice(0, 300)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -203,6 +203,15 @@ export function openDb(path?: string): Database {
|
|||
created_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS x_tokens (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
if (!path) {
|
||||
migrateFromJson(db);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { ActionsBlock, ContextBlock, KnownBlock, SectionBlock } from "@slac
|
|||
import type { Block } from "@slack/types";
|
||||
import type { ToolCall } from "./helpers";
|
||||
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { isString, toRecord } from "@openrouter/spawn-shared";
|
||||
import { App } from "@slack/bolt";
|
||||
import * as v from "valibot";
|
||||
|
|
@ -51,10 +51,8 @@ const REDDIT_CLIENT_SECRET = process.env.REDDIT_CLIENT_SECRET ?? "";
|
|||
const REDDIT_USERNAME = process.env.REDDIT_USERNAME ?? "";
|
||||
const REDDIT_PASSWORD = process.env.REDDIT_PASSWORD ?? "";
|
||||
const REDDIT_USER_AGENT = `spawn-growth:v1.0.0 (by /u/${REDDIT_USERNAME})`;
|
||||
const X_API_KEY = process.env.X_API_KEY ?? "";
|
||||
const X_API_SECRET = process.env.X_API_SECRET ?? "";
|
||||
const X_ACCESS_TOKEN = process.env.X_ACCESS_TOKEN ?? "";
|
||||
const X_ACCESS_TOKEN_SECRET = process.env.X_ACCESS_TOKEN_SECRET ?? "";
|
||||
const X_CLIENT_ID = process.env.X_CLIENT_ID ?? "";
|
||||
const X_CLIENT_SECRET = process.env.X_CLIENT_SECRET ?? "";
|
||||
|
||||
for (const [name, value] of Object.entries({
|
||||
SLACK_BOT_TOKEN,
|
||||
|
|
@ -68,7 +66,7 @@ for (const [name, value] of Object.entries({
|
|||
|
||||
// #endregion
|
||||
|
||||
// #region X (Twitter) posting
|
||||
// #region X (Twitter) posting — OAuth 2.0 with PKCE token refresh
|
||||
|
||||
interface XPostResult {
|
||||
ok: boolean;
|
||||
|
|
@ -77,34 +75,6 @@ interface XPostResult {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
/** Generate OAuth 1.0a Authorization header for X API. */
|
||||
function generateXOAuthHeader(method: string, url: string): string {
|
||||
const oauthParams: Record<string, string> = {
|
||||
oauth_consumer_key: X_API_KEY,
|
||||
oauth_nonce: randomBytes(16).toString("hex"),
|
||||
oauth_signature_method: "HMAC-SHA1",
|
||||
oauth_timestamp: String(Math.floor(Date.now() / 1000)),
|
||||
oauth_token: X_ACCESS_TOKEN,
|
||||
oauth_version: "1.0",
|
||||
};
|
||||
|
||||
const sortedKeys = Object.keys(oauthParams).sort();
|
||||
const paramString = sortedKeys.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(oauthParams[k])}`).join("&");
|
||||
|
||||
const signatureBase = `${method.toUpperCase()}&${encodeURIComponent(url)}&${encodeURIComponent(paramString)}`;
|
||||
const signingKey = `${encodeURIComponent(X_API_SECRET)}&${encodeURIComponent(X_ACCESS_TOKEN_SECRET)}`;
|
||||
const signature = createHmac("sha1", signingKey).update(signatureBase).digest("base64");
|
||||
|
||||
oauthParams.oauth_signature = signature;
|
||||
|
||||
const headerParts = Object.keys(oauthParams)
|
||||
.sort()
|
||||
.map((k) => `${encodeURIComponent(k)}="${encodeURIComponent(oauthParams[k])}"`)
|
||||
.join(", ");
|
||||
|
||||
return `OAuth ${headerParts}`;
|
||||
}
|
||||
|
||||
const XPostResponseSchema = v.object({
|
||||
data: v.object({
|
||||
id: v.string(),
|
||||
|
|
@ -112,12 +82,117 @@ const XPostResponseSchema = v.object({
|
|||
}),
|
||||
});
|
||||
|
||||
/** Post a tweet (or reply) to X. Returns result with tweet URL on success. */
|
||||
const XTokenResponseSchema = v.object({
|
||||
access_token: v.string(),
|
||||
refresh_token: v.optional(v.string()),
|
||||
expires_in: v.optional(v.number()),
|
||||
});
|
||||
|
||||
interface StoredTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/** Load X OAuth 2.0 tokens from state.db. */
|
||||
function loadXTokens(): StoredTokens | null {
|
||||
try {
|
||||
const row = db
|
||||
.query<
|
||||
{
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_at: number;
|
||||
},
|
||||
[]
|
||||
>("SELECT access_token, refresh_token, expires_at FROM x_tokens WHERE id = 1")
|
||||
.get();
|
||||
if (!row) return null;
|
||||
return {
|
||||
accessToken: row.access_token,
|
||||
refreshToken: row.refresh_token,
|
||||
expiresAt: row.expires_at,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Save refreshed tokens back to state.db. */
|
||||
function saveXTokens(tokens: StoredTokens): void {
|
||||
db.run(
|
||||
`INSERT INTO x_tokens (id, access_token, refresh_token, expires_at, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
access_token = excluded.access_token,
|
||||
refresh_token = excluded.refresh_token,
|
||||
expires_at = excluded.expires_at,
|
||||
updated_at = excluded.updated_at`,
|
||||
[
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken,
|
||||
tokens.expiresAt,
|
||||
new Date().toISOString(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** Refresh the X OAuth 2.0 access token using the refresh token. */
|
||||
async function refreshXToken(refreshToken: string): Promise<StoredTokens | null> {
|
||||
if (!X_CLIENT_ID || !X_CLIENT_SECRET) return null;
|
||||
|
||||
const basicAuth = Buffer.from(`${X_CLIENT_ID}:${X_CLIENT_SECRET}`).toString("base64");
|
||||
const res = await fetch("https://api.x.com/2/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`[x-post] Token refresh failed: ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const json: unknown = await res.json();
|
||||
const parsed = v.safeParse(XTokenResponseSchema, json);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const newTokens: StoredTokens = {
|
||||
accessToken: parsed.output.access_token,
|
||||
refreshToken: parsed.output.refresh_token ?? refreshToken,
|
||||
expiresAt: Date.now() + (parsed.output.expires_in ?? 7200) * 1000,
|
||||
};
|
||||
saveXTokens(newTokens);
|
||||
return newTokens;
|
||||
}
|
||||
|
||||
/** Get a valid X access token, refreshing if expired. */
|
||||
async function getXAccessToken(): Promise<string | null> {
|
||||
const tokens = loadXTokens();
|
||||
if (!tokens) return null;
|
||||
|
||||
// Refresh if expires within 5 minutes
|
||||
if (Date.now() > tokens.expiresAt - 300_000) {
|
||||
const refreshed = await refreshXToken(tokens.refreshToken);
|
||||
return refreshed?.accessToken ?? null;
|
||||
}
|
||||
|
||||
return tokens.accessToken;
|
||||
}
|
||||
|
||||
/** Post a tweet (or reply) to X using OAuth 2.0 Bearer token. */
|
||||
async function postToX(text: string, replyToTweetId?: string): Promise<XPostResult> {
|
||||
if (!X_API_KEY || !X_API_SECRET || !X_ACCESS_TOKEN || !X_ACCESS_TOKEN_SECRET) {
|
||||
const accessToken = await getXAccessToken();
|
||||
if (!accessToken) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "X API credentials not configured",
|
||||
error: "No X OAuth 2.0 tokens — run x-auth.ts to authorize",
|
||||
};
|
||||
}
|
||||
if (!text || text.length > 280) {
|
||||
|
|
@ -137,13 +212,11 @@ async function postToX(text: string, replyToTweetId?: string): Promise<XPostResu
|
|||
};
|
||||
}
|
||||
|
||||
const authHeader = generateXOAuthHeader("POST", url);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "spawn-growth/1.0",
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue