From 850d59e68d8b6cb6a85a30869b0d13bb2b89d9f8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Dec 2025 12:36:54 +0000 Subject: [PATCH] Add Sweetistics API posting support to bird --- README.md | 14 +++++++ src/index.ts | 67 +++++++++++++++++++++++++++++- src/lib/sweetistics-client.ts | 70 ++++++++++++++++++++++++++++++++ tests/sweetistics-client.test.ts | 50 +++++++++++++++++++++++ 4 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 src/lib/sweetistics-client.ts create mode 100644 tests/sweetistics-client.test.ts diff --git a/README.md b/README.md index 1c73770..cb0b7a3 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,20 @@ bird check - Requires Chrome to be logged into x.com - May prompt for keychain access on first run +### Posting via Sweetistics (API key) + +If you have a Sweetistics API key, `bird` can post through the Sweetistics SaaS instead of using local Twitter cookies: + +```bash +export SWEETISTICS_API_KEY="sweet-..." +bird tweet "Hello from Sweetistics!" + +# Optional: point to a self-hosted instance +bird --sweetistics-base-url "http://localhost:3000" --sweetistics-api-key "sweet-..." tweet "hi" +``` + +When an API key is present, `bird` will use Sweetistics’ `/api/actions/tweet` endpoint and skip local cookie resolution. + ### Getting Your Cookies 1. Open Chrome and log into x.com diff --git a/src/index.ts b/src/index.ts index 4a4fb5e..2623189 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { Command } from 'commander'; import { resolveCredentials } from './lib/cookies.js'; import { extractTweetId } from './lib/extract-tweet-id.js'; import { TwitterClient } from './lib/twitter-client.js'; +import { SweetisticsClient } from './lib/sweetistics-client.js'; const program = new Command(); @@ -22,7 +23,21 @@ program.name('bird').description('Post tweets and replies via Twitter/X GraphQL program .option('--auth-token ', 'Twitter auth_token cookie') .option('--ct0 ', 'Twitter ct0 cookie') - .option('--chrome-profile ', 'Chrome profile name for cookie extraction'); + .option('--chrome-profile ', 'Chrome profile name for cookie extraction') + .option('--sweetistics-api-key ', 'Sweetistics API key (or set SWEETISTICS_API_KEY)') + .option('--sweetistics-base-url ', 'Sweetistics base URL', process.env.SWEETISTICS_BASE_URL || 'https://sweetistics.com'); + +function resolveSweetisticsConfig(options: { sweetisticsApiKey?: string; sweetisticsBaseUrl?: string }) { + const apiKey = + options.sweetisticsApiKey || + process.env.SWEETISTICS_API_KEY || + process.env.SWEETISTICS_LOCALHOST_API_KEY || + null; + + const baseUrl = options.sweetisticsBaseUrl || process.env.SWEETISTICS_BASE_URL || 'https://sweetistics.com'; + + return { apiKey, baseUrl }; +} // Tweet command program @@ -31,6 +46,30 @@ program .argument('', 'Tweet text') .action(async (text: string) => { const opts = program.opts(); + const sweetistics = resolveSweetisticsConfig(opts); + + if (sweetistics.apiKey) { + try { + const client = new SweetisticsClient({ + baseUrl: sweetistics.baseUrl, + apiKey: sweetistics.apiKey, + }); + const result = await client.tweet(text); + if (result.success) { + console.log('βœ… Tweet posted via Sweetistics!'); + if (result.tweetId) { + console.log(`πŸ”— https://x.com/i/status/${result.tweetId}`); + } + return; + } + console.error(`❌ Sweetistics post failed: ${result.error ?? 'Unknown error'}`); + process.exit(1); + } catch (error) { + console.error(`❌ Sweetistics error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + } + const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, @@ -70,6 +109,31 @@ program .argument('', 'Reply text') .action(async (tweetIdOrUrl: string, text: string) => { const opts = program.opts(); + const sweetistics = resolveSweetisticsConfig(opts); + const tweetId = extractTweetId(tweetIdOrUrl); + + if (sweetistics.apiKey) { + try { + const client = new SweetisticsClient({ + baseUrl: sweetistics.baseUrl, + apiKey: sweetistics.apiKey, + }); + const result = await client.tweet(text, tweetId); + if (result.success) { + console.log('βœ… Reply posted via Sweetistics!'); + if (result.tweetId) { + console.log(`πŸ”— https://x.com/i/status/${result.tweetId}`); + } + return; + } + console.error(`❌ Sweetistics reply failed: ${result.error ?? 'Unknown error'}`); + process.exit(1); + } catch (error) { + console.error(`❌ Sweetistics error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + } + const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, @@ -89,7 +153,6 @@ program console.error(`πŸ“ Using credentials from: ${cookies.source}`); } - const tweetId = extractTweetId(tweetIdOrUrl); console.error(`πŸ“ Replying to tweet: ${tweetId}`); const client = new TwitterClient({ cookies }); diff --git a/src/lib/sweetistics-client.ts b/src/lib/sweetistics-client.ts new file mode 100644 index 0000000..ea6979d --- /dev/null +++ b/src/lib/sweetistics-client.ts @@ -0,0 +1,70 @@ +export interface SweetisticsClientOptions { + baseUrl: string; + apiKey: string; + userAgent?: string; +} + +export interface SweetisticsTweetResult { + success: boolean; + tweetId?: string; + error?: string; +} + +function normalizeBaseUrl(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return 'https://sweetistics.com'; + return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed; +} + +export class SweetisticsClient { + private readonly baseUrl: string; + private readonly apiKey: string; + private readonly userAgent?: string; + + constructor(options: SweetisticsClientOptions) { + this.baseUrl = normalizeBaseUrl(options.baseUrl); + this.apiKey = options.apiKey.trim(); + this.userAgent = options.userAgent; + if (!this.apiKey) { + throw new Error('Sweetistics API key is required'); + } + } + + async tweet(text: string, replyToTweetId?: string): Promise { + const payload: Record = { text }; + if (replyToTweetId) { + payload.replyToTweetId = replyToTweetId; + } + + const response = await fetch(`${this.baseUrl}/api/actions/tweet`, { + method: 'POST', + headers: { + authorization: `Bearer ${this.apiKey}`, + 'content-type': 'application/json', + ...(this.userAgent ? { 'user-agent': this.userAgent } : {}), + }, + body: JSON.stringify(payload), + }); + + let data: unknown; + try { + data = await response.json(); + } catch (error) { + return { + success: false, + error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + const success = typeof (data as { success?: unknown })?.success === 'boolean' ? (data as { success: boolean }).success : false; + const tweetId = typeof (data as { tweetId?: unknown })?.tweetId === 'string' ? (data as { tweetId?: string }).tweetId : undefined; + const errorMessage = typeof (data as { error?: unknown })?.error === 'string' ? (data as { error?: string }).error : undefined; + + if (!response.ok || !success) { + const reason = errorMessage || `HTTP ${response.status}`; + return { success: false, error: reason }; + } + + return { success: true, tweetId }; + } +} diff --git a/tests/sweetistics-client.test.ts b/tests/sweetistics-client.test.ts new file mode 100644 index 0000000..2a5e6e7 --- /dev/null +++ b/tests/sweetistics-client.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SweetisticsClient } from '../src/lib/sweetistics-client.js'; + +describe('SweetisticsClient', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('posts tweet with bearer token and reply id', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true, tweetId: '123' }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' }); + const result = await client.tweet('hello world', '456'); + + expect(result.success).toBe(true); + expect(result.tweetId).toBe('123'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.example.com/api/actions/tweet'); + expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer sweet-test' }); + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ text: 'hello world', replyToTweetId: '456' }); + }); + + it('returns error when API responds with failure', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ success: false, error: 'Unauthorized' }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const client = new SweetisticsClient({ baseUrl: 'https://api.example.com', apiKey: 'sweet-test' }); + const result = await client.tweet('test'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Unauthorized'); + }); +});