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
186 changes: 186 additions & 0 deletions cache.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
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<string, CacheEntry<DiscordChannel[]>>();
const messageCache = new Map<string, CacheEntry<DiscordMessage[]>>();
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<string, DiscordMessage>();
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;
}
}
},
};
}
8 changes: 8 additions & 0 deletions config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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();
Expand All @@ -71,6 +77,8 @@ export function loadConfig(
discordBotToken,
discordGuildId,
pollIntervalMs,
cacheTtlMs,
cacheWindowMs,
port,
logLevel,
};
Expand Down
102 changes: 102 additions & 0 deletions discord.ts
Original file line number Diff line number Diff line change
@@ -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<DiscordChannel[]>;
fetchMessages(
channelId: string,
limit?: number,
): Promise<DiscordMessage[]>;
}

/** Minimal fetch signature — avoids Bun-specific `preconnect` property on `typeof fetch`. */
export type FetchFn = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;

/**
* 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<Response> {
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<DiscordChannel[]> {
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<DiscordChannel[]>;
},

async fetchMessages(
channelId: string,
limit = 50,
): Promise<DiscordMessage[]> {
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<DiscordMessage[]>;
},
};
}
Loading
Loading