diff --git a/discord.ts b/discord.ts index 50c0a21..6b143c4 100644 --- a/discord.ts +++ b/discord.ts @@ -22,12 +22,24 @@ export interface DiscordMessage { [key: string]: unknown; } +export interface SendMessageResponse { + ok: boolean; + status: number; + headers: Headers; + body: unknown; +} + export interface DiscordClient { fetchChannels(guildId: string): Promise; fetchMessages( channelId: string, limit?: number, ): Promise; + sendMessage( + channelId: string, + body: BodyInit, + contentType: string, + ): Promise; } /** Minimal fetch signature — avoids Bun-specific `preconnect` property on `typeof fetch`. */ @@ -46,28 +58,49 @@ export function createDiscordClient( botToken: string, fetchFn: FetchFn = fetch, ): DiscordClient { - async function discordFetch(path: string): Promise { + /** + * Perform an authenticated fetch against the Discord REST API. + * Supports GET (default) and POST with arbitrary body/content-type. + * Handles 429 rate limits with a single retry after Retry-After. + */ + async function discordFetch( + path: string, + options?: { method?: string; body?: BodyInit; contentType?: string }, + ): Promise { const url = `${DISCORD_API_BASE}${path}`; - const res = await fetchFn(url, { - headers: { - Authorization: `Bot ${botToken}`, - "Content-Type": "application/json", - }, - }); + const method = options?.method ?? "GET"; + + const headers: Record = { + Authorization: `Bot ${botToken}`, + }; + // For GET requests or when no explicit content-type, default to application/json. + // For POST with an explicit content-type (e.g. multipart/form-data), use that instead. + if (options?.contentType) { + headers["Content-Type"] = options.contentType; + } else { + headers["Content-Type"] = "application/json"; + } + + const init: RequestInit = { method, headers }; + if (options?.body !== undefined) { + init.body = options.body; + } + + const res = await fetchFn(url, init); // Rate limit handling: respect 429 + Retry-After header if (res.status === 429) { + // Consume the response body to free the connection + await res.text(); const rawRetryAfter = res.headers.get("Retry-After"); const retryAfter = rawRetryAfter !== null ? Number(rawRetryAfter) : 1; const waitMs = Math.max(0, retryAfter) * 1000; + console.warn( + `[discord] 429 rate limited on ${method} ${path} — retrying after ${waitMs}ms`, + ); await new Promise((resolve) => setTimeout(resolve, waitMs)); // Retry once after waiting - return fetchFn(url, { - headers: { - Authorization: `Bot ${botToken}`, - "Content-Type": "application/json", - }, - }); + return fetchFn(url, init); } return res; @@ -98,5 +131,31 @@ export function createDiscordClient( } return res.json() as Promise; }, + + async sendMessage( + channelId: string, + body: BodyInit, + contentType: string, + ): Promise { + const res = await discordFetch(`/channels/${channelId}/messages`, { + method: "POST", + body, + contentType, + }); + + const rawText = await res.text(); + let responseBody: unknown; + try { + responseBody = JSON.parse(rawText); + } catch { + responseBody = rawText; + } + return { + ok: res.ok, + status: res.status, + headers: res.headers, + body: responseBody, + }; + }, }; } diff --git a/index.ts b/index.ts index f5628c5..0480930 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,6 @@ import { loadConfig } from "./config"; import { createDiscordClient } from "./discord"; +import type { DiscordClient, DiscordMessage } from "./discord"; import { createCache } from "./cache"; import type { Cache } from "./cache"; import { initialPoll, startPollingLoop, createLogger } from "./poller"; @@ -9,10 +10,16 @@ const startTime = Date.now(); /** * Create the request handler with access to cache and config. + * When a DiscordClient is provided, write pass-through (POST) routes are enabled. */ -function createHandler(cache: Cache, guildId: string, cacheTtlMs?: number) { +function createHandler( + cache: Cache, + guildId: string, + cacheTtlMs?: number, + client?: DiscordClient, +) { const ttl = cacheTtlMs ?? Infinity; - return function handleRequest(req: Request): Response { + return async function handleRequest(req: Request): Promise { const url = new URL(req.url); // Health endpoint — includes cache stats @@ -55,10 +62,12 @@ function createHandler(cache: Cache, guildId: string, cacheTtlMs?: number) { }); } - // GET /api/v10/channels/{channelId}/messages + // Match /api/v10/channels/{channelId}/messages for both GET and POST const messagesMatch = url.pathname.match( /^\/api\/v10\/channels\/([^/]+)\/messages$/, ); + + // GET /api/v10/channels/{channelId}/messages if (req.method === "GET" && messagesMatch) { const channelId = messagesMatch[1]; @@ -103,6 +112,43 @@ function createHandler(cache: Cache, guildId: string, cacheTtlMs?: number) { }); } + // POST /api/v10/channels/{channelId}/messages — write pass-through + if (req.method === "POST" && messagesMatch) { + if (!client) { + return Response.json( + { error: "Write pass-through is not configured (no Discord client)" }, + { status: 503 }, + ); + } + + const channelId = messagesMatch[1]; + const contentType = req.headers.get("Content-Type") ?? "application/json"; + + // Read the raw body to forward transparently (supports JSON and multipart) + const rawBody = await req.arrayBuffer(); + + const result = await client.sendMessage( + channelId, + rawBody, + contentType, + ); + + // On success, inject the returned message into the cache + if (result.ok) { + const msg = result.body as DiscordMessage; + if (msg && typeof msg.id === "string" && /^\d+$/.test(msg.id)) { + try { + cache.setMessages(channelId, [msg]); + } catch { + // Cache write failure should not break the response + } + } + } + + // Return Discord's response verbatim (status code + body) + return Response.json(result.body, { status: result.status }); + } + return Response.json({ error: "not found" }, { status: 404 }); }; } @@ -124,7 +170,7 @@ if (import.meta.main) { logger, ); - const handler = createHandler(cache, config.discordGuildId, config.cacheTtlMs); + const handler = createHandler(cache, config.discordGuildId, config.cacheTtlMs, client); const server = Bun.serve({ port: config.port, diff --git a/tests/index.test.ts b/tests/index.test.ts index 7cdedac..c5d0b77 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "bun:test"; import { createHandler, VERSION } from "../index"; import { createCache } from "../cache"; -import type { DiscordChannel, DiscordMessage } from "../discord"; +import type { + DiscordChannel, + DiscordClient, + DiscordMessage, + SendMessageResponse, +} from "../discord"; /** * Helper: create a Discord snowflake ID from a timestamp. @@ -40,7 +45,7 @@ describe("GET /health", () => { const cache = createCache(TEST_TTL, TEST_WINDOW); const handler = createHandler(cache, "guild-1"); const req = new Request("http://localhost/health", { method: "GET" }); - const res = handler(req); + const res = await handler(req); expect(res.status).toBe(200); @@ -71,7 +76,7 @@ describe("GET /api/v10/guilds/{guildId}/channels", () => { const req = new Request( "http://localhost/api/v10/guilds/guild-1/channels", ); - const res = handler(req); + const res = await handler(req); expect(res.status).toBe(200); expect(res.headers.get("X-Cache")).toBe("HIT"); @@ -89,7 +94,7 @@ describe("GET /api/v10/guilds/{guildId}/channels", () => { const req = new Request( "http://localhost/api/v10/guilds/unknown-guild/channels", ); - const res = handler(req); + const res = await handler(req); expect(res.status).toBe(404); }); @@ -104,7 +109,7 @@ describe("GET /api/v10/channels/{channelId}/messages", () => { const req = new Request( "http://localhost/api/v10/channels/ch-1/messages", ); - const res = handler(req); + const res = await handler(req); expect(res.status).toBe(400); const body = await res.json(); @@ -118,7 +123,7 @@ describe("GET /api/v10/channels/{channelId}/messages", () => { const req = new Request( "http://localhost/api/v10/channels/ch-1/messages?after=abc", ); - const res = handler(req); + const res = await handler(req); expect(res.status).toBe(400); const body = await res.json(); @@ -137,7 +142,7 @@ describe("GET /api/v10/channels/{channelId}/messages", () => { const req = new Request( `http://localhost/api/v10/channels/ch-1/messages?after=${afterId}&limit=abc`, ); - const res = handler(req); + const res = await handler(req); expect(res.status).toBe(400); const body = await res.json(); @@ -165,7 +170,7 @@ describe("GET /api/v10/channels/{channelId}/messages", () => { const req = new Request( `http://localhost/api/v10/channels/ch-1/messages?after=${id1}`, ); - const res = handler(req); + const res = await handler(req); expect(res.status).toBe(200); expect(res.headers.get("X-Cache")).toBe("HIT"); @@ -194,7 +199,7 @@ describe("GET /api/v10/channels/{channelId}/messages", () => { const req = new Request( `http://localhost/api/v10/channels/ch-1/messages?after=${oldId}`, ); - const res = handler(req); + const res = await handler(req); expect(res.status).toBe(200); const body = await res.json(); @@ -208,7 +213,7 @@ describe("GET /api/v10/channels/{channelId}/messages", () => { const req = new Request( "http://localhost/api/v10/channels/unknown-ch/messages?after=100", ); - const res = handler(req); + const res = await handler(req); expect(res.status).toBe(404); }); @@ -234,7 +239,7 @@ describe("GET /api/v10/channels/{channelId}/messages", () => { const req = new Request( `http://localhost/api/v10/channels/ch-1/messages?after=${afterId}&limit=2`, ); - const res = handler(req); + const res = await handler(req); expect(res.status).toBe(200); const body = (await res.json()) as DiscordMessage[]; @@ -245,12 +250,209 @@ describe("GET /api/v10/channels/{channelId}/messages", () => { }); }); +describe("POST /api/v10/channels/{channelId}/messages", () => { + /** + * Helper: build a mock DiscordClient with a controllable sendMessage. + */ + function mockClient( + sendResponse: SendMessageResponse, + ): DiscordClient & { captured: { channelId: string; body: string; contentType: string } } { + const captured = { channelId: "", body: "", contentType: "" }; + return { + captured, + async fetchChannels() { + return []; + }, + async fetchMessages() { + return []; + }, + async sendMessage(channelId, body, contentType) { + captured.channelId = channelId; + if (body instanceof ArrayBuffer) { + captured.body = new TextDecoder().decode(body); + } else if (typeof body === "string") { + captured.body = body; + } + captured.contentType = contentType; + return sendResponse; + }, + }; + } + + test("forwards POST body to Discord via client and returns response", async () => { + const cache = createCache(TEST_TTL, TEST_WINDOW); + const sentMsg: DiscordMessage = { + id: timestampToSnowflake(Date.now()), + channel_id: "ch-1", + content: "hello from proxy", + timestamp: new Date().toISOString(), + author: { id: "u-1", username: "bot" }, + }; + + const client = mockClient({ + ok: true, + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + body: sentMsg, + }); + + const handler = createHandler(cache, "guild-1", TEST_TTL, client); + const req = new Request( + "http://localhost/api/v10/channels/ch-1/messages", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "hello from proxy" }), + }, + ); + + const res = await handler(req); + + expect(res.status).toBe(200); + const body = (await res.json()) as DiscordMessage; + expect(body.id).toBe(sentMsg.id); + expect(body.content).toBe("hello from proxy"); + expect(client.captured.channelId).toBe("ch-1"); + expect(client.captured.contentType).toBe("application/json"); + }); + + test("injects sent message into cache on success", async () => { + const cache = createCache(TEST_TTL, TEST_WINDOW); + const now = Date.now(); + const msgId = timestampToSnowflake(now); + const sentMsg: DiscordMessage = { + id: msgId, + channel_id: "ch-1", + content: "cached after send", + timestamp: new Date().toISOString(), + author: { id: "u-1", username: "bot" }, + }; + + const client = mockClient({ + ok: true, + status: 200, + headers: new Headers(), + body: sentMsg, + }); + + const handler = createHandler(cache, "guild-1", TEST_TTL, client); + const req = new Request( + "http://localhost/api/v10/channels/ch-1/messages", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "cached after send" }), + }, + ); + + await handler(req); + + // The message should now be in the cache + const afterId = timestampToSnowflake(now - 60_000); + const cached = cache.getMessages("ch-1", afterId); + expect(cached).toBeDefined(); + expect(cached!.data).toHaveLength(1); + expect(cached!.data[0].id).toBe(msgId); + expect(cached!.data[0].content).toBe("cached after send"); + }); + + test("does NOT inject into cache on Discord error", async () => { + const cache = createCache(TEST_TTL, TEST_WINDOW); + const client = mockClient({ + ok: false, + status: 403, + headers: new Headers(), + body: { message: "Missing Permissions", code: 50013 }, + }); + + const handler = createHandler(cache, "guild-1", TEST_TTL, client); + const req = new Request( + "http://localhost/api/v10/channels/ch-1/messages", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "forbidden" }), + }, + ); + + const res = await handler(req); + + expect(res.status).toBe(403); + // Cache should remain empty for this channel + const cached = cache.getMessages("ch-1", "0"); + expect(cached).toBeUndefined(); + }); + + test("forwards multipart/form-data content-type to Discord client", async () => { + const cache = createCache(TEST_TTL, TEST_WINDOW); + const sentMsg: DiscordMessage = { + id: timestampToSnowflake(Date.now()), + channel_id: "ch-1", + content: "with attachment", + timestamp: new Date().toISOString(), + author: { id: "u-1", username: "bot" }, + }; + + const client = mockClient({ + ok: true, + status: 200, + headers: new Headers(), + body: sentMsg, + }); + + const boundary = "----testboundary"; + const multipartBody = + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="content"\r\n\r\n` + + `with attachment\r\n` + + `--${boundary}--`; + + const handler = createHandler(cache, "guild-1", TEST_TTL, client); + const req = new Request( + "http://localhost/api/v10/channels/ch-1/messages", + { + method: "POST", + headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` }, + body: multipartBody, + }, + ); + + const res = await handler(req); + + expect(res.status).toBe(200); + expect(client.captured.contentType).toBe( + `multipart/form-data; boundary=${boundary}`, + ); + expect(client.captured.body).toContain("with attachment"); + }); + + test("returns 503 when no Discord client is configured", async () => { + const cache = createCache(TEST_TTL, TEST_WINDOW); + // No client passed — write pass-through disabled + const handler = createHandler(cache, "guild-1", TEST_TTL); + const req = new Request( + "http://localhost/api/v10/channels/ch-1/messages", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "test" }), + }, + ); + + const res = await handler(req); + + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.error).toContain("not configured"); + }); +}); + describe("unknown routes", () => { test("returns 404 for unknown path", async () => { const cache = createCache(TEST_TTL, TEST_WINDOW); const handler = createHandler(cache, "guild-1"); const req = new Request("http://localhost/unknown", { method: "GET" }); - const res = handler(req); + const res = await handler(req); expect(res.status).toBe(404); diff --git a/tests/poller.test.ts b/tests/poller.test.ts index 38c141f..ae971ca 100644 --- a/tests/poller.test.ts +++ b/tests/poller.test.ts @@ -20,6 +20,9 @@ function makeMockClient( async fetchMessages(channelId: string): Promise { return messagesByChannel[channelId] ?? []; }, + async sendMessage() { + return { ok: true, status: 200, headers: new Headers(), body: {} }; + }, }; } @@ -67,6 +70,9 @@ describe("initialPoll", () => { async fetchMessages(): Promise { throw new Error("Network error"); }, + async sendMessage() { + return { ok: true, status: 200, headers: new Headers(), body: {} }; + }, }; const cache = createCache(60_000, 4 * 60 * 60 * 1000); @@ -103,6 +109,9 @@ describe("initialPoll", () => { }, ]; }, + async sendMessage() { + return { ok: true, status: 200, headers: new Headers(), body: {} }; + }, }; const cache = createCache(60_000, 4 * 60 * 60 * 1000); diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts new file mode 100644 index 0000000..bdd673e --- /dev/null +++ b/tests/proxy.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; +import { createDiscordClient } from "../discord"; +import type { + FetchFn, + DiscordMessage, + SendMessageResponse, +} from "../discord"; + +/** + * Helper: build a fake Discord message response. + */ +function fakeMessage( + channelId: string, + content: string, + id = "msg-new-1", +): DiscordMessage { + return { + id, + channel_id: channelId, + content, + timestamp: new Date().toISOString(), + author: { id: "u-1", username: "bot" }, + }; +} + +describe("sendMessage — POST proxied to Discord", () => { + test("forwards POST body and content-type to Discord", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + let capturedBody = ""; + let capturedContentType = ""; + + const responseMsg = fakeMessage("ch-1", "hello world"); + + const fakeFetch: FetchFn = async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + capturedUrl = url; + capturedMethod = init?.method ?? "GET"; + capturedContentType = + new Headers(init?.headers ?? {}).get("Content-Type") ?? ""; + // Read the body from the request init + if (init?.body instanceof ArrayBuffer) { + capturedBody = new TextDecoder().decode(init.body); + } else if (typeof init?.body === "string") { + capturedBody = init.body; + } + + return new Response(JSON.stringify(responseMsg), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const client = createDiscordClient("test-token", fakeFetch); + const body = JSON.stringify({ content: "hello world" }); + const result = await client.sendMessage( + "ch-1", + body, + "application/json", + ); + + expect(capturedUrl).toContain("/channels/ch-1/messages"); + expect(capturedMethod).toBe("POST"); + expect(capturedContentType).toBe("application/json"); + expect(capturedBody).toBe(body); + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + }); + + test("returns Discord's response verbatim", async () => { + const responseMsg = fakeMessage("ch-1", "response content", "msg-42"); + + const fakeFetch: FetchFn = async () => { + return new Response(JSON.stringify(responseMsg), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const client = createDiscordClient("test-token", fakeFetch); + const result = await client.sendMessage( + "ch-1", + JSON.stringify({ content: "test" }), + "application/json", + ); + + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + const body = result.body as DiscordMessage; + expect(body.id).toBe("msg-42"); + expect(body.content).toBe("response content"); + expect(body.channel_id).toBe("ch-1"); + }); + + test("returns non-ok response from Discord without throwing", async () => { + const fakeFetch: FetchFn = async () => { + return new Response( + JSON.stringify({ message: "Missing Permissions", code: 50013 }), + { + status: 403, + statusText: "Forbidden", + headers: { "Content-Type": "application/json" }, + }, + ); + }; + + const client = createDiscordClient("test-token", fakeFetch); + const result = await client.sendMessage( + "ch-1", + JSON.stringify({ content: "test" }), + "application/json", + ); + + expect(result.ok).toBe(false); + expect(result.status).toBe(403); + const body = result.body as { message: string; code: number }; + expect(body.message).toBe("Missing Permissions"); + }); + + test("forwards multipart/form-data body without modification", async () => { + let capturedContentType = ""; + let capturedBodyText = ""; + + const responseMsg = fakeMessage("ch-1", "file uploaded"); + + const fakeFetch: FetchFn = async (_input, init) => { + capturedContentType = + new Headers(init?.headers ?? {}).get("Content-Type") ?? ""; + if (init?.body instanceof ArrayBuffer) { + capturedBodyText = new TextDecoder().decode(init.body); + } else if (typeof init?.body === "string") { + capturedBodyText = init.body; + } + + return new Response(JSON.stringify(responseMsg), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const client = createDiscordClient("test-token", fakeFetch); + + // Simulate multipart body as raw bytes + const multipartBoundary = "----boundary123"; + const multipartBody = + `--${multipartBoundary}\r\n` + + `Content-Disposition: form-data; name="content"\r\n\r\n` + + `file uploaded\r\n` + + `--${multipartBoundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="test.txt"\r\n` + + `Content-Type: text/plain\r\n\r\n` + + `file contents here\r\n` + + `--${multipartBoundary}--`; + + const contentType = `multipart/form-data; boundary=${multipartBoundary}`; + const result = await client.sendMessage( + "ch-1", + multipartBody, + contentType, + ); + + expect(capturedContentType).toBe(contentType); + expect(result.ok).toBe(true); + // Verify the body was forwarded without modification + expect(capturedBodyText).toContain("file contents here"); + expect(capturedBodyText).toContain(multipartBoundary); + }); + + test("retries on 429 rate limit for POST requests", async () => { + let callCount = 0; + const responseMsg = fakeMessage("ch-1", "after retry"); + + const fakeFetch: FetchFn = async () => { + callCount++; + if (callCount === 1) { + return new Response( + JSON.stringify({ message: "Rate limited" }), + { + status: 429, + headers: { + "Retry-After": "0.01", + "Content-Type": "application/json", + }, + }, + ); + } + return new Response(JSON.stringify(responseMsg), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const client = createDiscordClient("test-token", fakeFetch); + const result = await client.sendMessage( + "ch-1", + JSON.stringify({ content: "test" }), + "application/json", + ); + + expect(callCount).toBe(2); + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + const body = result.body as DiscordMessage; + expect(body.content).toBe("after retry"); + }); +});