diff --git a/README.md b/README.md index ac0c55f..f7d5fd8 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/SKILL.md b/SKILL.md index 037a812..c871216 100644 --- a/SKILL.md +++ b/SKILL.md @@ -23,11 +23,11 @@ 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 @@ -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.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..9755b58 --- /dev/null +++ b/src/adapters/xquik-twitter.ts @@ -0,0 +1,184 @@ +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); + } +} + +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", + }; + } + + const 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), + }), + }); + 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..94ff90d --- /dev/null +++ b/test/xquik-twitter.test.mjs @@ -0,0 +1,190 @@ +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("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"); + }); +});