/** * Discord Channel Adapter * * Uses discord.js for Discord API. * Supports DM pairing for secure access control. */ import type { ChannelAdapter } from './types.js'; import type { InboundAttachment, InboundMessage, InboundReaction, OutboundFile, OutboundMessage } from '../core/types.js'; import type { DmPolicy } from '../pairing/types.js'; import { isUserAllowed, upsertPairingRequest } from '../pairing/store.js'; import { buildAttachmentPath, downloadToFile } from './attachments.js'; import { HELP_TEXT } from '../core/commands.js'; import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, resolveReceiveBotMessages, resolveDailyLimits, checkDailyLimit, type GroupModeConfig } from './group-mode.js'; import { basename } from 'node:path'; import { createLogger } from '../logger.js'; const log = createLogger('Discord'); // Dynamic import to avoid requiring Discord deps if not used let Client: typeof import('discord.js').Client; let GatewayIntentBits: typeof import('discord.js').GatewayIntentBits; let Partials: typeof import('discord.js').Partials; export interface DiscordConfig { token: string; dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open' allowedUsers?: string[]; // Discord user IDs streaming?: boolean; // Stream responses via progressive message edits (default: false) attachmentsDir?: string; attachmentsMaxBytes?: number; groups?: Record; // Per-guild/channel settings agentName?: string; // For scoping daily limit counters in multi-agent mode } export function shouldProcessDiscordBotMessage(params: { isFromBot: boolean; isGroup: boolean; authorId?: string; selfUserId?: string; groups?: Record; keys: string[]; }): boolean { if (!params.isFromBot) return true; if (!params.isGroup) return false; if (params.selfUserId && params.authorId === params.selfUserId) return false; return resolveReceiveBotMessages(params.groups, params.keys); } export class DiscordAdapter implements ChannelAdapter { readonly id = 'discord' as const; readonly name = 'Discord'; private client: InstanceType | null = null; private config: DiscordConfig; private running = false; private attachmentsDir?: string; private attachmentsMaxBytes?: number; onMessage?: (msg: InboundMessage) => Promise; onCommand?: (command: string, chatId?: string, args?: string) => Promise; constructor(config: DiscordConfig) { this.config = { ...config, dmPolicy: config.dmPolicy || 'pairing', }; this.attachmentsDir = config.attachmentsDir; this.attachmentsMaxBytes = config.attachmentsMaxBytes; } /** * Check if a user is authorized based on dmPolicy * Returns 'allowed', 'blocked', or 'pairing' */ private async checkAccess(userId: string): Promise<'allowed' | 'blocked' | 'pairing'> { const policy = this.config.dmPolicy || 'pairing'; // Open policy: everyone allowed if (policy === 'open') { return 'allowed'; } // Check if already allowed (config or store) const allowed = await isUserAllowed('discord', userId, this.config.allowedUsers); if (allowed) { return 'allowed'; } // Allowlist policy: not allowed if not in list if (policy === 'allowlist') { return 'blocked'; } // Pairing policy: needs pairing return 'pairing'; } /** * Format pairing message for Discord */ private formatPairingMsg(code: string): string { return `Hi! This bot requires pairing. Your pairing code: **${code}** Ask the bot owner to approve with: \`lettabot pairing approve discord ${code}\``; } private async sendPairingMessage( message: import('discord.js').Message, text: string ): Promise { const channel = message.channel; const canSend = channel.isTextBased() && 'send' in channel; const sendable = canSend ? (channel as unknown as { send: (content: string) => Promise }) : null; if (!message.guildId) { if (sendable) { await sendable.send(text); } return; } try { await message.author.send(text); } catch { if (sendable) { await sendable.send(text); } } } async start(): Promise { if (this.running) return; const discord = await import('discord.js'); Client = discord.Client; GatewayIntentBits = discord.GatewayIntentBits; Partials = discord.Partials; this.client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages, GatewayIntentBits.DirectMessageReactions, ], partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User], }); this.client.once('clientReady', () => { const tag = this.client?.user?.tag || '(unknown)'; log.info(`Bot logged in as ${tag}`); log.info(`DM policy: ${this.config.dmPolicy}`); this.running = true; }); this.client.on('messageCreate', async (message) => { const isFromBot = !!message.author?.bot; const isGroup = !!message.guildId; const chatId = message.channel.id; const keys = [chatId]; if (message.guildId) keys.push(message.guildId); const selfUserId = this.client?.user?.id; if (!shouldProcessDiscordBotMessage({ isFromBot, isGroup, authorId: message.author?.id, selfUserId, groups: this.config.groups, keys, })) return; let content = (message.content || '').trim(); const userId = message.author?.id; if (!userId) return; // Handle audio attachments const audioAttachment = message.attachments.find(a => a.contentType?.startsWith('audio/')); if (audioAttachment?.url) { try { const { isTranscriptionConfigured } = await import('../transcription/index.js'); if (!isTranscriptionConfigured()) { await message.reply('Voice messages require a transcription API key. See: https://github.com/letta-ai/lettabot#voice'); } else { // Download audio const response = await fetch(audioAttachment.url); const buffer = Buffer.from(await response.arrayBuffer()); const { transcribeAudio } = await import('../transcription/index.js'); const ext = audioAttachment.contentType?.split('/')[1] || 'mp3'; const result = await transcribeAudio(buffer, audioAttachment.name || `audio.${ext}`); if (result.success && result.text) { log.info(`Transcribed audio: "${result.text.slice(0, 50)}..."`); content = (content ? content + '\n' : '') + `[Voice message]: ${result.text}`; } else { log.error(`Transcription failed: ${result.error}`); content = (content ? content + '\n' : '') + `[Voice message - transcription failed: ${result.error}]`; } } } catch (error) { log.error('Error transcribing audio:', error); content = (content ? content + '\n' : '') + `[Voice message - error: ${error instanceof Error ? error.message : 'unknown error'}]`; } } // Bypass pairing for guild (group) messages if (!message.guildId) { const access = await this.checkAccess(userId); if (access === 'blocked') { const ch = message.channel; if (ch.isTextBased() && 'send' in ch) { await (ch as { send: (content: string) => Promise }).send( "Sorry, you're not authorized to use this bot." ); } return; } if (access === 'pairing') { const { code, created } = await upsertPairingRequest('discord', userId, { username: message.author.username, }); if (!code) { await message.channel.send('Too many pending pairing requests. Please try again later.'); return; } if (created) { log.info(`New pairing request from ${userId} (${message.author.username}): ${code}`); } await this.sendPairingMessage(message, this.formatPairingMsg(code)); return; } } const attachments = await this.collectAttachments(message.attachments, message.channel.id); if (!content && attachments.length === 0) return; if (content.startsWith('/')) { const parts = content.slice(1).split(/\s+/); const command = parts[0]?.toLowerCase(); const cmdArgs = parts.slice(1).join(' ') || undefined; if (command === 'help' || command === 'start') { await message.channel.send(HELP_TEXT); return; } if (this.onCommand) { if (command === 'status' || command === 'reset' || command === 'heartbeat' || command === 'cancel' || command === 'model') { const result = await this.onCommand(command, message.channel.id, cmdArgs); if (result) { await message.channel.send(result); } return; } } } if (this.onMessage) { const isGroup = !!message.guildId; const groupName = isGroup && 'name' in message.channel ? message.channel.name : undefined; const displayName = message.member?.displayName || message.author.globalName || message.author.username; const wasMentioned = isGroup && !!this.client?.user && message.mentions.has(this.client.user); let isListeningMode = false; // Group gating: config-based allowlist + mode if (isGroup && this.config.groups) { const chatId = message.channel.id; const serverId = message.guildId; const keys = [chatId]; if (serverId) keys.push(serverId); if (!isGroupAllowed(this.config.groups, keys)) { log.info(`Group ${chatId} not in allowlist, ignoring`); return; } if (!isGroupUserAllowed(this.config.groups, keys, userId)) { return; // User not in group allowedUsers -- silent drop } const mode = resolveGroupMode(this.config.groups, keys, 'open'); if (mode === 'disabled') { return; // Groups disabled for this channel -- silent drop } if (mode === 'mention-only' && !wasMentioned) { return; // Mention required but not mentioned -- silent drop } isListeningMode = mode === 'listen' && !wasMentioned; // Daily rate limit check (after all other gating so we only count real triggers) const limits = resolveDailyLimits(this.config.groups, keys); const counterScope = limits.matchedKey ?? chatId; const counterKey = `${this.config.agentName ?? ''}:discord:${counterScope}`; const limitResult = checkDailyLimit(counterKey, userId, limits); if (!limitResult.allowed) { log.info(`Daily limit reached for ${counterKey} (${limitResult.reason})`); return; } } await this.onMessage({ channel: 'discord', chatId: message.channel.id, userId, userName: displayName, userHandle: message.author.username, messageId: message.id, text: content || '', timestamp: message.createdAt, isGroup, groupName, serverId: message.guildId || undefined, wasMentioned, isListeningMode, attachments, formatterHints: this.getFormatterHints(), }); } }); this.client.on('error', (err) => { log.error('Client error:', err); }); this.client.on('messageReactionAdd', async (reaction, user) => { await this.handleReactionEvent(reaction, user, 'added'); }); this.client.on('messageReactionRemove', async (reaction, user) => { await this.handleReactionEvent(reaction, user, 'removed'); }); log.info('Connecting...'); await this.client.login(this.config.token); } async stop(): Promise { if (!this.running || !this.client) return; this.client.destroy(); this.running = false; } isRunning(): boolean { return this.running; } async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> { if (!this.client) throw new Error('Discord not started'); const channel = await this.client.channels.fetch(msg.chatId); if (!channel || !channel.isTextBased() || !('send' in channel)) { throw new Error(`Discord channel not found or not text-based: ${msg.chatId}`); } const sendable = channel as { send: (content: string) => Promise<{ id: string }> }; const chunks = splitMessageText(msg.text); let lastMessageId = ''; for (const chunk of chunks) { const result = await sendable.send(chunk); lastMessageId = result.id; } return { messageId: lastMessageId }; } async sendFile(file: OutboundFile): Promise<{ messageId: string }> { if (!this.client) throw new Error('Discord not started'); const channel = await this.client.channels.fetch(file.chatId); if (!channel || !channel.isTextBased() || !('send' in channel)) { throw new Error(`Discord channel not found or not text-based: ${file.chatId}`); } const payload = { content: file.caption || undefined, files: [ { attachment: file.filePath, name: basename(file.filePath) }, ], }; const result = await (channel as { send: (options: typeof payload) => Promise<{ id: string }> }).send(payload); return { messageId: result.id }; } async editMessage(chatId: string, messageId: string, text: string): Promise { if (!this.client) throw new Error('Discord not started'); const channel = await this.client.channels.fetch(chatId); if (!channel || !channel.isTextBased()) { throw new Error(`Discord channel not found or not text-based: ${chatId}`); } const message = await channel.messages.fetch(messageId); const botUserId = this.client.user?.id; if (!botUserId || message.author.id !== botUserId) { log.warn('Cannot edit message not sent by bot'); return; } // Discord edit limit is 2000 chars -- truncate if needed (edits can't split) const truncated = text.length > DISCORD_MAX_LENGTH ? text.slice(0, DISCORD_MAX_LENGTH - 1) + '\u2026' : text; await message.edit(truncated); } async addReaction(chatId: string, messageId: string, emoji: string): Promise { if (!this.client) throw new Error('Discord not started'); const channel = await this.client.channels.fetch(chatId); if (!channel || !channel.isTextBased()) { throw new Error(`Discord channel not found or not text-based: ${chatId}`); } const textChannel = channel as { messages: { fetch: (id: string) => Promise<{ react: (input: string) => Promise }> } }; const message = await textChannel.messages.fetch(messageId); const resolved = resolveDiscordEmoji(emoji); await message.react(resolved); } async sendTypingIndicator(chatId: string): Promise { if (!this.client) return; try { const channel = await this.client.channels.fetch(chatId); if (!channel || !channel.isTextBased() || !('sendTyping' in channel)) return; await (channel as { sendTyping: () => Promise }).sendTyping(); } catch { // Ignore typing indicator failures } } getDmPolicy(): string { return this.config.dmPolicy || 'pairing'; } getFormatterHints() { return { supportsReactions: true, supportsFiles: true, formatHint: 'Discord markdown: **bold** *italic* `code` [links](url) ```code blocks``` — supports headers', }; } supportsEditing(): boolean { return this.config.streaming ?? false; } private async handleReactionEvent( reaction: import('discord.js').MessageReaction | import('discord.js').PartialMessageReaction, user: import('discord.js').User | import('discord.js').PartialUser, action: InboundReaction['action'] ): Promise { if ('bot' in user && user.bot) return; try { if (reaction.partial) { await reaction.fetch(); } if (reaction.message.partial) { await reaction.message.fetch(); } } catch (err) { log.warn('Failed to fetch reaction/message:', err); } const message = reaction.message; const channelId = message.channel?.id; if (!channelId) return; const access = await this.checkAccess(user.id); if (access !== 'allowed') { return; } const emoji = reaction.emoji.id ? reaction.emoji.toString() : (reaction.emoji.name || reaction.emoji.toString()); if (!emoji) return; const isGroup = !!message.guildId; const groupName = isGroup && 'name' in message.channel ? message.channel.name || undefined : undefined; const userId = user.id; const userName = 'username' in user ? (user.username ?? undefined) : undefined; const displayName = message.guild?.members.cache.get(userId)?.displayName || userName || userId; this.onMessage?.({ channel: 'discord', chatId: channelId, userId: userId, userName: displayName, userHandle: userName || userId, messageId: message.id, text: '', timestamp: new Date(), isGroup, groupName, serverId: message.guildId || undefined, reaction: { emoji, messageId: message.id, action, }, formatterHints: this.getFormatterHints(), }).catch((err) => { log.error('Error handling reaction:', err); }); } private async collectAttachments(attachments: unknown, channelId: string): Promise { if (!attachments || typeof attachments !== 'object') return []; const list = Array.from((attachments as { values: () => Iterable }).values?.() || []); if (list.length === 0) return []; const results: InboundAttachment[] = []; for (const attachment of list) { const name = attachment.name || attachment.id || 'attachment'; const entry: InboundAttachment = { id: attachment.id, name, mimeType: attachment.contentType || undefined, size: attachment.size, kind: attachment.contentType?.startsWith('image/') ? 'image' : 'file', url: attachment.url, }; if (this.attachmentsDir && attachment.url) { if (this.attachmentsMaxBytes === 0) { results.push(entry); continue; } if (this.attachmentsMaxBytes && attachment.size && attachment.size > this.attachmentsMaxBytes) { log.warn(`Attachment ${name} exceeds size limit, skipping download.`); results.push(entry); continue; } const target = buildAttachmentPath(this.attachmentsDir, 'discord', channelId, name); try { await downloadToFile(attachment.url, target); entry.localPath = target; log.info(`Attachment saved to ${target}`); } catch (err) { log.warn('Failed to download attachment:', err); } } results.push(entry); } return results; } } const DISCORD_EMOJI_ALIAS_TO_UNICODE: Record = { eyes: '\u{1F440}', thumbsup: '\u{1F44D}', thumbs_up: '\u{1F44D}', '+1': '\u{1F44D}', heart: '\u2764\uFE0F', fire: '\u{1F525}', smile: '\u{1F604}', laughing: '\u{1F606}', tada: '\u{1F389}', clap: '\u{1F44F}', ok_hand: '\u{1F44C}', white_check_mark: '\u2705', }; function resolveDiscordEmoji(input: string): string { const aliasMatch = input.match(/^:([^:]+):$/); const alias = aliasMatch ? aliasMatch[1] : null; if (alias && DISCORD_EMOJI_ALIAS_TO_UNICODE[alias]) { return DISCORD_EMOJI_ALIAS_TO_UNICODE[alias]; } if (DISCORD_EMOJI_ALIAS_TO_UNICODE[input]) { return DISCORD_EMOJI_ALIAS_TO_UNICODE[input]; } return input; } // Discord message length limit const DISCORD_MAX_LENGTH = 2000; // Leave some headroom when choosing split points const DISCORD_SPLIT_THRESHOLD = 1900; /** * Split text into chunks that fit within Discord's 2000-char limit. * Splits at paragraph boundaries (double newlines), falling back to * single newlines, then hard-splitting at the threshold. */ function splitMessageText(text: string): string[] { if (text.length <= DISCORD_SPLIT_THRESHOLD) { return [text]; } const chunks: string[] = []; let remaining = text; while (remaining.length > DISCORD_SPLIT_THRESHOLD) { let splitIdx = -1; const searchRegion = remaining.slice(0, DISCORD_SPLIT_THRESHOLD); // Try paragraph boundary (double newline) const lastParagraph = searchRegion.lastIndexOf('\n\n'); if (lastParagraph > DISCORD_SPLIT_THRESHOLD * 0.3) { splitIdx = lastParagraph; } // Fall back to single newline if (splitIdx === -1) { const lastNewline = searchRegion.lastIndexOf('\n'); if (lastNewline > DISCORD_SPLIT_THRESHOLD * 0.3) { splitIdx = lastNewline; } } // Hard split as last resort if (splitIdx === -1) { splitIdx = DISCORD_SPLIT_THRESHOLD; } chunks.push(remaining.slice(0, splitIdx).trimEnd()); remaining = remaining.slice(splitIdx).trimStart(); } if (remaining.trim()) { chunks.push(remaining.trim()); } return chunks; } type DiscordAttachment = { id?: string; name?: string | null; contentType?: string | null; size?: number; url?: string; };