Files
lettabot/src/channels/discord.ts

643 lines
21 KiB
TypeScript

/**
* 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<string, GroupModeConfig>; // 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<string, GroupModeConfig>;
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<typeof Client> | null = null;
private config: DiscordConfig;
private running = false;
private attachmentsDir?: string;
private attachmentsMaxBytes?: number;
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string, chatId?: string, args?: string) => Promise<string | null>;
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<void> {
const channel = message.channel;
const canSend = channel.isTextBased() && 'send' in channel;
const sendable = canSend
? (channel as unknown as { send: (content: string) => Promise<unknown> })
: 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<void> {
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<unknown> }).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<void> {
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<void> {
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<void> {
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<unknown> }> } };
const message = await textChannel.messages.fetch(messageId);
const resolved = resolveDiscordEmoji(emoji);
await message.react(resolved);
}
async sendTypingIndicator(chatId: string): Promise<void> {
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<void> }).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<void> {
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<InboundAttachment[]> {
if (!attachments || typeof attachments !== 'object') return [];
const list = Array.from((attachments as { values: () => Iterable<DiscordAttachment> }).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<string, string> = {
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;
};