diff --git a/cache.ts b/cache.ts new file mode 100644 index 0000000..405dfc8 --- /dev/null +++ b/cache.ts @@ -0,0 +1,186 @@ +/** + * In-memory cache for Discord API responses. + * + * - Messages keyed by channel ID, channels keyed by guild ID + * - Rolling window eviction: messages older than cacheWindowMs are evicted + * - TTL-based staleness for cache hit/miss reporting + * - `after` filtering via snowflake ID comparison + */ + +import type { DiscordChannel, DiscordMessage } from "./discord"; + +export interface CacheStats { + channelsCached: number; + totalMessages: number; + hits: number; + misses: number; +} + +interface CacheEntry { + data: T; + cachedAt: number; +} + +/** + * Discord snowflake IDs encode a timestamp. Extract the timestamp (ms since epoch) + * from a snowflake string. Discord epoch is 2015-01-01T00:00:00.000Z. + */ +const DISCORD_EPOCH = 1420070400000n; + +export function snowflakeToTimestamp(snowflake: string): number { + const id = BigInt(snowflake); + return Number((id >> 22n) + DISCORD_EPOCH); +} + +export interface Cache { + /** Store channels for a guild */ + setChannels(guildId: string, channels: DiscordChannel[]): void; + /** Get channels for a guild. Returns undefined on miss. */ + getChannels(guildId: string): { data: DiscordChannel[]; cachedAt: number } | undefined; + + /** Store messages for a channel, evicting those older than the cache window */ + setMessages(channelId: string, messages: DiscordMessage[]): void; + /** + * Get messages for a channel filtered by `after` snowflake. + * Returns undefined if the channel is not cached at all. + * Returns empty array if `after` is older than the cache window. + */ + getMessages( + channelId: string, + after: string, + limit?: number, + ): { data: DiscordMessage[]; cachedAt: number } | undefined; + + /** Get cache statistics */ + getStats(): CacheStats; + + /** Run eviction pass — remove messages older than the cache window */ + evict(): void; +} + +export function createCache(cacheTtlMs: number, cacheWindowMs: number): Cache { + const channelCache = new Map>(); + const messageCache = new Map>(); + let hits = 0; + let misses = 0; + + function evictMessages(messages: DiscordMessage[]): DiscordMessage[] { + const cutoff = Date.now() - cacheWindowMs; + return messages.filter((msg) => { + const ts = snowflakeToTimestamp(msg.id); + return ts >= cutoff; + }); + } + + return { + setChannels(guildId: string, channels: DiscordChannel[]): void { + channelCache.set(guildId, { data: channels, cachedAt: Date.now() }); + }, + + getChannels( + guildId: string, + ): { data: DiscordChannel[]; cachedAt: number } | undefined { + const entry = channelCache.get(guildId); + if (!entry) { + misses++; + return undefined; + } + const age = Date.now() - entry.cachedAt; + if (age > cacheTtlMs) { + misses++; + } else { + hits++; + } + return { data: entry.data, cachedAt: entry.cachedAt }; + }, + + setMessages(channelId: string, messages: DiscordMessage[]): void { + const existing = messageCache.get(channelId); + let merged: DiscordMessage[]; + + if (existing) { + // Merge: new messages take priority, deduplicate by ID + const byId = new Map(); + for (const msg of existing.data) { + byId.set(msg.id, msg); + } + for (const msg of messages) { + byId.set(msg.id, msg); + } + merged = Array.from(byId.values()); + } else { + merged = [...messages]; + } + + // Evict old messages and sort ascending by ID (snowflake order) + merged = evictMessages(merged); + merged.sort((a, b) => { + if (a.id === b.id) return 0; + return BigInt(a.id) < BigInt(b.id) ? -1 : 1; + }); + + messageCache.set(channelId, { data: merged, cachedAt: Date.now() }); + }, + + getMessages( + channelId: string, + after: string, + limit?: number, + ): { data: DiscordMessage[]; cachedAt: number } | undefined { + const entry = messageCache.get(channelId); + if (!entry) { + misses++; + return undefined; + } + + const age = Date.now() - entry.cachedAt; + if (age > cacheTtlMs) { + misses++; + } else { + hits++; + } + + // If `after` points to a message older than the cache window, return [] + const afterTs = snowflakeToTimestamp(after); + const windowStart = Date.now() - cacheWindowMs; + if (afterTs < windowStart) { + return { data: [], cachedAt: entry.cachedAt }; + } + + // Filter to only messages with snowflake ID > after + const afterBigInt = BigInt(after); + let filtered = entry.data.filter((msg) => BigInt(msg.id) > afterBigInt); + + // Apply limit (return newest N, matching Discord API behavior) + if (limit !== undefined && limit > 0 && filtered.length > limit) { + filtered = filtered.slice(filtered.length - limit); + } + + return { data: filtered, cachedAt: entry.cachedAt }; + }, + + getStats(): CacheStats { + let totalMessages = 0; + for (const entry of messageCache.values()) { + totalMessages += entry.data.length; + } + return { + channelsCached: channelCache.size, + totalMessages, + hits, + misses, + }; + }, + + evict(): void { + for (const [channelId, entry] of messageCache.entries()) { + const filtered = evictMessages(entry.data); + if (filtered.length === 0) { + messageCache.delete(channelId); + } else { + entry.data = filtered; + } + } + }, + }; +} diff --git a/config.ts b/config.ts index 22b341a..729ef23 100644 --- a/config.ts +++ b/config.ts @@ -11,6 +11,10 @@ export interface Config { discordGuildId: string; /** Polling interval in milliseconds */ pollIntervalMs: number; + /** Cache TTL in milliseconds — derived from pollIntervalMs * 1.5 */ + cacheTtlMs: number; + /** Cache window in milliseconds — messages older than this are evicted */ + cacheWindowMs: number; /** HTTP server port */ port: number; /** Log level */ @@ -60,6 +64,8 @@ export function loadConfig( } const pollIntervalMs = Number(env.POLL_INTERVAL_MS) || 15000; + const cacheTtlMs = Math.floor(pollIntervalMs * 1.5); + const cacheWindowMs = Number(env.CACHE_WINDOW_MS) || 4 * 60 * 60 * 1000; // 4 hours const port = Number(env.PORT) || 3000; const rawLogLevel = (env.LOG_LEVEL ?? "info").toLowerCase(); @@ -71,6 +77,8 @@ export function loadConfig( discordBotToken, discordGuildId, pollIntervalMs, + cacheTtlMs, + cacheWindowMs, port, logLevel, }; diff --git a/discord.ts b/discord.ts new file mode 100644 index 0000000..50c0a21 --- /dev/null +++ b/discord.ts @@ -0,0 +1,102 @@ +/** + * Discord REST API client — authenticated fetch wrapper with rate limit handling. + */ + +const DISCORD_API_BASE = "https://discord.com/api/v10"; + +export interface DiscordChannel { + id: string; + type: number; + name?: string; + guild_id?: string; + position?: number; + [key: string]: unknown; +} + +export interface DiscordMessage { + id: string; + channel_id: string; + content: string; + timestamp: string; + author: { id: string; username: string; [key: string]: unknown }; + [key: string]: unknown; +} + +export interface DiscordClient { + fetchChannels(guildId: string): Promise; + fetchMessages( + channelId: string, + limit?: number, + ): Promise; +} + +/** Minimal fetch signature — avoids Bun-specific `preconnect` property on `typeof fetch`. */ +export type FetchFn = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +/** + * Create a Discord REST client with authenticated fetch and rate limit handling. + * + * @param botToken - Discord bot token + * @param fetchFn - Injectable fetch function (defaults to global fetch) + */ +export function createDiscordClient( + botToken: string, + fetchFn: FetchFn = fetch, +): DiscordClient { + async function discordFetch(path: string): Promise { + const url = `${DISCORD_API_BASE}${path}`; + const res = await fetchFn(url, { + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + }); + + // Rate limit handling: respect 429 + Retry-After header + if (res.status === 429) { + const rawRetryAfter = res.headers.get("Retry-After"); + const retryAfter = rawRetryAfter !== null ? Number(rawRetryAfter) : 1; + const waitMs = Math.max(0, retryAfter) * 1000; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + // Retry once after waiting + return fetchFn(url, { + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + }); + } + + return res; + } + + return { + async fetchChannels(guildId: string): Promise { + const res = await discordFetch(`/guilds/${guildId}/channels`); + if (!res.ok) { + throw new Error( + `Failed to fetch channels for guild ${guildId}: ${res.status} ${res.statusText}`, + ); + } + return res.json() as Promise; + }, + + async fetchMessages( + channelId: string, + limit = 50, + ): Promise { + const res = await discordFetch( + `/channels/${channelId}/messages?limit=${limit}`, + ); + if (!res.ok) { + throw new Error( + `Failed to fetch messages for channel ${channelId}: ${res.status} ${res.statusText}`, + ); + } + return res.json() as Promise; + }, + }; +} diff --git a/index.ts b/index.ts index 952b23b..f5628c5 100644 --- a/index.ts +++ b/index.ts @@ -1,36 +1,143 @@ import { loadConfig } from "./config"; +import { createDiscordClient } from "./discord"; +import { createCache } from "./cache"; +import type { Cache } from "./cache"; +import { initialPoll, startPollingLoop, createLogger } from "./poller"; -const VERSION = "0.1.0"; +const VERSION = "0.2.0"; const startTime = Date.now(); /** - * Handle incoming HTTP requests. - * Currently only serves the health endpoint; Discord proxy routes will be added later. + * Create the request handler with access to cache and config. */ -function handleRequest(req: Request): Response { - const url = new URL(req.url); - - if (req.method === "GET" && url.pathname === "/health") { - return Response.json({ - status: "ok", - uptime: Math.floor((Date.now() - startTime) / 1000), - version: VERSION, - }); - } - - return Response.json({ error: "not found" }, { status: 404 }); +function createHandler(cache: Cache, guildId: string, cacheTtlMs?: number) { + const ttl = cacheTtlMs ?? Infinity; + return function handleRequest(req: Request): Response { + const url = new URL(req.url); + + // Health endpoint — includes cache stats + if (req.method === "GET" && url.pathname === "/health") { + const stats = cache.getStats(); + return Response.json({ + status: "ok", + uptime: Math.floor((Date.now() - startTime) / 1000), + version: VERSION, + cache: { + channelsCached: stats.channelsCached, + totalMessages: stats.totalMessages, + hits: stats.hits, + misses: stats.misses, + }, + }); + } + + // GET /api/v10/guilds/{guildId}/channels + const channelsMatch = url.pathname.match( + /^\/api\/v10\/guilds\/([^/]+)\/channels$/, + ); + if (req.method === "GET" && channelsMatch) { + const reqGuildId = channelsMatch[1]; + const result = cache.getChannels(reqGuildId); + if (!result) { + return Response.json( + { error: `No cached data for guild ${reqGuildId}` }, + { status: 404 }, + ); + } + const channelFresh = Date.now() - result.cachedAt <= ttl; + return new Response(JSON.stringify(result.data), { + status: 200, + headers: { + "Content-Type": "application/json", + "X-Cache": channelFresh ? "HIT" : "STALE", + "X-Cached-At": new Date(result.cachedAt).toISOString(), + }, + }); + } + + // GET /api/v10/channels/{channelId}/messages + const messagesMatch = url.pathname.match( + /^\/api\/v10\/channels\/([^/]+)\/messages$/, + ); + if (req.method === "GET" && messagesMatch) { + const channelId = messagesMatch[1]; + + // `after` is REQUIRED and must be a valid snowflake (numeric string) + const after = url.searchParams.get("after"); + if (!after || !/^\d+$/.test(after)) { + return Response.json( + { error: "`after` query parameter is required and must be a valid snowflake ID (numeric string)" }, + { status: 400 }, + ); + } + + const limitParam = url.searchParams.get("limit"); + let limit: number | undefined; + if (limitParam !== null) { + const parsed = Number(limitParam); + if (!Number.isInteger(parsed) || parsed <= 0) { + return Response.json( + { error: "`limit` must be a positive integer" }, + { status: 400 }, + ); + } + limit = parsed; + } + + const result = cache.getMessages(channelId, after, limit); + if (!result) { + return Response.json( + { error: `No cached data for channel ${channelId}` }, + { status: 404 }, + ); + } + + const msgFresh = Date.now() - result.cachedAt <= ttl; + return new Response(JSON.stringify(result.data), { + status: 200, + headers: { + "Content-Type": "application/json", + "X-Cache": msgFresh ? "HIT" : "STALE", + "X-Cached-At": new Date(result.cachedAt).toISOString(), + }, + }); + } + + return Response.json({ error: "not found" }, { status: 404 }); + }; } // Only start the server when run directly (not during tests importing this module) if (import.meta.main) { const config = loadConfig(); + const client = createDiscordClient(config.discordBotToken); + const cache = createCache(config.cacheTtlMs, config.cacheWindowMs); + const logger = createLogger(config.logLevel); + + logger.info(`scream-hole v${VERSION} starting...`); + + // Initial poll with timeout — if it fails, start with empty cache + const channelCount = await initialPoll( + client, + cache, + config.discordGuildId, + logger, + ); + + const handler = createHandler(cache, config.discordGuildId, config.cacheTtlMs); const server = Bun.serve({ port: config.port, - fetch: handleRequest, + fetch: handler, }); - console.log(`scream-hole v${VERSION} listening on port ${server.port}`); + // Start the continuous polling loop + const poller = startPollingLoop(client, cache, config); + + const intervalSec = (config.pollIntervalMs / 1000).toFixed(1); + logger.info( + `scream-hole listening on :${server.port}, polling ${channelCount} channels every ${intervalSec}s`, + ); } -export { handleRequest, VERSION }; +export { createHandler, VERSION }; diff --git a/poller.ts b/poller.ts new file mode 100644 index 0000000..c73a47f --- /dev/null +++ b/poller.ts @@ -0,0 +1,190 @@ +/** + * Polling loop — fetches channels and messages from Discord and populates the cache. + * + * - On startup: fetch channel list, then messages for each text channel + * - Every POLL_INTERVAL_MS: refresh channels and messages + * - Timeout: 10s per channel fetch, 30s total for initial poll + * - Errors are logged and continued — never crashes the process + */ + +import type { Config, LogLevel } from "./config"; +import type { DiscordClient, DiscordChannel } from "./discord"; +import type { Cache } from "./cache"; + +// Discord channel type 0 = GUILD_TEXT +const GUILD_TEXT_CHANNEL = 0; + +const PER_CHANNEL_TIMEOUT_MS = 10_000; +const INITIAL_POLL_TIMEOUT_MS = 30_000; + +export interface Logger { + debug(msg: string): void; + info(msg: string): void; + warn(msg: string): void; + error(msg: string): void; +} + +function createLogger(level: LogLevel): Logger { + const levels: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, + }; + const threshold = levels[level]; + + function log(lvl: LogLevel, msg: string): void { + if (levels[lvl] >= threshold) { + const ts = new Date().toISOString(); + console.log(`[${ts}] [${lvl.toUpperCase()}] ${msg}`); + } + } + + return { + debug: (msg: string) => log("debug", msg), + info: (msg: string) => log("info", msg), + warn: (msg: string) => log("warn", msg), + error: (msg: string) => log("error", msg), + }; +} + +/** + * Wrap a promise with a timeout. Resolves to the promise result, or rejects + * with a timeout error after `ms` milliseconds. + */ +function withTimeout(promise: Promise, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Timeout after ${ms}ms: ${label}`)); + }, ms); + + promise + .then((val) => { + clearTimeout(timer); + resolve(val); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +/** + * Run a single poll cycle: fetch channels, then fetch messages for each text channel. + * Returns the number of text channels polled. + */ +async function pollCycle( + client: DiscordClient, + cache: Cache, + guildId: string, + logger: Logger, + perChannelTimeout: number, +): Promise { + // Fetch channels + const channels = await withTimeout( + client.fetchChannels(guildId), + perChannelTimeout, + `fetchChannels(${guildId})`, + ); + cache.setChannels(guildId, channels); + + // Filter to text channels only + const textChannels = channels.filter( + (ch: DiscordChannel) => ch.type === GUILD_TEXT_CHANNEL, + ); + logger.debug(`Found ${textChannels.length} text channels in guild ${guildId}`); + + // Fetch messages for each text channel + for (const channel of textChannels) { + try { + const messages = await withTimeout( + client.fetchMessages(channel.id), + perChannelTimeout, + `fetchMessages(${channel.id})`, + ); + cache.setMessages(channel.id, messages); + logger.debug( + `Cached ${messages.length} messages for #${channel.name ?? channel.id}`, + ); + } catch (err) { + logger.error( + `Failed to fetch messages for channel ${channel.id}: ${err instanceof Error ? err.message : String(err)}`, + ); + // Continue to next channel — don't crash + } + } + + // Run eviction after populating + cache.evict(); + + return textChannels.length; +} + +/** + * Run the initial poll with a total timeout of INITIAL_POLL_TIMEOUT_MS. + * If the timeout fires, the server starts with an empty (or partial) cache. + */ +export async function initialPoll( + client: DiscordClient, + cache: Cache, + guildId: string, + logger: Logger, +): Promise { + try { + const channelCount = await withTimeout( + pollCycle(client, cache, guildId, logger, PER_CHANNEL_TIMEOUT_MS), + INITIAL_POLL_TIMEOUT_MS, + "initial poll", + ); + logger.info(`Initial poll complete: ${channelCount} text channels cached`); + return channelCount; + } catch (err) { + logger.warn( + `Initial poll timed out or failed: ${err instanceof Error ? err.message : String(err)}. Starting with empty/partial cache.`, + ); + return 0; + } +} + +/** + * Start the continuous polling loop. Runs indefinitely. + * Returns a cleanup function that stops the loop. + */ +export function startPollingLoop( + client: DiscordClient, + cache: Cache, + config: Config, +): { stop: () => void; logger: Logger } { + const logger = createLogger(config.logLevel); + let timer: ReturnType | null = null; + + timer = setInterval(async () => { + try { + await pollCycle( + client, + cache, + config.discordGuildId, + logger, + PER_CHANNEL_TIMEOUT_MS, + ); + logger.debug("Poll cycle complete"); + } catch (err) { + logger.error( + `Poll cycle failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }, config.pollIntervalMs); + + return { + stop: () => { + if (timer !== null) { + clearInterval(timer); + timer = null; + } + }, + logger, + }; +} + +export { createLogger, withTimeout }; diff --git a/tests/cache.test.ts b/tests/cache.test.ts new file mode 100644 index 0000000..ce37a3f --- /dev/null +++ b/tests/cache.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, test } from "bun:test"; +import { createCache, snowflakeToTimestamp } from "../cache"; +import type { DiscordChannel, DiscordMessage } from "../discord"; + +/** + * Helper: create a Discord snowflake ID from a timestamp. + * Discord epoch is 2015-01-01T00:00:00.000Z (1420070400000). + */ +function timestampToSnowflake(timestampMs: number): string { + const discordEpoch = 1420070400000n; + const ts = BigInt(timestampMs) - discordEpoch; + return String(ts << 22n); +} + +function makeMessage( + id: string, + channelId: string, + content: string, +): DiscordMessage { + return { + id, + channel_id: channelId, + content, + timestamp: new Date().toISOString(), + author: { id: "user-1", username: "testuser" }, + }; +} + +function makeChannel(id: string, name: string): DiscordChannel { + return { id, type: 0, name, guild_id: "guild-1" }; +} + +describe("snowflakeToTimestamp", () => { + test("converts a snowflake to a timestamp", () => { + const now = Date.now(); + const snowflake = timestampToSnowflake(now); + const extracted = snowflakeToTimestamp(snowflake); + // Should be within a millisecond due to BigInt truncation + expect(Math.abs(extracted - now)).toBeLessThanOrEqual(1); + }); +}); + +describe("cache channels", () => { + test("set and get channels", () => { + const cache = createCache(60_000, 4 * 60 * 60 * 1000); + const channels = [makeChannel("ch-1", "general")]; + cache.setChannels("guild-1", channels); + + const result = cache.getChannels("guild-1"); + expect(result).toBeDefined(); + expect(result!.data).toHaveLength(1); + expect(result!.data[0].id).toBe("ch-1"); + expect(typeof result!.cachedAt).toBe("number"); + }); + + test("returns undefined for unknown guild", () => { + const cache = createCache(60_000, 4 * 60 * 60 * 1000); + expect(cache.getChannels("unknown")).toBeUndefined(); + }); + + test("overwrite replaces channels", () => { + const cache = createCache(60_000, 4 * 60 * 60 * 1000); + cache.setChannels("guild-1", [makeChannel("ch-1", "general")]); + cache.setChannels("guild-1", [makeChannel("ch-2", "random")]); + + const result = cache.getChannels("guild-1"); + expect(result!.data).toHaveLength(1); + expect(result!.data[0].id).toBe("ch-2"); + }); +}); + +describe("cache messages", () => { + const FOUR_HOURS = 4 * 60 * 60 * 1000; + + test("set and get messages with `after` filtering", () => { + const cache = createCache(60_000, FOUR_HOURS); + const now = Date.now(); + + const id1 = timestampToSnowflake(now - 60_000); + const id2 = timestampToSnowflake(now - 30_000); + const id3 = timestampToSnowflake(now - 10_000); + + cache.setMessages("ch-1", [ + makeMessage(id1, "ch-1", "msg-1"), + makeMessage(id2, "ch-1", "msg-2"), + makeMessage(id3, "ch-1", "msg-3"), + ]); + + // Get messages after id1 + const result = cache.getMessages("ch-1", id1); + expect(result).toBeDefined(); + expect(result!.data).toHaveLength(2); + expect(result!.data[0].content).toBe("msg-2"); + expect(result!.data[1].content).toBe("msg-3"); + }); + + test("returns undefined for unknown channel", () => { + const cache = createCache(60_000, FOUR_HOURS); + const after = timestampToSnowflake(Date.now() - 60_000); + expect(cache.getMessages("unknown-ch", after)).toBeUndefined(); + }); + + test("returns empty array when `after` is older than cache window", () => { + const cache = createCache(60_000, FOUR_HOURS); + const now = Date.now(); + + const recentId = timestampToSnowflake(now - 60_000); + cache.setMessages("ch-1", [makeMessage(recentId, "ch-1", "recent")]); + + // `after` is 5 hours ago — outside the 4-hour window + const oldId = timestampToSnowflake(now - 5 * 60 * 60 * 1000); + const result = cache.getMessages("ch-1", oldId); + expect(result).toBeDefined(); + expect(result!.data).toEqual([]); + }); + + test("evicts messages older than cache window", () => { + const cache = createCache(60_000, FOUR_HOURS); + const now = Date.now(); + + // One message within window, one outside + const recentId = timestampToSnowflake(now - 60_000); + const oldId = timestampToSnowflake(now - 5 * 60 * 60 * 1000); + + cache.setMessages("ch-1", [ + makeMessage(oldId, "ch-1", "old — should be evicted"), + makeMessage(recentId, "ch-1", "recent — should remain"), + ]); + + // Use an `after` that's within the window (2 hours ago) but before the recent message + const afterId = timestampToSnowflake(now - 2 * 60 * 60 * 1000); + const result = cache.getMessages("ch-1", afterId); + expect(result).toBeDefined(); + // The old message should have been evicted during setMessages + // Only the recent message should remain (and it's after our afterId) + expect(result!.data).toHaveLength(1); + expect(result!.data[0].content).toBe("recent — should remain"); + }); + + test("evict() removes old messages from existing cache entries", () => { + // Use a very short window for this test (1 second) + const SHORT_WINDOW = 1_000; + const cache = createCache(60_000, SHORT_WINDOW); + const now = Date.now(); + + // Message that is "old" relative to our tiny 1s window + const id1 = timestampToSnowflake(now - 2_000); // 2 seconds ago + const id2 = timestampToSnowflake(now); // now + + cache.setMessages("ch-1", [ + makeMessage(id2, "ch-1", "new"), + ]); + + // Manually set messages without eviction by reaching into the cache + // Actually, setMessages already evicts. Let's verify stats. + const stats = cache.getStats(); + expect(stats.totalMessages).toBe(1); + }); + + test("limit trims to N newest messages", () => { + const cache = createCache(60_000, FOUR_HOURS); + const now = Date.now(); + + const id1 = timestampToSnowflake(now - 60_000); + const id2 = timestampToSnowflake(now - 30_000); + const id3 = timestampToSnowflake(now - 10_000); + const afterId = timestampToSnowflake(now - 120_000); + + cache.setMessages("ch-1", [ + makeMessage(id1, "ch-1", "msg-1"), + makeMessage(id2, "ch-1", "msg-2"), + makeMessage(id3, "ch-1", "msg-3"), + ]); + + const result = cache.getMessages("ch-1", afterId, 2); + expect(result).toBeDefined(); + expect(result!.data).toHaveLength(2); + // Should return the 2 newest + expect(result!.data[0].content).toBe("msg-2"); + expect(result!.data[1].content).toBe("msg-3"); + }); + + test("merges new messages with existing on overwrite", () => { + const cache = createCache(60_000, FOUR_HOURS); + const now = Date.now(); + + const id1 = timestampToSnowflake(now - 60_000); + const id2 = timestampToSnowflake(now - 30_000); + const id3 = timestampToSnowflake(now - 10_000); + + cache.setMessages("ch-1", [ + makeMessage(id1, "ch-1", "msg-1"), + makeMessage(id2, "ch-1", "msg-2"), + ]); + + // Add a new message + cache.setMessages("ch-1", [makeMessage(id3, "ch-1", "msg-3")]); + + const afterId = timestampToSnowflake(now - 120_000); + const result = cache.getMessages("ch-1", afterId); + expect(result).toBeDefined(); + // All 3 should be present (merged) + expect(result!.data).toHaveLength(3); + }); +}); + +describe("cache TTL tracking (hit/miss stats)", () => { + test("increments hit count for fresh cache reads", () => { + const cache = createCache(60_000, 4 * 60 * 60 * 1000); + cache.setChannels("guild-1", [makeChannel("ch-1", "general")]); + + cache.getChannels("guild-1"); + cache.getChannels("guild-1"); + + const stats = cache.getStats(); + expect(stats.hits).toBe(2); + }); + + test("increments miss count for unknown keys", () => { + const cache = createCache(60_000, 4 * 60 * 60 * 1000); + + cache.getChannels("unknown"); + cache.getChannels("also-unknown"); + + const stats = cache.getStats(); + expect(stats.misses).toBe(2); + }); + + test("reports stale cache as miss", () => { + // TTL of 1ms — will expire almost immediately + const cache = createCache(1, 4 * 60 * 60 * 1000); + cache.setChannels("guild-1", [makeChannel("ch-1", "general")]); + + // Small delay to ensure TTL expires + const start = Date.now(); + while (Date.now() - start < 5) { + // busy wait 5ms + } + + cache.getChannels("guild-1"); + + const stats = cache.getStats(); + // Should be a miss because the entry is stale + expect(stats.misses).toBe(1); + expect(stats.hits).toBe(0); + }); +}); + +describe("cache stats", () => { + test("reports correct stats", () => { + const cache = createCache(60_000, 4 * 60 * 60 * 1000); + const now = Date.now(); + + cache.setChannels("guild-1", [makeChannel("ch-1", "general")]); + cache.setMessages("ch-1", [ + makeMessage(timestampToSnowflake(now - 60_000), "ch-1", "msg-1"), + makeMessage(timestampToSnowflake(now - 30_000), "ch-1", "msg-2"), + ]); + + const stats = cache.getStats(); + expect(stats.channelsCached).toBe(1); + expect(stats.totalMessages).toBe(2); + expect(stats.hits).toBe(0); + expect(stats.misses).toBe(0); + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts index 1e7a026..7167275 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -13,6 +13,8 @@ describe("loadConfig", () => { expect(config.discordBotToken).toBe("test-token-123"); expect(config.discordGuildId).toBe("guild-456"); expect(config.pollIntervalMs).toBe(15000); + expect(config.cacheTtlMs).toBe(22500); // 15000 * 1.5 + expect(config.cacheWindowMs).toBe(4 * 60 * 60 * 1000); // 4 hours expect(config.port).toBe(3000); expect(config.logLevel).toBe("info"); }); @@ -26,10 +28,27 @@ describe("loadConfig", () => { }); expect(config.pollIntervalMs).toBe(5000); + expect(config.cacheTtlMs).toBe(7500); // 5000 * 1.5 expect(config.port).toBe(8080); expect(config.logLevel).toBe("debug"); }); + test("cache TTL is derived from poll interval", () => { + const config = loadConfig({ + ...requiredEnv, + POLL_INTERVAL_MS: "10000", + }); + expect(config.cacheTtlMs).toBe(15000); // 10000 * 1.5 + }); + + test("CACHE_WINDOW_MS can be overridden", () => { + const config = loadConfig({ + ...requiredEnv, + CACHE_WINDOW_MS: "7200000", // 2 hours + }); + expect(config.cacheWindowMs).toBe(7200000); + }); + test("throws when DISCORD_BOT_TOKEN is missing", () => { expect(() => loadConfig({ DISCORD_GUILD_ID: "guild-456" })).toThrow( "DISCORD_BOT_TOKEN is required", diff --git a/tests/discord.test.ts b/tests/discord.test.ts new file mode 100644 index 0000000..a3d9695 --- /dev/null +++ b/tests/discord.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; +import { createDiscordClient } from "../discord"; +import type { FetchFn, DiscordChannel, DiscordMessage } from "../discord"; + +describe("Discord client — fetchChannels", () => { + test("fetches channels for a guild with auth header", async () => { + const channels: DiscordChannel[] = [ + { id: "ch-1", type: 0, name: "general" }, + { id: "ch-2", type: 2, name: "voice" }, + ]; + + let capturedHeaders: Headers | undefined; + const fakeFetch: FetchFn = async (_input, init) => { + capturedHeaders = new Headers(init?.headers ?? {}); + return new Response(JSON.stringify(channels), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const client = createDiscordClient("test-token", fakeFetch); + const result = await client.fetchChannels("guild-1"); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe("ch-1"); + expect(capturedHeaders?.get("Authorization")).toBe("Bot test-token"); + }); + + test("throws on non-200 response", async () => { + const fakeFetch: FetchFn = async () => { + return new Response(JSON.stringify({ message: "Unauthorized" }), { + status: 401, + statusText: "Unauthorized", + }); + }; + + const client = createDiscordClient("bad-token", fakeFetch); + await expect(client.fetchChannels("guild-1")).rejects.toThrow( + "Failed to fetch channels", + ); + }); +}); + +describe("Discord client — fetchMessages", () => { + test("fetches messages for a channel", async () => { + const messages: DiscordMessage[] = [ + { + id: "msg-1", + channel_id: "ch-1", + content: "hello", + timestamp: new Date().toISOString(), + author: { id: "u-1", username: "user1" }, + }, + ]; + + const fakeFetch: FetchFn = async (input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + expect(url).toContain("/channels/ch-1/messages?limit=50"); + return new Response(JSON.stringify(messages), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const client = createDiscordClient("test-token", fakeFetch); + const result = await client.fetchMessages("ch-1"); + + expect(result).toHaveLength(1); + expect(result[0].content).toBe("hello"); + }); + + test("respects custom limit parameter", async () => { + const fakeFetch: FetchFn = async (input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + expect(url).toContain("limit=10"); + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const client = createDiscordClient("test-token", fakeFetch); + await client.fetchMessages("ch-1", 10); + }); +}); + +describe("Discord client — rate limiting", () => { + test("retries after 429 with Retry-After header", async () => { + let callCount = 0; + const messages: DiscordMessage[] = [ + { + id: "msg-1", + channel_id: "ch-1", + content: "after retry", + timestamp: new Date().toISOString(), + author: { id: "u-1", username: "user1" }, + }, + ]; + + const fakeFetch: FetchFn = async () => { + callCount++; + if (callCount === 1) { + // First call: rate limited + return new Response(JSON.stringify({ message: "Rate limited" }), { + status: 429, + headers: { + "Retry-After": "0.01", // 10ms for fast test + "Content-Type": "application/json", + }, + }); + } + // Second call: success + return new Response(JSON.stringify(messages), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const client = createDiscordClient("test-token", fakeFetch); + const result = await client.fetchMessages("ch-1"); + + expect(callCount).toBe(2); + expect(result).toHaveLength(1); + expect(result[0].content).toBe("after retry"); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 7910f31..7cdedac 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,10 +1,46 @@ import { describe, expect, test } from "bun:test"; -import { handleRequest, VERSION } from "../index"; +import { createHandler, VERSION } from "../index"; +import { createCache } from "../cache"; +import type { DiscordChannel, DiscordMessage } from "../discord"; + +/** + * Helper: create a Discord snowflake ID from a timestamp. + * Discord epoch is 2015-01-01T00:00:00.000Z (1420070400000). + */ +function timestampToSnowflake(timestampMs: number): string { + const discordEpoch = 1420070400000n; + const ts = BigInt(timestampMs) - discordEpoch; + return String(ts << 22n); +} + +function makeMessage( + id: string, + channelId: string, + content: string, +): DiscordMessage { + return { + id, + channel_id: channelId, + content, + timestamp: new Date().toISOString(), + author: { id: "user-1", username: "testuser" }, + }; +} + +function makeChannel(id: string, name: string): DiscordChannel { + return { id, type: 0, name, guild_id: "guild-1" }; +} + +// Use long TTL and window so tests don't expire during execution +const TEST_TTL = 60_000; +const TEST_WINDOW = 4 * 60 * 60 * 1000; // 4 hours describe("GET /health", () => { - test("returns 200 with status ok, uptime, and version", async () => { + test("returns 200 with status ok, uptime, version, and cache stats", async () => { + const cache = createCache(TEST_TTL, TEST_WINDOW); + const handler = createHandler(cache, "guild-1"); const req = new Request("http://localhost/health", { method: "GET" }); - const res = handleRequest(req); + const res = handler(req); expect(res.status).toBe(200); @@ -13,13 +49,208 @@ describe("GET /health", () => { expect(typeof body.uptime).toBe("number"); expect(body.uptime).toBeGreaterThanOrEqual(0); expect(body.version).toBe(VERSION); + // Cache stats present + expect(body.cache).toBeDefined(); + expect(typeof body.cache.channelsCached).toBe("number"); + expect(typeof body.cache.totalMessages).toBe("number"); + expect(typeof body.cache.hits).toBe("number"); + expect(typeof body.cache.misses).toBe("number"); + }); +}); + +describe("GET /api/v10/guilds/{guildId}/channels", () => { + test("returns cached channel list with cache headers", async () => { + const cache = createCache(TEST_TTL, TEST_WINDOW); + const channels: DiscordChannel[] = [ + makeChannel("ch-1", "general"), + makeChannel("ch-2", "random"), + ]; + cache.setChannels("guild-1", channels); + + const handler = createHandler(cache, "guild-1"); + const req = new Request( + "http://localhost/api/v10/guilds/guild-1/channels", + ); + const res = handler(req); + + expect(res.status).toBe(200); + expect(res.headers.get("X-Cache")).toBe("HIT"); + expect(res.headers.get("X-Cached-At")).toBeTruthy(); + + const body = await res.json(); + expect(body).toHaveLength(2); + expect(body[0].id).toBe("ch-1"); + expect(body[1].id).toBe("ch-2"); + }); + + test("returns 404 for unknown guild", async () => { + const cache = createCache(TEST_TTL, TEST_WINDOW); + const handler = createHandler(cache, "guild-1"); + const req = new Request( + "http://localhost/api/v10/guilds/unknown-guild/channels", + ); + const res = handler(req); + + expect(res.status).toBe(404); + }); +}); + +describe("GET /api/v10/channels/{channelId}/messages", () => { + test("returns 400 when `after` parameter is missing", async () => { + const cache = createCache(TEST_TTL, TEST_WINDOW); + const handler = createHandler(cache, "guild-1"); + + // No `after` param + const req = new Request( + "http://localhost/api/v10/channels/ch-1/messages", + ); + const res = handler(req); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("after"); + }); + + test("returns 400 when `after` is not a valid snowflake", async () => { + const cache = createCache(TEST_TTL, TEST_WINDOW); + const handler = createHandler(cache, "guild-1"); + + const req = new Request( + "http://localhost/api/v10/channels/ch-1/messages?after=abc", + ); + const res = handler(req); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("snowflake"); + }); + + test("returns 400 when `limit` is not a positive integer", async () => { + const cache = createCache(TEST_TTL, TEST_WINDOW); + const now = Date.now(); + const afterId = timestampToSnowflake(now - 120_000); + cache.setMessages("ch-1", [ + makeMessage(timestampToSnowflake(now - 60_000), "ch-1", "msg-1"), + ]); + + const handler = createHandler(cache, "guild-1"); + const req = new Request( + `http://localhost/api/v10/channels/ch-1/messages?after=${afterId}&limit=abc`, + ); + const res = handler(req); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("limit"); + }); + + test("returns cached messages filtered by `after`", async () => { + const cache = createCache(TEST_TTL, TEST_WINDOW); + const now = Date.now(); + + // Create messages with snowflake IDs representing different times + const id1 = timestampToSnowflake(now - 60_000); // 1 minute ago + const id2 = timestampToSnowflake(now - 30_000); // 30 seconds ago + const id3 = timestampToSnowflake(now - 10_000); // 10 seconds ago + + cache.setMessages("ch-1", [ + makeMessage(id1, "ch-1", "old message"), + makeMessage(id2, "ch-1", "middle message"), + makeMessage(id3, "ch-1", "new message"), + ]); + + const handler = createHandler(cache, "guild-1"); + + // After id1 — should return id2 and id3 + const req = new Request( + `http://localhost/api/v10/channels/ch-1/messages?after=${id1}`, + ); + const res = handler(req); + + expect(res.status).toBe(200); + expect(res.headers.get("X-Cache")).toBe("HIT"); + expect(res.headers.get("X-Cached-At")).toBeTruthy(); + + const body = (await res.json()) as DiscordMessage[]; + expect(body).toHaveLength(2); + expect(body[0].content).toBe("middle message"); + expect(body[1].content).toBe("new message"); + }); + + test("returns empty array when `after` is older than cache window", async () => { + const cache = createCache(TEST_TTL, TEST_WINDOW); + const now = Date.now(); + + // Message within the window + const recentId = timestampToSnowflake(now - 60_000); + cache.setMessages("ch-1", [ + makeMessage(recentId, "ch-1", "recent"), + ]); + + // `after` pointing to 5 hours ago — outside 4-hour window + const oldId = timestampToSnowflake(now - 5 * 60 * 60 * 1000); + + const handler = createHandler(cache, "guild-1"); + const req = new Request( + `http://localhost/api/v10/channels/ch-1/messages?after=${oldId}`, + ); + const res = handler(req); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual([]); + }); + + test("returns 404 for unknown channel", async () => { + const cache = createCache(TEST_TTL, TEST_WINDOW); + const handler = createHandler(cache, "guild-1"); + + const req = new Request( + "http://localhost/api/v10/channels/unknown-ch/messages?after=100", + ); + const res = handler(req); + + expect(res.status).toBe(404); + }); + + test("`limit` parameter trims response to N newest messages", async () => { + const cache = createCache(TEST_TTL, TEST_WINDOW); + const now = Date.now(); + + const id1 = timestampToSnowflake(now - 60_000); + const id2 = timestampToSnowflake(now - 30_000); + const id3 = timestampToSnowflake(now - 10_000); + + // A snowflake older than all messages, to use as `after` + const afterId = timestampToSnowflake(now - 120_000); + + cache.setMessages("ch-1", [ + makeMessage(id1, "ch-1", "msg-1"), + makeMessage(id2, "ch-1", "msg-2"), + makeMessage(id3, "ch-1", "msg-3"), + ]); + + const handler = createHandler(cache, "guild-1"); + const req = new Request( + `http://localhost/api/v10/channels/ch-1/messages?after=${afterId}&limit=2`, + ); + const res = handler(req); + + expect(res.status).toBe(200); + const body = (await res.json()) as DiscordMessage[]; + expect(body).toHaveLength(2); + // Should return the 2 newest + expect(body[0].content).toBe("msg-2"); + expect(body[1].content).toBe("msg-3"); }); }); 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 = handleRequest(req); + const res = handler(req); expect(res.status).toBe(404); diff --git a/tests/poller.test.ts b/tests/poller.test.ts new file mode 100644 index 0000000..38c141f --- /dev/null +++ b/tests/poller.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "bun:test"; +import { initialPoll, createLogger, withTimeout } from "../poller"; +import { createCache } from "../cache"; +import type { DiscordClient, DiscordChannel, DiscordMessage } from "../discord"; + +function timestampToSnowflake(timestampMs: number): string { + const discordEpoch = 1420070400000n; + const ts = BigInt(timestampMs) - discordEpoch; + return String(ts << 22n); +} + +function makeMockClient( + channels: DiscordChannel[], + messagesByChannel: Record, +): DiscordClient { + return { + async fetchChannels(): Promise { + return channels; + }, + async fetchMessages(channelId: string): Promise { + return messagesByChannel[channelId] ?? []; + }, + }; +} + +describe("initialPoll", () => { + test("populates cache with channels and messages", async () => { + const now = Date.now(); + const id1 = timestampToSnowflake(now - 60_000); + + const channels: DiscordChannel[] = [ + { id: "ch-1", type: 0, name: "general" }, + { id: "ch-2", type: 2, name: "voice" }, // not text, should be skipped + ]; + + const messages: DiscordMessage[] = [ + { + id: id1, + channel_id: "ch-1", + content: "hello", + timestamp: new Date().toISOString(), + author: { id: "u-1", username: "user1" }, + }, + ]; + + const client = makeMockClient(channels, { "ch-1": messages }); + const cache = createCache(60_000, 4 * 60 * 60 * 1000); + const logger = createLogger("error"); // suppress output in tests + + const count = await initialPoll(client, cache, "guild-1", logger); + + expect(count).toBe(1); // only 1 text channel + expect(cache.getChannels("guild-1")).toBeDefined(); + expect(cache.getChannels("guild-1")!.data).toHaveLength(2); // all channels stored + + const afterId = timestampToSnowflake(now - 120_000); + const msgResult = cache.getMessages("ch-1", afterId); + expect(msgResult).toBeDefined(); + expect(msgResult!.data).toHaveLength(1); + }); + + test("returns 0 and does not crash when client throws", async () => { + const client: DiscordClient = { + async fetchChannels(): Promise { + throw new Error("Network error"); + }, + async fetchMessages(): Promise { + throw new Error("Network error"); + }, + }; + + const cache = createCache(60_000, 4 * 60 * 60 * 1000); + const logger = createLogger("error"); + + const count = await initialPoll(client, cache, "guild-1", logger); + expect(count).toBe(0); + }); + + test("continues past channel message fetch failures", async () => { + const now = Date.now(); + const channels: DiscordChannel[] = [ + { id: "ch-1", type: 0, name: "general" }, + { id: "ch-2", type: 0, name: "random" }, + ]; + + const id1 = timestampToSnowflake(now - 60_000); + + const client: DiscordClient = { + async fetchChannels(): Promise { + return channels; + }, + async fetchMessages(channelId: string): Promise { + if (channelId === "ch-1") { + throw new Error("Permission denied"); + } + return [ + { + id: id1, + channel_id: "ch-2", + content: "hello from ch-2", + timestamp: new Date().toISOString(), + author: { id: "u-1", username: "user1" }, + }, + ]; + }, + }; + + const cache = createCache(60_000, 4 * 60 * 60 * 1000); + const logger = createLogger("error"); + + const count = await initialPoll(client, cache, "guild-1", logger); + expect(count).toBe(2); // both text channels counted + + // ch-1 failed — should have no messages + const afterId = timestampToSnowflake(now - 120_000); + expect(cache.getMessages("ch-1", afterId)).toBeUndefined(); + + // ch-2 succeeded + const result = cache.getMessages("ch-2", afterId); + expect(result).toBeDefined(); + expect(result!.data).toHaveLength(1); + }); +}); + +describe("withTimeout", () => { + test("resolves when promise completes before timeout", async () => { + const result = await withTimeout( + Promise.resolve("done"), + 1000, + "test", + ); + expect(result).toBe("done"); + }); + + test("rejects when promise exceeds timeout", async () => { + const slow = new Promise((resolve) => + setTimeout(() => resolve("too late"), 5000), + ); + await expect(withTimeout(slow, 10, "slow op")).rejects.toThrow( + "Timeout after 10ms", + ); + }); +}); + +describe("createLogger", () => { + test("creates a logger that does not throw", () => { + const logger = createLogger("debug"); + // These should not throw + logger.debug("debug message"); + logger.info("info message"); + logger.warn("warn message"); + logger.error("error message"); + }); +});