import { readFile } from 'node:fs/promises' import { getDiscordHeaders } from './super-properties' import { DiscordSearchIndexNotReadyResponseSchema, DiscordSearchResponseSchema } from './types' import type { DiscordChannel, DiscordDMChannel, DiscordFile, DiscordGuild, DiscordGuildMember, DiscordMention, DiscordMessage, DiscordReadState, DiscordRelationship, DiscordSearchOptions, DiscordSearchResult, DiscordUnreadMention, DiscordUnreadMentionsResult, DiscordUser, DiscordUserNote, DiscordUserProfile, } from './types' const MENTIONS_WINDOW_DAYS = 7 const MENTIONS_PAGE_SIZE = 100 const UNREAD_MENTIONS_MAX = 500 function isSnowflakeNewer(candidate: string, marker: string): boolean { try { return BigInt(candidate) > BigInt(marker) } catch { return false } } export class DiscordError extends Error { code: string constructor(message: string, code: string) { super(message) this.name = 'DiscordError' this.code = code } } interface RateLimitBucket { remaining: number resetAt: number bucketHash: string } const BASE_URL = 'https://discord.com/api/v10' const MAX_RETRIES = 3 const BASE_BACKOFF_MS = 100 const MAX_SEARCH_INDEX_RETRY_MS = 30_000 export class DiscordClient { private token: string | null = null private buckets: Map = new Map() private globalRateLimitUntil: number = 0 async login(credentials?: { token: string }): Promise { if (credentials) { if (!credentials.token) { throw new DiscordError('Token is required', 'missing_token') } this.token = credentials.token return this } const { ensureDiscordAuth } = await import('./ensure-auth') await ensureDiscordAuth() const { DiscordCredentialManager } = await import('./credential-manager') const credManager = new DiscordCredentialManager() const token = await credManager.getToken() if (!token) { throw new DiscordError( 'No Discord credentials found. Make sure Discord is logged in to the desktop app or a supported Chromium browser.', 'no_credentials', ) } return this.login({ token }) } private ensureAuth(): string { if (!this.token) { throw new DiscordError('Not authenticated. Call .login() first.', 'not_authenticated') } return this.token } private getBucketKey(method: string, path: string): string { const queryIndex = path.indexOf('?') const route = queryIndex === -1 ? path : path.slice(0, queryIndex) const normalized = route .replace(/\/channels\/\d+/, '/channels/{channel_id}') .replace(/\/guilds\/\d+/, '/guilds/{guild_id}') .replace(/\/users\/\d+/, '/users/{user_id}') .replace(/\/messages\/\d+/, '/messages/{message_id}') return `${method}:${normalized}` } private async waitForRateLimit(bucketKey: string): Promise { const now = Date.now() if (this.globalRateLimitUntil > now) { await this.sleep(this.globalRateLimitUntil - now) } const bucket = this.buckets.get(bucketKey) if (bucket && bucket.remaining === 0 && bucket.resetAt * 1000 > now) { await this.sleep(bucket.resetAt * 1000 - now) } } private updateBucket(bucketKey: string, response: Response): void { const remaining = response.headers.get('X-RateLimit-Remaining') const reset = response.headers.get('X-RateLimit-Reset') const bucketHash = response.headers.get('X-RateLimit-Bucket') if (remaining !== null && reset !== null && bucketHash !== null) { this.buckets.set(bucketKey, { remaining: parseInt(remaining, 10), resetAt: parseFloat(reset), bucketHash, }) } } private async handleRateLimitResponse(response: Response): Promise { const retryAfter = response.headers.get('Retry-After') const isGlobal = response.headers.get('X-RateLimit-Global') === 'true' const waitMs = parseFloat(retryAfter || '1') * 1000 if (isGlobal) { this.globalRateLimitUntil = Date.now() + waitMs } await this.sleep(waitMs) return waitMs } private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } private async request(method: string, path: string, body?: unknown): Promise { const url = `${BASE_URL}${path}` const bucketKey = this.getBucketKey(method, path) for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { await this.waitForRateLimit(bucketKey) const headers: Record = getDiscordHeaders(this.ensureAuth()) const options: RequestInit = { method, headers, } if (body !== undefined) { headers['Content-Type'] = 'application/json' options.body = JSON.stringify(body) } const response = await fetch(url, options) this.updateBucket(bucketKey, response) if (response.status === 429) { if (attempt < MAX_RETRIES) { await this.handleRateLimitResponse(response) continue } const errorBody = await response.json().catch(() => ({})) throw new DiscordError((errorBody as any).message || 'Rate limited', 'rate_limited') } if (response.status >= 500 && attempt < MAX_RETRIES) { await this.sleep(BASE_BACKOFF_MS * 2 ** attempt) continue } if (!response.ok) { const errorBody = await response.json().catch(() => ({})) throw new DiscordError( (errorBody as any).message || `HTTP ${response.status}`, (errorBody as any).code?.toString() || `http_${response.status}`, ) } if (response.status === 204) { return undefined as T } return response.json() as Promise } throw new DiscordError('Request failed after retries', 'max_retries') } private async requestFormData(path: string, formData: FormData): Promise { const url = `${BASE_URL}${path}` const bucketKey = this.getBucketKey('POST', path) await this.waitForRateLimit(bucketKey) const headers = getDiscordHeaders(this.ensureAuth()) const response = await fetch(url, { method: 'POST', headers, body: formData, }) this.updateBucket(bucketKey, response) if (!response.ok) { const errorBody = await response.json().catch(() => ({})) throw new DiscordError( (errorBody as any).message || `HTTP ${response.status}`, (errorBody as any).code?.toString() || `http_${response.status}`, ) } return response.json() as Promise } async testAuth(): Promise { return this.request('GET', '/users/@me') } async listServers(): Promise { return this.request('GET', '/users/@me/guilds') } async getServer(serverId: string): Promise { return this.request('GET', `/guilds/${serverId}`) } async listChannels(serverId: string): Promise { return this.request('GET', `/guilds/${serverId}/channels`) } async getChannel(channelId: string): Promise { return this.request('GET', `/channels/${channelId}`) } async sendMessage(channelId: string, content: string, options?: { reply_to?: string }): Promise { const body: Record = { content } if (options?.reply_to) { body.message_reference = { message_id: options.reply_to } } return this.request('POST', `/channels/${channelId}/messages`, body) } async getMessages(channelId: string, limit: number = 50, options?: { around?: string }): Promise { const params = new URLSearchParams() if (options?.around) { params.set('around', options.around) } params.set('limit', limit.toString()) return this.request('GET', `/channels/${channelId}/messages?${params}`) } async getMessage(channelId: string, messageId: string): Promise { const messages = await this.getMessages(channelId, 1, { around: messageId }) const message = messages[0] if (!message || message.id !== messageId) { throw new DiscordError('Message not found', '10008') } return message } async editMessage(channelId: string, messageId: string, content: string): Promise { return this.request('PATCH', `/channels/${channelId}/messages/${messageId}`, { content }) } async deleteMessage(channelId: string, messageId: string): Promise { return this.request('DELETE', `/channels/${channelId}/messages/${messageId}`) } async addReaction(channelId: string, messageId: string, emoji: string): Promise { const encodedEmoji = encodeURIComponent(emoji) return this.request('PUT', `/channels/${channelId}/messages/${messageId}/reactions/${encodedEmoji}/@me`) } async removeReaction(channelId: string, messageId: string, emoji: string): Promise { const encodedEmoji = encodeURIComponent(emoji) return this.request('DELETE', `/channels/${channelId}/messages/${messageId}/reactions/${encodedEmoji}/@me`) } async ackMessage(channelId: string, messageId: string): Promise { return this.request('POST', `/channels/${channelId}/messages/${messageId}/ack`, { token: null, }) } async listUsers(serverId: string): Promise { interface GuildMember { user: DiscordUser } const members = await this.request('GET', `/guilds/${serverId}/members?limit=1000`) return members.map((m) => m.user) } async getUser(userId: string): Promise { return this.request('GET', `/users/${userId}`) } async uploadFile(channelId: string, filePath: string, options?: { reply_to?: string }): Promise { const fileBuffer = await readFile(filePath) const filename = filePath.split('/').pop() || 'file' const formData = new FormData() if (options?.reply_to) { formData.append('payload_json', JSON.stringify({ message_reference: { message_id: options.reply_to } })) } formData.append('files[0]', new Blob([fileBuffer]), filename) interface MessageWithAttachments extends DiscordMessage { attachments: DiscordFile[] } const message = await this.requestFormData(`/channels/${channelId}/messages`, formData) return message.attachments[0] } async listFiles(channelId: string): Promise { interface MessageWithAttachments extends DiscordMessage { attachments: DiscordFile[] } const messages = await this.request('GET', `/channels/${channelId}/messages?limit=100`) const files: DiscordFile[] = [] for (const msg of messages) { if (msg.attachments && msg.attachments.length > 0) { files.push(...msg.attachments) } } return files } async listDMChannels(): Promise { return this.request('GET', '/users/@me/channels') } async createDM(userId: string): Promise { return this.request('POST', '/users/@me/channels', { recipient_id: userId, }) } async getMentions(options?: { limit?: number; guildId?: string; before?: string }): Promise { const params = new URLSearchParams() params.set('limit', (options?.limit ?? 25).toString()) params.set('roles', 'true') params.set('everyone', 'true') if (options?.guildId) { params.set('guild_id', options.guildId) } if (options?.before) { params.set('before', options.before) } return this.request('GET', `/users/@me/mentions?${params.toString()}`) } async fetchReadState(): Promise { const { DiscordListener } = await import('./listener') const listener = new DiscordListener(this) try { await listener.start() const readState = listener.getReadState() if (readState === null) { throw new DiscordError('Discord gateway did not deliver read state', 'no_read_state') } return readState } finally { listener.stop() } } async getUnreadMentions(options?: { guildId?: string; limit?: number }): Promise { const limit = options?.limit ?? UNREAD_MENTIONS_MAX const [readState, scan] = await Promise.all([ this.fetchReadState(), this.collectMentions({ guildId: options?.guildId, limit }), ]) const readStateByChannel = new Map(readState.map((state) => [state.channelId, state])) const unread: DiscordUnreadMention[] = [] for (const mention of scan.mentions) { const state = readStateByChannel.get(mention.channel_id) // A channel with no read-state entry is unknown, not unread: omit rather than assume unread. if (!state || state.mentionCount === 0) continue if (state.lastMessageId !== null && !isSnowflakeNewer(mention.id, state.lastMessageId)) continue unread.push({ ...mention, mention_count: state.mentionCount }) } // READY read states carry no guild association, so badgeCount is always the // account-wide unread-mention total even when mentions are guild-filtered. const badgeCount = readState.reduce((sum, state) => sum + state.mentionCount, 0) return { mentions: unread, count: unread.length, badgeCount, complete: scan.complete, windowDays: MENTIONS_WINDOW_DAYS, } } private async collectMentions(options: { guildId?: string limit: number }): Promise<{ mentions: DiscordMention[]; complete: boolean }> { const collected: DiscordMention[] = [] const seen = new Set() let before: string | undefined while (collected.length < options.limit) { const pageSize = Math.min(MENTIONS_PAGE_SIZE, options.limit - collected.length) const page = await this.getMentions({ limit: pageSize, guildId: options.guildId, before }) // An empty or short page is the natural end of history: the scan is exhaustive. if (page.length < pageSize) { for (const mention of page) { if (seen.has(mention.id)) continue seen.add(mention.id) collected.push(mention) } return { mentions: collected, complete: true } } let added = 0 for (const mention of page) { if (collected.length >= options.limit) break if (seen.has(mention.id)) continue seen.add(mention.id) collected.push(mention) added++ } // Discord returns pages newest-first; a non-advancing cursor means a repeated // page, so stop to avoid looping. Either way the scan did not reach history's end. const oldest = page[page.length - 1].id if (added === 0 || oldest === before) return { mentions: collected, complete: false } before = oldest } return { mentions: collected, complete: false } } async getUserNote(userId: string): Promise { try { return await this.request('GET', `/users/@me/notes/${userId}`) } catch (error) { if (error instanceof DiscordError && error.code === 'http_404') { return null } throw error } } async setUserNote(userId: string, note: string): Promise { return this.request('PUT', `/users/@me/notes/${userId}`, { note, }) } async getRelationships(): Promise { return this.request('GET', '/users/@me/relationships') } async searchMembers(guildId: string, query: string, limit: number = 10): Promise { const params = new URLSearchParams() params.set('query', query) params.set('limit', limit.toString()) return this.request('GET', `/guilds/${guildId}/members/search?${params.toString()}`) } async searchMessages( guildId: string, query: string, options: DiscordSearchOptions = {}, ): Promise<{ results: DiscordSearchResult[]; total: number }> { const params = new URLSearchParams() params.set('content', query) if (options.channelId) { params.set('channel_id', options.channelId) } if (options.authorId) { params.set('author_id', options.authorId) } if (options.has) { params.set('has', options.has) } if (options.sortBy) { params.set('sort_by', options.sortBy) } if (options.sortOrder) { params.set('sort_order', options.sortOrder) } if (options.limit !== undefined) { params.set('limit', Math.max(1, Math.min(options.limit, 25)).toString()) } if (options.offset !== undefined) { params.set('offset', options.offset.toString()) } const path = `/guilds/${guildId}/messages/search?${params.toString()}` for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { const response: unknown = await this.request('GET', path) const searchResponse = DiscordSearchResponseSchema.safeParse(response) if (searchResponse.success) { const results = searchResponse.data.messages .flat() .filter((msg) => msg.hit) .map((msg) => ({ id: msg.id, channel_id: msg.channel_id, guild_id: msg.guild_id, content: msg.content, author: msg.author, timestamp: msg.timestamp, hit: msg.hit, })) return { results, total: searchResponse.data.total_results, } } const indexNotReadyResponse = DiscordSearchIndexNotReadyResponseSchema.safeParse(response) if (!indexNotReadyResponse.success) { throw new DiscordError('Discord returned an invalid search response', 'invalid_search_response') } if (attempt === MAX_RETRIES) { throw new DiscordError('Search index is not ready after retries', 'search_index_not_ready') } const requestedDelayMs = indexNotReadyResponse.data.retry_after * 1000 const retryDelayMs = Math.min( requestedDelayMs === 0 ? BASE_BACKOFF_MS : requestedDelayMs, MAX_SEARCH_INDEX_RETRY_MS, ) await this.sleep(retryDelayMs) } throw new DiscordError('Search failed after retries', 'max_retries') } async getUserProfile(userId: string): Promise { return this.request('GET', `/users/${userId}/profile`) } async createThread( channelId: string, name: string, options?: { auto_archive_duration?: number rate_limit_per_user?: number }, ): Promise { return this.request('POST', `/channels/${channelId}/threads`, { name, ...options, }) } async archiveThread(threadId: string, archived: boolean = true): Promise { return this.request('PATCH', `/channels/${threadId}`, { archived, }) } async gatewayConnect(): Promise<{ token: string }> { return { token: this.ensureAuth() } } }