diff --git a/apps/mcp-linkedin/tsconfig.json b/apps/mcp-linkedin/tsconfig.json new file mode 100644 index 0000000..596e2cf --- /dev/null +++ b/apps/mcp-linkedin/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/apps/scheduler/src/dispatch.test.ts b/apps/scheduler/src/dispatch.test.ts new file mode 100644 index 0000000..b2d637b --- /dev/null +++ b/apps/scheduler/src/dispatch.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from "vitest"; +import { makeOutletDispatcher } from "./dispatch"; + +const target = (outlet: string) => ({ id: 1, outlet, content_id: 1 }); +const ok = (id: string) => async () => ({ externalId: id, externalUrl: `https://x/${id}` }); + +describe("makeOutletDispatcher", () => { + it("routes linkedin.* to the linkedin dispatcher", async () => { + const linkedin = vi.fn(ok("li-1")); + const d = makeOutletDispatcher({ linkedin }); + expect(await d(target("linkedin.org"))).toEqual({ + externalId: "li-1", + externalUrl: "https://x/li-1", + }); + await d(target("linkedin.member")); + expect(linkedin).toHaveBeenCalledTimes(2); + }); + + it("routes twitter to the twitter dispatcher", async () => { + const twitter = vi.fn(ok("tw-1")); + const d = makeOutletDispatcher({ twitter }); + expect(await d(target("twitter"))).toEqual({ + externalId: "tw-1", + externalUrl: "https://x/tw-1", + }); + }); + + it("fails closed when the target outlet dispatcher is not configured", async () => { + const d = makeOutletDispatcher({}); + await expect(d(target("linkedin.org"))).rejects.toThrow(/not configured/); + await expect(d(target("twitter"))).rejects.toThrow(/not configured/); + }); + + it("throws for unknown outlets", async () => { + const d = makeOutletDispatcher({ linkedin: ok("x") }); + await expect(d(target("mastodon"))).rejects.toThrow(/no dispatcher/); + }); +}); diff --git a/apps/scheduler/src/dispatch.ts b/apps/scheduler/src/dispatch.ts new file mode 100644 index 0000000..8736dfa --- /dev/null +++ b/apps/scheduler/src/dispatch.ts @@ -0,0 +1,30 @@ +import type { Dispatcher, DispatchTarget, DispatchResult } from "./worker"; + +export interface OutletDispatchers { + linkedin?: Dispatcher; + twitter?: Dispatcher; +} + +/** + * Routes a publish target to the right outlet dispatcher by its `outlet` + * string (linkedin.member / linkedin.org → linkedin; twitter → twitter). + * Unconfigured outlets throw (fail-closed) so a missing integration is recorded + * as an error rather than silently dropped. + */ +export function makeOutletDispatcher(map: OutletDispatchers): Dispatcher { + return async (target: DispatchTarget): Promise => { + if (target.outlet.startsWith("linkedin")) { + if (!map.linkedin) { + throw new Error(`linkedin dispatch not configured (outlet=${target.outlet})`); + } + return map.linkedin(target); + } + if (target.outlet === "twitter") { + if (!map.twitter) { + throw new Error(`twitter dispatch not configured (outlet=${target.outlet})`); + } + return map.twitter(target); + } + throw new Error(`no dispatcher configured for outlet ${target.outlet}`); + }; +} diff --git a/apps/scheduler/src/main.ts b/apps/scheduler/src/main.ts index d114aa4..bf7fb57 100644 --- a/apps/scheduler/src/main.ts +++ b/apps/scheduler/src/main.ts @@ -6,7 +6,8 @@ */ import { createDb } from "@stargue/schema/client"; import { makeRedisConnection } from "./queue"; -import { makePublishWorker, type Dispatcher } from "./worker"; +import { makePublishWorker } from "./worker"; +import { makeOutletDispatcher } from "./dispatch"; const DATABASE_URL = process.env.DATABASE_URL; const REDIS_URL = process.env.REDIS_URL; @@ -17,16 +18,24 @@ const { db, sql } = createDb(DATABASE_URL); const connection = makeRedisConnection(REDIS_URL); /** - * Gate 0.9: the real LinkedIn dispatcher is wired once Community Management API - * access is granted. Until then the worker boots and consumes jobs but refuses - * to dispatch — surfacing a clear, audited failure rather than silently - * dropping or fake-publishing. + * Outlet dispatchers are wired once their credentials exist: + * - linkedin: Community Management API access (Gate 0.9 / CMA in review) + * - twitter: an X developer app with write-tier access (not yet registered) + * Until then each fails closed — the worker boots and consumes jobs but records + * a clear, audited failure rather than silently dropping or fake-publishing. */ -const dispatch: Dispatcher = async (target) => { - throw new Error( - `LinkedIn dispatch not yet enabled (Gate 0.9 / CMA pending) — outlet=${target.outlet} publication=${target.id}`, - ); -}; +const dispatch = makeOutletDispatcher({ + linkedin: async (target) => { + throw new Error( + `LinkedIn dispatch not yet enabled (Gate 0.9 / CMA pending) — publication=${target.id}`, + ); + }, + twitter: async (target) => { + throw new Error( + `Twitter/X dispatch not yet enabled (X app not registered) — publication=${target.id}`, + ); + }, +}); const worker = makePublishWorker({ db, connection, dispatch }); worker.on("ready", () => console.log("[scheduler] publish worker ready")); diff --git a/apps/scheduler/tsconfig.json b/apps/scheduler/tsconfig.json new file mode 100644 index 0000000..596e2cf --- /dev/null +++ b/apps/scheduler/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/bun.lock b/bun.lock index 735f1ab..bea14f2 100644 --- a/bun.lock +++ b/bun.lock @@ -119,6 +119,14 @@ "vitest": "^2.1.0", }, }, + "packages/twitter-client": { + "name": "@stargue/twitter-client", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^2.1.0", + }, + }, }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -357,6 +365,8 @@ "@stargue/schema": ["@stargue/schema@workspace:packages/schema"], + "@stargue/twitter-client": ["@stargue/twitter-client@workspace:packages/twitter-client"], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], diff --git a/packages/linkedin-client/tsconfig.json b/packages/linkedin-client/tsconfig.json new file mode 100644 index 0000000..596e2cf --- /dev/null +++ b/packages/linkedin-client/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/packages/observability/tsconfig.json b/packages/observability/tsconfig.json new file mode 100644 index 0000000..596e2cf --- /dev/null +++ b/packages/observability/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/packages/sanitize/tsconfig.json b/packages/sanitize/tsconfig.json new file mode 100644 index 0000000..596e2cf --- /dev/null +++ b/packages/sanitize/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/packages/schema/src/frontmatter.ts b/packages/schema/src/frontmatter.ts index 4d54d09..8f39dbb 100644 --- a/packages/schema/src/frontmatter.ts +++ b/packages/schema/src/frontmatter.ts @@ -5,6 +5,7 @@ export const OutletSchema = z.enum([ "stargue.net", "linkedin.member", "linkedin.org", + "twitter", ]); export type Outlet = z.infer; diff --git a/packages/schema/tsconfig.json b/packages/schema/tsconfig.json new file mode 100644 index 0000000..596e2cf --- /dev/null +++ b/packages/schema/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/packages/twitter-client/package.json b/packages/twitter-client/package.json new file mode 100644 index 0000000..16ef1a7 --- /dev/null +++ b/packages/twitter-client/package.json @@ -0,0 +1,21 @@ +{ + "name": "@stargue/twitter-client", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "lint": "echo 'lint pending'" + }, + "devDependencies": { + "vitest": "^2.1.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/twitter-client/src/client.test.ts b/packages/twitter-client/src/client.test.ts new file mode 100644 index 0000000..0f6ca36 --- /dev/null +++ b/packages/twitter-client/src/client.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from "vitest"; +import { TwitterClient, TwitterApiError } from "./client"; +import { TWEET_MAX_LENGTH } from "./types"; + +const fakeFetch = (status: number, body: unknown): typeof fetch => + vi.fn(async () => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ) as unknown as typeof fetch; + +describe("TwitterClient.createTweet", () => { + it("posts text and returns the tweet id + url", async () => { + const fetchImpl = fakeFetch(201, { data: { id: "1790000000000000000", text: "hi" } }); + const client = new TwitterClient({ + getAccessToken: async () => "token-abc", + fetchImpl, + baseUrl: "https://api.test", + }); + const res = await client.createTweet("hi"); + expect(res.id).toBe("1790000000000000000"); + expect(res.url).toContain("1790000000000000000"); + + const call = (fetchImpl as unknown as ReturnType).mock.calls[0]!; + expect(call[0]).toBe("https://api.test/2/tweets"); + expect(call[1].method).toBe("POST"); + expect(call[1].headers.Authorization).toBe("Bearer token-abc"); + expect(JSON.parse(call[1].body)).toEqual({ text: "hi" }); + }); + + it("rejects empty text", async () => { + const client = new TwitterClient({ getAccessToken: async () => "t", fetchImpl: fakeFetch(201, {}) }); + await expect(client.createTweet("")).rejects.toThrow(/empty/); + }); + + it("rejects text over the 280-char limit", async () => { + const client = new TwitterClient({ getAccessToken: async () => "t", fetchImpl: fakeFetch(201, {}) }); + await expect(client.createTweet("x".repeat(TWEET_MAX_LENGTH + 1))).rejects.toThrow(/exceeds/); + }); + + it("throws TwitterApiError on non-2xx", async () => { + const client = new TwitterClient({ + getAccessToken: async () => "t", + fetchImpl: fakeFetch(403, { title: "Forbidden" }), + }); + await expect(client.createTweet("hi")).rejects.toBeInstanceOf(TwitterApiError); + }); +}); diff --git a/packages/twitter-client/src/client.ts b/packages/twitter-client/src/client.ts new file mode 100644 index 0000000..8fe219c --- /dev/null +++ b/packages/twitter-client/src/client.ts @@ -0,0 +1,64 @@ +import { + type CreateTweetRequest, + type CreateTweetResponse, + TWEET_MAX_LENGTH, +} from "./types"; + +export class TwitterApiError extends Error { + constructor( + public readonly status: number, + public readonly body: string, + ) { + super(`Twitter API error ${status}: ${body}`); + this.name = "TwitterApiError"; + } +} + +export interface TwitterClientOptions { + /** Returns a valid OAuth 2.0 user-context access token (user.write/tweet.write). */ + getAccessToken: () => Promise; + /** Injectable for tests; defaults to global fetch. */ + fetchImpl?: typeof fetch; + /** Override for tests / API mocks; defaults to the live X API. */ + baseUrl?: string; +} + +/** + * Minimal X (Twitter) API v2 client for posting our own content. Mirrors the + * shape of @stargue/linkedin-client: a thin, injectable HTTP wrapper. Live use + * requires an X developer app + write-tier access (registered separately). + */ +export class TwitterClient { + private readonly fetchImpl: typeof fetch; + private readonly baseUrl: string; + + constructor(private readonly opts: TwitterClientOptions) { + this.fetchImpl = opts.fetchImpl ?? fetch; + this.baseUrl = opts.baseUrl ?? "https://api.twitter.com"; + } + + async createTweet(text: string): Promise<{ id: string; url: string }> { + if (text.length === 0) throw new Error("tweet text is empty"); + if (text.length > TWEET_MAX_LENGTH) { + throw new Error(`tweet exceeds ${TWEET_MAX_LENGTH} characters (${text.length})`); + } + const token = await this.opts.getAccessToken(); + const body: CreateTweetRequest = { text }; + const res = await this.fetchImpl(`${this.baseUrl}/2/tweets`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new TwitterApiError(res.status, await res.text().catch(() => "")); + } + const json = (await res.json()) as CreateTweetResponse; + return { + id: json.data.id, + url: `https://twitter.com/i/web/status/${json.data.id}`, + }; + } +} diff --git a/packages/twitter-client/src/index.ts b/packages/twitter-client/src/index.ts new file mode 100644 index 0000000..c9240f8 --- /dev/null +++ b/packages/twitter-client/src/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./client"; diff --git a/packages/twitter-client/src/types.ts b/packages/twitter-client/src/types.ts new file mode 100644 index 0000000..3afdaf1 --- /dev/null +++ b/packages/twitter-client/src/types.ts @@ -0,0 +1,14 @@ +/** X API v2 — Manage Tweets (POST /2/tweets). */ + +export interface CreateTweetRequest { + text: string; + /** Optional: reply settings, media ids, etc. — added as the integration grows. */ + reply?: { in_reply_to_tweet_id: string }; +} + +export interface CreateTweetResponse { + data: { id: string; text: string }; +} + +/** X enforces a 280-character limit on standard tweets. */ +export const TWEET_MAX_LENGTH = 280; diff --git a/packages/twitter-client/tsconfig.json b/packages/twitter-client/tsconfig.json new file mode 100644 index 0000000..596e2cf --- /dev/null +++ b/packages/twitter-client/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +}