Add Sweetistics API posting support to bird
This commit is contained in:
@@ -91,6 +91,20 @@ bird check
|
|||||||
- Requires Chrome to be logged into x.com
|
- Requires Chrome to be logged into x.com
|
||||||
- May prompt for keychain access on first run
|
- 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
|
### Getting Your Cookies
|
||||||
|
|
||||||
1. Open Chrome and log into x.com
|
1. Open Chrome and log into x.com
|
||||||
|
|||||||
+65
-2
@@ -13,6 +13,7 @@ import { Command } from 'commander';
|
|||||||
import { resolveCredentials } from './lib/cookies.js';
|
import { resolveCredentials } from './lib/cookies.js';
|
||||||
import { extractTweetId } from './lib/extract-tweet-id.js';
|
import { extractTweetId } from './lib/extract-tweet-id.js';
|
||||||
import { TwitterClient } from './lib/twitter-client.js';
|
import { TwitterClient } from './lib/twitter-client.js';
|
||||||
|
import { SweetisticsClient } from './lib/sweetistics-client.js';
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
@@ -22,7 +23,21 @@ program.name('bird').description('Post tweets and replies via Twitter/X GraphQL
|
|||||||
program
|
program
|
||||||
.option('--auth-token <token>', 'Twitter auth_token cookie')
|
.option('--auth-token <token>', 'Twitter auth_token cookie')
|
||||||
.option('--ct0 <token>', 'Twitter ct0 cookie')
|
.option('--ct0 <token>', 'Twitter ct0 cookie')
|
||||||
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction');
|
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction')
|
||||||
|
.option('--sweetistics-api-key <key>', 'Sweetistics API key (or set SWEETISTICS_API_KEY)')
|
||||||
|
.option('--sweetistics-base-url <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
|
// Tweet command
|
||||||
program
|
program
|
||||||
@@ -31,6 +46,30 @@ program
|
|||||||
.argument('<text>', 'Tweet text')
|
.argument('<text>', 'Tweet text')
|
||||||
.action(async (text: string) => {
|
.action(async (text: string) => {
|
||||||
const opts = program.opts();
|
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({
|
const { cookies, warnings } = await resolveCredentials({
|
||||||
authToken: opts.authToken,
|
authToken: opts.authToken,
|
||||||
ct0: opts.ct0,
|
ct0: opts.ct0,
|
||||||
@@ -70,6 +109,31 @@ program
|
|||||||
.argument('<text>', 'Reply text')
|
.argument('<text>', 'Reply text')
|
||||||
.action(async (tweetIdOrUrl: string, text: string) => {
|
.action(async (tweetIdOrUrl: string, text: string) => {
|
||||||
const opts = program.opts();
|
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({
|
const { cookies, warnings } = await resolveCredentials({
|
||||||
authToken: opts.authToken,
|
authToken: opts.authToken,
|
||||||
ct0: opts.ct0,
|
ct0: opts.ct0,
|
||||||
@@ -89,7 +153,6 @@ program
|
|||||||
console.error(`📍 Using credentials from: ${cookies.source}`);
|
console.error(`📍 Using credentials from: ${cookies.source}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
|
||||||
console.error(`📝 Replying to tweet: ${tweetId}`);
|
console.error(`📝 Replying to tweet: ${tweetId}`);
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies });
|
const client = new TwitterClient({ cookies });
|
||||||
|
|||||||
@@ -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<SweetisticsTweetResult> {
|
||||||
|
const payload: Record<string, unknown> = { 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user