X/Twitter structural support + per-package tsconfig scoping
X/Twitter (live posting gated on an X developer app, registered separately): - schema: add 'twitter' to OutletSchema (sanitize 280-char limit already present). - packages/twitter-client: X API v2 client (POST /2/tweets, OAuth2 user Bearer), injectable fetch, 280-char guard; 4 contract tests. - apps/scheduler/src/dispatch.ts: makeOutletDispatcher routes by outlet (linkedin.* -> linkedin, twitter -> twitter), fail-closed for unconfigured outlets; 4 tests. main.ts wires both outlets fail-closed until creds exist. Monorepo fix: add a local tsconfig.json (extends root, include src) to each package + non-admin app. Without it, every package's `tsc --noEmit` compiled the WHOLE repo via the root config (no JSX/DOM) and choked on the admin .tsx — masked until now by turbo build caching. Tests: 135 passing + 13 gated across 12 tasks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export const OutletSchema = z.enum([
|
||||
"stargue.net",
|
||||
"linkedin.member",
|
||||
"linkedin.org",
|
||||
"twitter",
|
||||
]);
|
||||
export type Outlet = z.infer<typeof OutletSchema>;
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<typeof vi.fn>).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);
|
||||
});
|
||||
});
|
||||
@@ -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<string>;
|
||||
/** 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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./types";
|
||||
export * from "./client";
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user