Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 72 additions & 13 deletions discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DiscordChannel[]>;
fetchMessages(
channelId: string,
limit?: number,
): Promise<DiscordMessage[]>;
sendMessage(
channelId: string,
body: BodyInit,
contentType: string,
): Promise<SendMessageResponse>;
}

/** Minimal fetch signature — avoids Bun-specific `preconnect` property on `typeof fetch`. */
Expand All @@ -46,28 +58,49 @@ export function createDiscordClient(
botToken: string,
fetchFn: FetchFn = fetch,
): DiscordClient {
async function discordFetch(path: string): Promise<Response> {
/**
* 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<Response> {
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<string, string> = {
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;
Expand Down Expand Up @@ -98,5 +131,31 @@ export function createDiscordClient(
}
return res.json() as Promise<DiscordMessage[]>;
},

async sendMessage(
channelId: string,
body: BodyInit,
contentType: string,
): Promise<SendMessageResponse> {
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,
};
},
};
}
54 changes: 50 additions & 4 deletions index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<Response> {
const url = new URL(req.url);

// Health endpoint — includes cache stats
Expand Down Expand Up @@ -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];

Expand Down Expand Up @@ -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 });
};
}
Expand All @@ -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,
Expand Down
Loading
Loading