Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,10 @@ X_CONSUMER_KEY=your-consumer-key
X_CONSUMER_SECRET=your-consumer-secret
X_ACCESS_TOKEN=your-access-token
X_ACCESS_TOKEN_SECRET=your-access-token-secret

# Optional: use Xquik instead of direct X API credentials for text tweets.
# Media file uploads still require the default x-api backend.
TWITTER_BACKEND=x-api
XQUIK_API_KEY=your-xquik-api-key
XQUIK_ACCOUNT=@your-account
XQUIK_API_BASE_URL=https://xquik.com/api/v1
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ bun install
Requirements:

- Bun runtime
- X Developer account with API credentials
- X Developer account with API credentials, or Xquik API credentials for text posting

```bash
bun install
Expand All @@ -145,6 +145,17 @@ X_ACCESS_TOKEN=your-access-token
X_ACCESS_TOKEN_SECRET=your-access-token-secret
```

To post text tweets through Xquik instead, set:

```env
TWITTER_BACKEND=xquik
XQUIK_API_KEY=your-xquik-api-key
XQUIK_ACCOUNT=@your-account
XQUIK_API_BASE_URL=https://xquik.com/api/v1
```

Xquik posting supports text tweets and confirmed tweet ID replies. Local media file uploads still require the default `x-api` backend.

**No API credentials?** No problem. x-poster works perfectly as a draft manager with one-click copy to clipboard. Just skip the `.env` setup.

### Run the Dashboard
Expand Down Expand Up @@ -189,7 +200,7 @@ bun run src/index.ts list
# List only pending
bun run src/index.ts list --pending

# Post next pending item (requires API credentials)
# Post next pending item (requires X API or Xquik credentials)
bun run src/index.ts post

# Preview without posting
Expand Down Expand Up @@ -217,6 +228,12 @@ If you want direct posting, you need X Developer API credentials:

**X API Pricing**: The free tier may have limited posting credits. Check the [current X API documentation](https://developer.x.com/en/docs/x-api) for the latest pricing. If you don't have API credits, use the Copy button to manually paste tweets into X.

## Xquik Setup (Optional)

Set `TWITTER_BACKEND=xquik`, `XQUIK_API_KEY`, and `XQUIK_ACCOUNT` in `.env` to post text tweets through Xquik. The dashboard and CLI keep `x-api` as the default, so existing Twitter OAuth credentials continue to work unchanged.

When Xquik returns a pending confirmation response, x-poster stops the queue item and records the write action message instead of chaining replies without a confirmed tweet ID.

## Architecture

```
Expand Down
117 changes: 117 additions & 0 deletions src/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { afterEach, describe, expect, test } from "bun:test";

import {
assertMediaSupported,
getPostingBackend,
postThreadWithBackend,
postTweetWithBackend,
} from "./api.ts";

const originalFetch = globalThis.fetch;
const originalEnv = {
TWITTER_BACKEND: process.env.TWITTER_BACKEND,
XQUIK_API_KEY: process.env.XQUIK_API_KEY,
XQUIK_ACCOUNT: process.env.XQUIK_ACCOUNT,
XQUIK_API_BASE_URL: process.env.XQUIK_API_BASE_URL,
};

function resetEnv(): void {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}

function stubFetch(handler: typeof fetch): void {
globalThis.fetch = handler;
}

afterEach(() => {
globalThis.fetch = originalFetch;
resetEnv();
});

describe("Xquik posting backend", () => {
test("resolves Xquik credentials from env", () => {
process.env.TWITTER_BACKEND = "xquik";
process.env.XQUIK_API_KEY = "key";
process.env.XQUIK_ACCOUNT = "@poster";
process.env.XQUIK_API_BASE_URL = "https://xquik.test/api/v1/";

const backend = getPostingBackend();

expect(backend).toEqual({
type: "xquik",
credentials: {
apiKey: "key",
account: "@poster",
apiBaseUrl: "https://xquik.test/api/v1",
},
});
});

test("posts text tweets through Xquik", async () => {
const requests: { url: string; init: RequestInit }[] = [];
stubFetch((async (url: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1]) => {
requests.push({ url: String(url), init: init ?? {} });
return Response.json({
success: true,
tweetId: "1234567890",
charged: true,
chargedCredits: "30",
});
}) as unknown as typeof fetch);

const response = await postTweetWithBackend("Hello queue", {
type: "xquik",
credentials: {
apiKey: "key",
account: "@poster",
apiBaseUrl: "https://xquik.test/api/v1",
},
});

expect(response.data.id).toBe("1234567890");
expect(response.data.text).toBe("Hello queue");
expect(requests[0]?.url).toBe("https://xquik.test/api/v1/x/tweets");
expect(new Headers(requests[0]?.init.headers).get("x-api-key")).toBe("key");
expect(JSON.parse(String(requests[0]?.init.body))).toEqual({
account: "@poster",
text: "Hello queue",
});
});

test("stops threads when Xquik confirmation is pending", async () => {
stubFetch((async () => Response.json({
error: "x_write_unconfirmed",
status: "pending_confirmation",
writeActionId: "42",
charged: false,
chargedCredits: "0",
retryable: false,
}, { status: 202 })) as unknown as typeof fetch);

await expect(postThreadWithBackend(["first", "reply"], {
type: "xquik",
credentials: {
apiKey: "key",
account: "@poster",
apiBaseUrl: "https://xquik.test/api/v1",
},
})).rejects.toThrow("confirmation is pending");
});

test("rejects local media on Xquik backend", () => {
expect(() => assertMediaSupported({
type: "xquik",
credentials: {
apiKey: "key",
account: "@poster",
apiBaseUrl: "https://xquik.test/api/v1",
},
}, ["content/media/post.png"])).toThrow("local media file uploads");
});
});
164 changes: 163 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,102 @@ import { basename, resolve } from "node:path";

import { buildOAuthHeader } from "./auth.ts";
import { getProjectRoot } from "./queue.ts";
import type { OAuthCredentials, PostTweetRequest, RateLimitInfo, TweetResponse, TwitterApiError } from "./types.ts";
import type {
OAuthCredentials,
PostTweetRequest,
PostingBackend,
RateLimitInfo,
TweetResponse,
TwitterApiError,
TwitterBackend,
} from "./types.ts";

const TWEETS_URL = "https://api.x.com/2/tweets";
const MEDIA_UPLOAD_URL = "https://upload.twitter.com/1.1/media/upload.json";
const RATE_LIMIT_URL =
"https://api.x.com/1.1/application/rate_limit_status.json?resources=statuses";
const DEFAULT_XQUIK_API_BASE_URL = "https://xquik.com/api/v1";

interface XquikTweetResponse {
success?: true;
tweetId?: string;
writeActionId?: string;
status?: string;
message?: string;
}

function getConfiguredTwitterBackend(): TwitterBackend {
const rawBackend = process.env.TWITTER_BACKEND?.trim().toLowerCase();
if (!rawBackend || rawBackend === "x-api") {
return "x-api";
}

if (rawBackend === "xquik") {
return "xquik";
}

throw new Error(`Unsupported TWITTER_BACKEND: ${process.env.TWITTER_BACKEND}`);
}

function getRequiredEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing ${name}. Set it in .env.`);
}
return value;
}

function normalizeBaseUrl(value: string | undefined): string {
const rawUrl = value?.trim() || DEFAULT_XQUIK_API_BASE_URL;
return rawUrl.replace(/\/+$/, "");
}

function getXquikCredentials(): PostingBackend {
return {
type: "xquik",
credentials: {
apiKey: getRequiredEnv("XQUIK_API_KEY"),
account: getRequiredEnv("XQUIK_ACCOUNT"),
apiBaseUrl: normalizeBaseUrl(process.env.XQUIK_API_BASE_URL),
},
};
}

export function getPostingBackend(credentials?: OAuthCredentials): PostingBackend {
const backend = getConfiguredTwitterBackend();

if (backend === "xquik") {
return getXquikCredentials();
}

if (!credentials) {
throw new Error("Twitter credentials not configured. Set them in account settings.");
}

return {
type: "x-api",
credentials,
};
}

export function isXquikBackendSelected(): boolean {
return getConfiguredTwitterBackend() === "xquik";
}

export function hasPostingBackend(credentials?: OAuthCredentials): boolean {
try {
getPostingBackend(credentials);
return true;
} catch {
return false;
}
}

export function assertMediaSupported(backend: PostingBackend, media?: string[]): void {
if (backend.type === "xquik" && media && media.length > 0) {
throw new Error("Xquik backend does not support local media file uploads. Use public media URLs or the X API backend.");
}
}

function getErrorDetail(payload: unknown): string {
if (typeof payload === "string") {
Expand Down Expand Up @@ -118,6 +208,61 @@ export async function postTweet(
return handleApiResponse<TweetResponse>(response, 201);
}

async function postXquikTweet(
text: string,
backend: Extract<PostingBackend, { type: "xquik" }>,
replyToId?: string,
): Promise<TweetResponse> {
const { apiKey, account, apiBaseUrl } = backend.credentials;
const response = await fetch(`${apiBaseUrl}/x/tweets`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
},
body: JSON.stringify({
account,
text,
...(replyToId ? { reply_to_tweet_id: replyToId } : {}),
}),
});

const payload = await parseResponseBody(response) as XquikTweetResponse | null;

if (response.status === 200 && payload?.tweetId) {
return {
data: {
id: payload.tweetId,
text,
},
};
}

if (response.status === 202 && payload?.writeActionId) {
throw new Error(
`Xquik accepted write action ${payload.writeActionId}, but tweet confirmation is pending. Do not retry until you confirm whether the tweet was posted.`,
);
}

throw new Error(`Xquik API error ${response.status}: ${getErrorDetail(payload)}`);
}

export async function postTweetWithBackend(
text: string,
backend: PostingBackend,
replyToId?: string,
mediaIds?: string[],
): Promise<TweetResponse> {
if (backend.type === "xquik") {
if (mediaIds && mediaIds.length > 0) {
throw new Error("Xquik backend does not accept uploaded X media IDs.");
}
return postXquikTweet(text, backend, replyToId);
}

return postTweet(text, backend.credentials, replyToId, mediaIds);
}

export async function postThread(tweets: string[], credentials: OAuthCredentials): Promise<TweetResponse[]> {
if (tweets.length === 0) {
throw new Error("Cannot post an empty thread.");
Expand All @@ -135,6 +280,23 @@ export async function postThread(tweets: string[], credentials: OAuthCredentials
return responses;
}

export async function postThreadWithBackend(tweets: string[], backend: PostingBackend): Promise<TweetResponse[]> {
if (tweets.length === 0) {
throw new Error("Cannot post an empty thread.");
}

const responses: TweetResponse[] = [];
let replyToId: string | undefined;

for (const tweet of tweets) {
const response = await postTweetWithBackend(tweet, backend, replyToId);
responses.push(response);
replyToId = response.data.id;
}

return responses;
}

export async function uploadMedia(filePath: string, credentials: OAuthCredentials): Promise<string> {
const projectRoot = getProjectRoot();
const absolutePath = resolve(projectRoot, filePath);
Expand Down
Loading