From 58c42d5cbc8244a35eb3aec26927abdf496b5894 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Tue, 23 Jun 2026 20:59:04 +0300 Subject: [PATCH] Add optional Xquik posting backend --- .env.example | 7 +++ README.md | 21 ++++++- src/api.test.ts | 117 ++++++++++++++++++++++++++++++++++ src/api.ts | 164 +++++++++++++++++++++++++++++++++++++++++++++++- src/index.ts | 50 +++++++++------ src/server.ts | 68 ++++++++++++++------ src/types.ts | 18 ++++++ 7 files changed, 406 insertions(+), 39 deletions(-) create mode 100644 src/api.test.ts diff --git a/.env.example b/.env.example index e1c7789..e29f286 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 93fb20c..8cbf9b6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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 ``` diff --git a/src/api.test.ts b/src/api.test.ts new file mode 100644 index 0000000..bffee88 --- /dev/null +++ b/src/api.test.ts @@ -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[0], init: Parameters[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"); + }); +}); diff --git a/src/api.ts b/src/api.ts index 712c271..cb22449 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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") { @@ -118,6 +208,61 @@ export async function postTweet( return handleApiResponse(response, 201); } +async function postXquikTweet( + text: string, + backend: Extract, + replyToId?: string, +): Promise { + 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 { + 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 { if (tweets.length === 0) { throw new Error("Cannot post an empty thread."); @@ -135,6 +280,23 @@ export async function postThread(tweets: string[], credentials: OAuthCredentials return responses; } +export async function postThreadWithBackend(tweets: string[], backend: PostingBackend): Promise { + 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 { const projectRoot = getProjectRoot(); const absolutePath = resolve(projectRoot, filePath); diff --git a/src/index.ts b/src/index.ts index 882587c..4b835ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,14 @@ import "dotenv/config"; -import { checkRateLimits, postThread, postTweet, uploadMedia } from "./api.ts"; +import { + assertMediaSupported, + checkRateLimits, + getPostingBackend, + isXquikBackendSelected, + postThreadWithBackend, + postTweetWithBackend, + uploadMedia, +} from "./api.ts"; import { addItem, getNextPending, @@ -221,9 +229,7 @@ async function handlePost(args: string[]): Promise { if (!user) { exitWithError(`User "${username}" not found. Create them with: bun run src/index.ts user add ${username} `); } - if (!user.twitter) { - exitWithError(`User "${username}" has no Twitter credentials. Set them with: bun run src/index.ts user set-twitter ${username}`); - } + const backend = getPostingBackend(user.twitter); const nextItem = getNextPending(username); @@ -238,18 +244,20 @@ async function handlePost(args: string[]): Promise { } try { - try { - const rateLimit = await checkRateLimits(user.twitter); - if (rateLimit.remaining !== null) { - console.log( - `Rate limit: ${rateLimit.remaining}/${rateLimit.limit ?? "?"} posts remaining${ - rateLimit.resetAt ? ` (resets ${rateLimit.resetAt})` : "" - }`, - ); + if (backend.type === "x-api") { + try { + const rateLimit = await checkRateLimits(backend.credentials); + if (rateLimit.remaining !== null) { + console.log( + `Rate limit: ${rateLimit.remaining}/${rateLimit.limit ?? "?"} posts remaining${ + rateLimit.resetAt ? ` (resets ${rateLimit.resetAt})` : "" + }`, + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`Rate limit lookup skipped: ${message}`); } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`Rate limit lookup skipped: ${message}`); } if (nextItem.type === "thread") { @@ -259,7 +267,7 @@ async function handlePost(args: string[]): Promise { exitWithError(`Thread #${nextItem.id} has no tweets.`); } - const responses = await postThread(threadTexts, user.twitter); + const responses = await postThreadWithBackend(threadTexts, backend); const threadIds = responses.map((response) => response.data.id); const firstId = threadIds[0]; @@ -276,11 +284,12 @@ async function handlePost(args: string[]): Promise { return; } + assertMediaSupported(backend, nextItem.media); const mediaIds = nextItem.media - ? await Promise.all(nextItem.media.map((filePath) => uploadMedia(filePath, user.twitter!))) + ? await Promise.all(nextItem.media.map((filePath) => uploadMedia(filePath, backend.type === "x-api" ? backend.credentials : user.twitter!))) : []; - const response = await postTweet(nextItem.text, user.twitter, undefined, mediaIds); + const response = await postTweetWithBackend(nextItem.text, backend, undefined, mediaIds); markPosted(username, nextItem.id, response.data.id); console.log(`✅ Posted tweet #${nextItem.id}`); @@ -366,6 +375,11 @@ function handleUserSetTwitter(args: string[]): void { const accessToken = process.env.X_ACCESS_TOKEN; const accessTokenSecret = process.env.X_ACCESS_TOKEN_SECRET; + if (isXquikBackendSelected()) { + console.log(`Twitter credentials are not required when TWITTER_BACKEND=xquik for "${username}"`); + return; + } + if (!consumerKey || !consumerSecret || !accessToken || !accessTokenSecret) { exitWithError("Missing X OAuth credentials. Set X_CONSUMER_KEY, X_CONSUMER_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET in .env"); } diff --git a/src/server.ts b/src/server.ts index a27574c..f87f609 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,16 @@ import "dotenv/config"; import { join } from "node:path"; -import { checkRateLimits, postThread, postTweet, uploadMedia } from "./api.ts"; +import { + assertMediaSupported, + checkRateLimits, + getPostingBackend, + hasPostingBackend, + isXquikBackendSelected, + postThreadWithBackend, + postTweetWithBackend, + uploadMedia, +} from "./api.ts"; import { addItem, deleteItem as deleteQueueItem, @@ -20,7 +29,7 @@ import { } from "./queue.ts"; import { createSession, getSession, deleteSession } from "./sessions.ts"; import { getUser, verifyUser } from "./users.ts"; -import type { AddQueueItemInput, QueueItemStatus } from "./types.ts"; +import type { AddQueueItemInput, PostingBackend, QueueItemStatus } from "./types.ts"; const PORT = Number(process.env.PORT) || 3001; @@ -245,7 +254,7 @@ async function handleMe(request: Request): Promise { if (!username) { return err("Not authenticated", 401); } - return ok({ username, hasTwitter: !!getUser(username)?.twitter }); + return ok({ username, hasTwitter: hasPostingBackend(getUser(username)?.twitter) }); } async function handleRegister(request: Request): Promise { @@ -295,9 +304,22 @@ async function handleSetTwitter(request: Request): Promise { async function handleGetTwitter(request: Request): Promise { const username = requireAuth(request); const user = getUser(username); - if (!user?.twitter) return ok({ configured: false }); + if (isXquikBackendSelected()) { + if (!hasPostingBackend(user?.twitter)) { + return ok({ configured: false, backend: "xquik" }); + } + const backend = getPostingBackend(user?.twitter); + return ok({ + configured: true, + backend: "xquik", + account: backend.type === "xquik" ? backend.credentials.account : "", + }); + } + + if (!user?.twitter) return ok({ configured: false, backend: "x-api" }); return ok({ configured: true, + backend: "x-api", consumerKey: user.twitter.consumerKey.slice(0, 4) + "...", accessToken: user.twitter.accessToken.slice(0, 4) + "...", }); @@ -316,19 +338,21 @@ async function handleApiRoute( let items = loadQueue(username); // Lazy scheduler: post any scheduled items that are due - if (user?.twitter) { + if (isXquikBackendSelected() || user?.twitter) { + const backend = getPostingBackend(user?.twitter); const dueItems = getDueScheduledItems(username); for (const item of dueItems) { try { if (item.type === "thread") { - const responses = await postThread(item.thread ?? [], user.twitter); + const responses = await postThreadWithBackend(item.thread ?? [], backend); const threadIds = responses.map((r) => r.data.id); markPosted(username, item.id, threadIds[0] ?? "", threadIds); } else { + assertMediaSupported(backend, item.media); const mediaIds = item.media - ? await Promise.all(item.media.map((fp) => uploadMedia(fp, user.twitter!))) + ? await Promise.all(item.media.map((fp) => uploadMedia(fp, backend.type === "x-api" ? backend.credentials : user!.twitter!))) : []; - const response = await postTweet(item.text, user.twitter, undefined, mediaIds); + const response = await postTweetWithBackend(item.text, backend, undefined, mediaIds); markPosted(username, item.id, response.data.id); } } catch (error) { @@ -372,8 +396,12 @@ async function handleApiRoute( if (method === "POST" && path === "/api/queue/post") { const user = getUser(username); - if (!user?.twitter) { - return err("Twitter credentials not configured. Set them in account settings.", 400); + let backend: PostingBackend; + try { + backend = getPostingBackend(user?.twitter); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return err(message, 400); } const nextItem = getNextPending(username); @@ -382,19 +410,22 @@ async function handleApiRoute( } try { - try { - const rateLimit = await checkRateLimits(user.twitter); - if (rateLimit.remaining !== null) { - console.log(`Rate limit: ${rateLimit.remaining}/${rateLimit.limit ?? "?"} posts remaining`); + if (backend.type === "x-api") { + try { + const rateLimit = await checkRateLimits(backend.credentials); + if (rateLimit.remaining !== null) { + console.log(`Rate limit: ${rateLimit.remaining}/${rateLimit.limit ?? "?"} posts remaining`); + } + } catch { } - } catch {} + } if (nextItem.type === "thread") { const threadTexts = nextItem.thread ?? []; if (threadTexts.length === 0) { return err(`Thread #${nextItem.id} has no tweets.`); } - const responses = await postThread(threadTexts, user.twitter); + const responses = await postThreadWithBackend(threadTexts, backend); const threadIds = responses.map((r) => r.data.id); const firstId = threadIds[0] ?? ""; markPosted(username, nextItem.id, firstId, threadIds); @@ -408,11 +439,12 @@ async function handleApiRoute( }); } + assertMediaSupported(backend, nextItem.media); const mediaIds = nextItem.media - ? await Promise.all(nextItem.media.map((fp) => uploadMedia(fp, user.twitter!))) + ? await Promise.all(nextItem.media.map((fp) => uploadMedia(fp, backend.type === "x-api" ? backend.credentials : user!.twitter!))) : []; - const response = await postTweet(nextItem.text, user.twitter, undefined, mediaIds); + const response = await postTweetWithBackend(nextItem.text, backend, undefined, mediaIds); markPosted(username, nextItem.id, response.data.id); return ok({ diff --git a/src/types.ts b/src/types.ts index edc5bf3..f81a239 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,8 @@ export interface TweetResponse { }; } +export type TwitterBackend = "x-api" | "xquik"; + export interface RateLimitInfo { limit: number | null; remaining: number | null; @@ -43,6 +45,22 @@ export interface OAuthCredentials { accessTokenSecret: string; } +export interface XquikCredentials { + apiKey: string; + account: string; + apiBaseUrl: string; +} + +export type PostingBackend = + | { + type: "x-api"; + credentials: OAuthCredentials; + } + | { + type: "xquik"; + credentials: XquikCredentials; + }; + export interface PostTweetRequest { text: string; reply?: {