diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7493879..9d33c4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,8 @@ jobs: - name: Install dependencies run: npm ci - - name: Build - run: npm run build + - name: Build and test + run: npm test - name: MCP introspection smoke test run: | diff --git a/README.md b/README.md index ac0c55f..0fc59d2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # content-distribution-mcp -**Publish your content everywhere—without rewriting for every platform.** +**Publish your content everywhere - without rewriting for every platform.** A MCP server that distributes a single piece of content across 8+ channels (DEV.to, Hashnode, GitHub Discussions, Reddit, Bluesky, LinkedIn, Medium, Twitter) with **automatic platform-specific adaptation**, idempotent publishing, per-community anti-spam rules, and centralized state management. @@ -16,7 +16,7 @@ This MCP handles distribution complexity. Write your core message once, generate ## How It Works 1. **Your agent** generates channel-specific copy variants (rewritten titles, trimmed text, platform-appropriate tags, audience-matched tone). -2. **This MCP** publishes each variant with idempotency, OAuth, API retries, and scheduling—enforcing platform constraints automatically. +2. **This MCP** publishes each variant with idempotency, OAuth, API retries, and scheduling - enforcing platform constraints automatically. 3. **You control** which platforms get what. The MCP returns per-channel hints (character limits, tag vocabularies, cooldowns) but leaves creative decisions to you. No LLM calls inside. No walled-in agents. Just a clean API for multi-platform content distribution at scale. @@ -31,7 +31,7 @@ Or add it permanently to your MCP host. ## Wire into your MCP host -**Claude Code** — add to `.claude/mcp.json`: +**Claude Code** - add to `.claude/mcp.json`: ```json { @@ -44,7 +44,7 @@ Or add it permanently to your MCP host. } ``` -**Claude Desktop** — add to `claude_desktop_config.json`: +**Claude Desktop** - add to `claude_desktop_config.json`: ```json { @@ -57,9 +57,9 @@ Or add it permanently to your MCP host. } ``` -**n8n** — use the MCP Client node, point it at `npx @automatelab/content-distribution-mcp` over stdio. +**n8n** - use the MCP Client node, point it at `npx @automatelab/content-distribution-mcp` over stdio. -**Cursor / Windsurf / any MCP host** — same `npx -y content-distribution-mcp` pattern. +**Cursor / Windsurf / any MCP host** - same `npx -y content-distribution-mcp` pattern. ## Configure credentials @@ -80,12 +80,14 @@ default: REDDIT_PASSWORD: "..." BLUESKY_IDENTIFIER: "you.bsky.social" BLUESKY_PASSWORD: "..." + XQUIK_API_KEY: "xq_..." + XQUIK_ACCOUNT: "@your_connected_x_account" subreddits: - ClaudeAI - LocalLLaMA ``` -Only set credentials for channels you intend to use. LinkedIn, Medium, and Twitter/X return `needs_browser` with a compose URL — no credentials needed. +Only set credentials for channels you intend to use. LinkedIn and Medium return `needs_browser` with a compose URL. Twitter/X can publish through Hermes Tweet when `XQUIK_API_KEY` or `HERMES_TWEET_API_KEY` is configured, and otherwise keeps the browser compose fallback. ## MCP tool surface @@ -95,7 +97,7 @@ Eight tools, dot-notation names form a navigable tree (`post.*`, `channel.*`, `p |---|---| | `post_publish` | Immediate publish; idempotent on `(content.id, channel)` | | `post_schedule` | Queue variants for `schedule_at`, publish the rest immediately | -| `post_drain` | Fire all scheduled posts due now — run from cron | +| `post_drain` | Fire all scheduled posts due now - run from cron | | `post_status` | Per-channel state for a content piece or channel | | `post_unpublish` | Best-effort delete (DEV.to sets unpublished; others vary) | | `channel_hints` | Per-channel metadata: char limits, Markdown support, tag vocab | @@ -115,7 +117,20 @@ Eight tools, dot-notation names form a navigable tree (`post.*`, `channel.*`, `p | `bluesky` | Auto | `BLUESKY_IDENTIFIER` + `BLUESKY_PASSWORD` | | `linkedin` | Browser fallback | returns `needs_browser` + compose URL | | `medium` | Browser fallback | returns `needs_browser` + compose URL | -| `twitter` / `x` | Browser fallback | returns `needs_browser` + compose URL | +| `twitter` / `x` | Auto or browser fallback | `XQUIK_API_KEY` or `HERMES_TWEET_API_KEY`, plus `XQUIK_ACCOUNT` or `HERMES_TWEET_ACCOUNT`; falls back to `needs_browser` when no key is configured | + +### Twitter/X via Hermes Tweet + +The `twitter` and `x` channels can publish automatically through Hermes Tweet and Xquik. Add these fields to the selected Distribution Profile: + +```yaml +default: + credentials: + XQUIK_API_KEY: "xq_your_key" + XQUIK_ACCOUNT: "@your_connected_x_account" +``` + +`HERMES_TWEET_API_KEY` and `HERMES_TWEET_ACCOUNT` are accepted aliases. `XQUIK_BASE_URL` can point to another compatible deployment when needed. If no Hermes Tweet key is configured, the adapter returns the original browser compose URL instead of failing. ## Example agent call @@ -172,6 +187,9 @@ Or call the `post_drain` MCP tool directly from an agent. |---|---|---| | `DISTRIBUTION_BACKEND` | `yaml` | State backend (`yaml` only in v1) | | `DISTRIBUTION_BACKEND_DIR` | `~/.distribution-mcp` | Directory for YAML state files | +| `XQUIK_API_KEY` / `HERMES_TWEET_API_KEY` | unset | Optional Hermes Tweet key for automated Twitter/X publishing | +| `XQUIK_ACCOUNT` / `HERMES_TWEET_ACCOUNT` | unset | Connected X account used when a `twitter` / `x` channel has no account suffix | +| `XQUIK_BASE_URL` | `https://xquik.com` | Optional compatible Hermes Tweet base URL | ## Requirements @@ -184,7 +202,7 @@ Agent (Claude Code / n8n / Cursor / any MCP host) │ generates per-channel copy, calls MCP tools ▼ content-distribution-mcp (this package, stdio transport) - │ no LLM calls — pure I/O + │ no LLM calls - pure I/O ├── adapters/ devto · hashnode · github-discussions · reddit · bluesky · browser └── backends/ yaml (post log · profiles · schedule queue · subreddit catalog) ``` @@ -199,9 +217,9 @@ grep -ri "anthropic" node_modules/content-distribution-mcp/dist/ # returns noth ## Part of the AutomateLab stack -- [agency-os](https://github.com/AutomateLab-tech/agency-os) — control plane -- **content-distribution-mcp** — this package -- [automatelab.tech](https://automatelab.tech) — blog and tutorials +- [agency-os](https://github.com/AutomateLab-tech/agency-os) - control plane +- **content-distribution-mcp** - this package +- [automatelab.tech](https://automatelab.tech) - blog and tutorials ## License diff --git a/SKILL.md b/SKILL.md index 037a812..96465eb 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: content-distribution -description: Use when the user wants to publish a post, article, or announcement to multiple platforms at once — DEV.to, Hashnode, GitHub Discussions, Reddit, Bluesky, LinkedIn, Medium, or Twitter/X. Handles platform-specific format adaptation, idempotent re-publish, per-community anti-spam rules, and scheduling. Write your message once; this skill routes it everywhere. +description: Use when the user wants to publish a post, article, or announcement to multiple platforms at once - DEV.to, Hashnode, GitHub Discussions, Reddit, Bluesky, LinkedIn, Medium, or Twitter/X. Handles platform-specific format adaptation, idempotent re-publish, per-community anti-spam rules, and scheduling. Write your message once; this skill routes it everywhere. version: 2.2.1 license: MIT homepage: https://github.com/AutomateLab-tech/content-distribution-mcp @@ -23,18 +23,18 @@ metadata: # content-distribution -Pairs with the `@automatelab/content-distribution-mcp` server. Publishes content to 8+ channels with automatic platform-specific adaptation, idempotent state tracking, and per-community anti-spam enforcement. +Pairs with the `@automatelab/content-distribution-mcp` server. Publishes content to 8+ channels with automatic platform-specific adaptation, idempotent state tracking, and per-community anti-spam enforcement. Twitter/X can use Hermes Tweet when a Hermes Tweet or Xquik key is configured, then fall back to browser compose when no key is set. ## What the MCP handles vs. what you handle -**MCP handles:** OAuth, API retries, scheduling, idempotency, character limits, platform constraints, posting state. +**MCP handles:** OAuth, API retries, scheduling, idempotency, character limits, platform constraints, posting state. **You handle:** Writing the platform-specific copy variants (title, body, tags, tone per channel). The MCP returns per-channel hints to guide you. ## Tool overview | Tool | Use when | |---|---| -| `distribute_content` | Publish to one or more channels in a single call — the main entry point | +| `distribute_content` | Publish to one or more channels in a single call - the main entry point | | `get_channel_hints` | Get character limits, tag vocabularies, cooldowns, and formatting rules before writing variants | | `get_distribution_status` | Check what went live where; retry failed channels | | `schedule_distribution` | Queue a post for future publish (e.g. "post this tomorrow at 9am UTC") | @@ -64,7 +64,7 @@ Pairs with the `@automatelab/content-distribution-mcp` server. Publishes content ## Idempotency -Every `distribute_content` call returns a `distribution_id`. Calling it again with the same id and same targets is a no-op — safe to retry after a partial failure. +Every `distribute_content` call returns a `distribution_id`. Calling it again with the same id and same targets is a no-op - safe to retry after a partial failure. ## Server setup @@ -92,7 +92,9 @@ Every `distribute_content` call returns a `distribution_id`. Calling it again wi } ``` -Requires Node 20+. Set platform API keys as environment variables — see the [README](https://github.com/AutomateLab-tech/content-distribution-mcp#configuration) for the full list. +Requires Node 20+. Set platform API keys as environment variables - see the [README](https://github.com/AutomateLab-tech/content-distribution-mcp#configure-credentials) for the full list. + +For automated Twitter/X publishing, set `XQUIK_API_KEY` plus `XQUIK_ACCOUNT`, or the aliases `HERMES_TWEET_API_KEY` plus `HERMES_TWEET_ACCOUNT`. Leave them unset to keep the browser compose fallback. --- diff --git a/package-lock.json b/package-lock.json index 1deed45..123a61d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@automatelab/content-distribution-mcp", - "version": "2.2.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@automatelab/content-distribution-mcp", - "version": "2.2.0", + "version": "3.0.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", @@ -606,9 +606,9 @@ } }, "node_modules/hono": { - "version": "4.12.21", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz", - "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==", + "version": "4.12.30", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz", + "integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -696,9 +696,19 @@ } }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/package.json b/package.json index 751c352..4792773 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ ], "scripts": { "build": "tsc", + "test": "npm run build && node --test test/*.test.mjs", "prepare": "npm run build" }, "dependencies": { diff --git a/src/adapters/index.ts b/src/adapters/index.ts index e0bb1b5..11b736e 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -4,6 +4,7 @@ import { GitHubDiscussionsAdapter } from "./github-discussions.js"; import { RedditAdapter } from "./reddit.js"; import { BlueskyAdapter } from "./bluesky.js"; import { makeBrowserAdapter } from "./browser.js"; +import { XquikTwitterAdapter } from "./xquik-twitter.js"; import type { Variant, PublishResult, ChannelHints } from "../models.js"; import type { Profile } from "../backends/base.js"; @@ -16,7 +17,7 @@ export interface ChannelAdapter { export function buildAdapterMap(): Record { const medium = makeBrowserAdapter("medium"); const linkedin = makeBrowserAdapter("linkedin"); - const twitter = makeBrowserAdapter("twitter"); + const twitter = new XquikTwitterAdapter(makeBrowserAdapter("twitter")); return { devto: new DevToAdapter(), diff --git a/src/adapters/xquik-twitter.ts b/src/adapters/xquik-twitter.ts new file mode 100644 index 0000000..e48c876 --- /dev/null +++ b/src/adapters/xquik-twitter.ts @@ -0,0 +1,203 @@ +import type { Variant, PublishResult, ChannelHints } from "../models.js"; +import type { Profile } from "../backends/base.js"; +import type { ChannelAdapter } from "./index.js"; + +const DEFAULT_XQUIK_BASE_URL = "https://xquik.com"; +const XQUIK_TWEET_PATH = "/api/v1/x/tweets"; +const X_POST_LIMIT = 280; +const REQUEST_TIMEOUT_MS = 30_000; + +interface XquikPostResponse { + data?: { + id?: unknown; + tweetId?: unknown; + url?: unknown; + }; + tweet?: { + id?: unknown; + url?: unknown; + }; + id?: unknown; + tweetId?: unknown; + url?: unknown; +} + +interface XquikConfig { + account: string; + apiKey: string; + baseUrl: string; +} + +function credential(profile: Profile, key: string): string { + const profileValue = profile.credentials[key]?.trim(); + if (profileValue) return profileValue; + return (process.env[key] ?? "").trim(); +} + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ""); +} + +export function getXquikConfig(profile: Profile, variant: Variant): XquikConfig { + const apiKey = credential(profile, "XQUIK_API_KEY") + || credential(profile, "HERMES_TWEET_API_KEY"); + const baseUrl = trimTrailingSlash( + credential(profile, "XQUIK_BASE_URL") || DEFAULT_XQUIK_BASE_URL, + ); + const accountFromChannel = variant.channel.split(":")[1] ?? ""; + const accountFromExtras = typeof variant.extras.account === "string" + ? variant.extras.account + : ""; + const account = accountFromExtras.trim() + || credential(profile, "XQUIK_ACCOUNT") + || credential(profile, "HERMES_TWEET_ACCOUNT") + || accountFromChannel.trim(); + + return { account, apiKey, baseUrl }; +} + +export function buildXquikUrl(baseUrl: string): string { + return new URL(`${trimTrailingSlash(baseUrl)}${XQUIK_TWEET_PATH}`).toString(); +} + +export function buildXquikHeaders(apiKey: string): Record { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "content-distribution-mcp/xquik-twitter", + }; + + if (apiKey.startsWith("xq_")) { + headers["x-api-key"] = apiKey; + } else { + headers.Authorization = `Bearer ${apiKey}`; + } + + return headers; +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function extractTweetId(payload: XquikPostResponse): string { + return stringValue(payload.data?.id) + || stringValue(payload.data?.tweetId) + || stringValue(payload.tweet?.id) + || stringValue(payload.id) + || stringValue(payload.tweetId); +} + +function extractTweetUrl(payload: XquikPostResponse, account: string): string | undefined { + const explicitUrl = stringValue(payload.data?.url) + || stringValue(payload.tweet?.url) + || stringValue(payload.url); + if (explicitUrl) return explicitUrl; + + const id = extractTweetId(payload); + if (!id || !account) return undefined; + + return `https://x.com/${account.replace(/^@/, "")}/status/${id}`; +} + +async function readJson(response: Response): Promise { + const text = await response.text(); + if (!text) return {}; + + try { + return JSON.parse(text) as XquikPostResponse; + } catch { + return { data: { id: "" } }; + } +} + +async function postWithTimeout(url: string, init: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +function requestFailure(error: unknown): string { + if (error instanceof DOMException && error.name === "AbortError") { + return `Hermes Tweet publish timed out after ${REQUEST_TIMEOUT_MS / 1_000} seconds`; + } + if (error instanceof Error && error.message) { + return `Hermes Tweet publish failed: ${error.message}`; + } + return "Hermes Tweet publish failed: network request failed"; +} + +export class XquikTwitterAdapter implements ChannelAdapter { + constructor(private readonly fallback: ChannelAdapter) {} + + hints(): ChannelHints { + return { + max_length: X_POST_LIMIT, + supported_md_features: ["links"], + cta_placement: "none", + canonical_url_supported: false, + browser_only: false, + }; + } + + async publish(variant: Variant, profile: Profile): Promise { + const config = getXquikConfig(profile, variant); + if (!config.apiKey) { + return this.fallback.publish(variant, profile); + } + + if (!config.account) { + return { + channel: variant.channel, + state: "failed", + error: "XQUIK_ACCOUNT or HERMES_TWEET_ACCOUNT required for automated Twitter/X publishing", + }; + } + + let response: Response; + try { + response = await postWithTimeout(buildXquikUrl(config.baseUrl), { + method: "POST", + headers: buildXquikHeaders(config.apiKey), + body: JSON.stringify({ + account: config.account, + text: variant.body.slice(0, X_POST_LIMIT), + }), + }); + } catch (error) { + return { + channel: variant.channel, + state: "failed", + error: requestFailure(error), + }; + } + const payload = await readJson(response); + + if (!response.ok) { + const detail = stringValue((payload as { error?: unknown }).error) + || stringValue((payload as { message?: unknown }).message) + || response.statusText + || "request failed"; + return { + channel: variant.channel, + state: "failed", + error: `Hermes Tweet publish failed (${response.status}): ${detail}`, + }; + } + + return { + channel: variant.channel, + state: "live", + live_url: extractTweetUrl(payload, config.account), + published_at: new Date().toISOString(), + }; + } + + async unpublish(liveUrl: string, profile: Profile): Promise<[boolean, string | undefined]> { + return this.fallback.unpublish(liveUrl, profile); + } +} diff --git a/test/xquik-twitter.test.mjs b/test/xquik-twitter.test.mjs new file mode 100644 index 0000000..07250a0 --- /dev/null +++ b/test/xquik-twitter.test.mjs @@ -0,0 +1,213 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + XquikTwitterAdapter, + buildXquikHeaders, + buildXquikUrl, + getXquikConfig, +} from "../dist/adapters/xquik-twitter.js"; + +const XQUIK_ENV_KEYS = [ + "XQUIK_API_KEY", + "HERMES_TWEET_API_KEY", + "XQUIK_ACCOUNT", + "HERMES_TWEET_ACCOUNT", + "XQUIK_BASE_URL", +]; + +const fallback = { + hints() { + return { + supported_md_features: ["links"], + cta_placement: "none", + canonical_url_supported: false, + browser_only: true, + }; + }, + async publish(variant) { + return { + channel: variant.channel, + state: "needs_browser", + compose_url: `https://twitter.com/compose/tweet?text=${encodeURIComponent(variant.body.slice(0, 280))}`, + }; + }, + async unpublish() { + return [false, "manual"]; + }, +}; + +function profile(credentials = {}) { + return { name: "test", credentials }; +} + +function variant(overrides = {}) { + return { + channel: "twitter", + title: "Launch", + body: "Ship the launch update", + tags: [], + extras: {}, + ...overrides, + }; +} + +async function withCleanEnv(fn) { + const previous = new Map(XQUIK_ENV_KEYS.map((key) => [key, process.env[key]])); + for (const key of XQUIK_ENV_KEYS) { + delete process.env[key]; + } + + try { + return await fn(); + } finally { + for (const [key, value] of previous.entries()) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +test("falls back to browser compose when no Hermes Tweet key is configured", async () => { + await withCleanEnv(async () => { + const adapter = new XquikTwitterAdapter(fallback); + const result = await adapter.publish(variant(), profile()); + + assert.equal(result.state, "needs_browser"); + assert.equal(result.channel, "twitter"); + assert.equal(result.compose_url.startsWith("https://twitter.com/compose/tweet"), true); + }); +}); + +test("uses Xquik API key auth for automated Twitter publishing", async () => { + await withCleanEnv(async () => { + const adapter = new XquikTwitterAdapter(fallback); + const calls = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, init) => { + calls.push({ url, init }); + return new Response(JSON.stringify({ data: { id: "12345" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + const result = await adapter.publish( + variant(), + profile({ XQUIK_API_KEY: "xq_test", XQUIK_ACCOUNT: "@launch" }), + ); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://xquik.com/api/v1/x/tweets"); + assert.equal(calls[0].init.headers["x-api-key"], "xq_test"); + assert.deepEqual(JSON.parse(calls[0].init.body), { + account: "@launch", + text: "Ship the launch update", + }); + assert.equal(result.state, "live"); + assert.equal(result.live_url, "https://x.com/launch/status/12345"); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +test("accepts bearer auth and account from channel suffix", async () => { + await withCleanEnv(async () => { + const adapter = new XquikTwitterAdapter(fallback); + const originalFetch = globalThis.fetch; + let request; + globalThis.fetch = async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ url: "https://x.com/team/status/9" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + const result = await adapter.publish( + variant({ channel: "x:team" }), + profile({ HERMES_TWEET_API_KEY: "bearer-token", XQUIK_BASE_URL: "https://example.test/root/" }), + ); + + assert.equal(request.url, "https://example.test/root/api/v1/x/tweets"); + assert.equal(request.init.headers.Authorization, "Bearer bearer-token"); + assert.equal(JSON.parse(request.init.body).account, "team"); + assert.equal(result.live_url, "https://x.com/team/status/9"); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +test("fails clearly when automated publishing lacks an account", async () => { + await withCleanEnv(async () => { + const adapter = new XquikTwitterAdapter(fallback); + const result = await adapter.publish(variant(), profile({ XQUIK_API_KEY: "xq_test" })); + + assert.equal(result.state, "failed"); + assert.match(result.error, /XQUIK_ACCOUNT/); + }); +}); + +test("surfaces Hermes Tweet API errors without throwing", async () => { + await withCleanEnv(async () => { + const adapter = new XquikTwitterAdapter(fallback); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(JSON.stringify({ error: "rate limited" }), { status: 429 }); + + try { + const result = await adapter.publish( + variant(), + profile({ XQUIK_API_KEY: "xq_test", XQUIK_ACCOUNT: "@launch" }), + ); + + assert.equal(result.state, "failed"); + assert.match(result.error, /429/); + assert.match(result.error, /rate limited/); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +test("converts network failures into failed publish results", async () => { + await withCleanEnv(async () => { + const adapter = new XquikTwitterAdapter(fallback); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + throw new TypeError("connection reset"); + }; + + try { + const result = await adapter.publish( + variant(), + profile({ XQUIK_API_KEY: "xq_test", XQUIK_ACCOUNT: "@launch" }), + ); + + assert.equal(result.channel, "twitter"); + assert.equal(result.state, "failed"); + assert.match(result.error, /connection reset/); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +test("trims configured credentials and preserves base URL paths", async () => { + await withCleanEnv(async () => { + const config = getXquikConfig( + profile({ XQUIK_API_KEY: " xq_test ", XQUIK_ACCOUNT: " @launch " }), + variant(), + ); + + assert.equal(config.apiKey, "xq_test"); + assert.equal(config.account, "@launch"); + assert.equal(buildXquikUrl("https://example.test/root/"), "https://example.test/root/api/v1/x/tweets"); + assert.deepEqual(buildXquikHeaders("token").Authorization, "Bearer token"); + }); +});