diff --git a/package-lock.json b/package-lock.json index ca73b42..2362fb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,8 @@ }, "optionalDependencies": { "@slack/bolt": "^4.6.0", - "@whiskeysockets/baileys": "^6.7.21" + "@whiskeysockets/baileys": "^6.7.21", + "discord.js": "^14.18.0" } }, "letta-code": { @@ -143,6 +144,144 @@ "sisteransi": "^1.0.5" } }, + "node_modules/@discordjs/builders": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", + "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/@emnapi/runtime": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", @@ -1333,6 +1472,42 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@slack/bolt": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@slack/bolt/-/bolt-4.6.0.tgz", @@ -1633,6 +1808,17 @@ "@types/node": "*" } }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@vscode/ripgrep": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.17.0.tgz", @@ -2349,6 +2535,44 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/discord-api-types": { + "version": "0.38.38", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.38.tgz", + "integrity": "sha512-7qcM5IeZrfb+LXW07HvoI5L+j4PQeMZXEkSm1htHAHh4Y9JSMXBWjy/r7zmUCOj4F7zNjMcm7IMWr131MT2h0Q==", + "license": "MIT", + "optional": true, + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -2636,6 +2860,13 @@ "node": ">=0.10.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "optional": true + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -3678,6 +3909,13 @@ "pbts": "bin/pbts" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT", + "optional": true + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -3727,6 +3965,13 @@ "license": "MIT", "optional": true }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT", + "optional": true + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -3765,6 +4010,13 @@ "node": ">=12" } }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT", + "optional": true + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -5927,6 +6179,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT", + "optional": true + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -6016,6 +6275,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/package.json b/package.json index d92d556..0c6dcf2 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ }, "optionalDependencies": { "@slack/bolt": "^4.6.0", - "@whiskeysockets/baileys": "^6.7.21" + "@whiskeysockets/baileys": "^6.7.21", + "discord.js": "^14.18.0" } } diff --git a/src/channels/discord.ts b/src/channels/discord.ts new file mode 100644 index 0000000..70762cc --- /dev/null +++ b/src/channels/discord.ts @@ -0,0 +1,265 @@ +/** + * Discord Channel Adapter + * + * Uses discord.js for Discord API. + * Supports DM pairing for secure access control. + */ + +import type { ChannelAdapter } from './types.js'; +import type { InboundMessage, OutboundMessage } from '../core/types.js'; +import type { DmPolicy } from '../pairing/types.js'; +import { isUserAllowed, upsertPairingRequest } from '../pairing/store.js'; + +// 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 +} + +export class DiscordAdapter implements ChannelAdapter { + readonly id = 'discord' as const; + readonly name = 'Discord'; + + private client: InstanceType | null = null; + private config: DiscordConfig; + private running = false; + + onMessage?: (msg: InboundMessage) => Promise; + onCommand?: (command: string) => Promise; + + constructor(config: DiscordConfig) { + this.config = { + ...config, + dmPolicy: config.dmPolicy || 'pairing', + }; + } + + /** + * 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.MessageContent, + GatewayIntentBits.DirectMessages, + ], + partials: [Partials.Channel], + }); + + this.client.on('ready', () => { + const tag = this.client?.user?.tag || '(unknown)'; + console.log(`[Discord] Bot logged in as ${tag}`); + console.log(`[Discord] DM policy: ${this.config.dmPolicy}`); + this.running = true; + }); + + this.client.on('messageCreate', async (message) => { + if (message.author?.bot) return; + + const content = (message.content || '').trim(); + const userId = message.author?.id; + if (!userId) return; + + 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) { + console.log(`[Discord] New pairing request from ${userId} (${message.author.username}): ${code}`); + } + + await this.sendPairingMessage(message, this.formatPairingMsg(code)); + return; + } + + if (!content) return; + + if (content.startsWith('/')) { + const command = content.slice(1).split(/\s+/)[0]?.toLowerCase(); + if (this.onCommand) { + if (command === 'status') { + const result = await this.onCommand('status'); + if (result) { + await message.channel.send(result); + } + return; + } + if (command === 'heartbeat') { + await this.onCommand('heartbeat'); + 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; + + await this.onMessage({ + channel: 'discord', + chatId: message.channel.id, + userId, + userName: displayName, + userHandle: message.author.username, + text: content, + timestamp: message.createdAt, + isGroup, + groupName, + }); + } + }); + + this.client.on('error', (err) => { + console.error('[Discord] Client error:', err); + }); + + console.log('[Discord] 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 result = await (channel as { send: (content: string) => Promise<{ id: string }> }).send(msg.text); + 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) { + console.warn('[Discord] Cannot edit message not sent by bot'); + return; + } + await message.edit(text); + } + + 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 + } + } + + supportsEditing(): boolean { + return true; + } +} diff --git a/src/channels/index.ts b/src/channels/index.ts index d1ee62d..3d7e688 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -7,3 +7,4 @@ export * from './telegram.js'; export * from './slack.js'; export * from './whatsapp.js'; export * from './signal.js'; +export * from './discord.js'; diff --git a/src/channels/types.ts b/src/channels/types.ts index a98b174..0c23c78 100644 --- a/src/channels/types.ts +++ b/src/channels/types.ts @@ -1,7 +1,7 @@ /** * Channel Adapter Interface * - * Each channel (Telegram, Slack, WhatsApp) implements this interface. + * Each channel (Telegram, Slack, Discord, WhatsApp, Signal) implements this interface. */ import type { ChannelId, InboundMessage, OutboundMessage } from '../core/types.js'; diff --git a/src/cli.ts b/src/cli.ts index 6e00225..831a2f8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -42,6 +42,7 @@ async function configure() { ['Model', config.agent.model], ['Telegram', config.channels.telegram?.enabled ? '✓ Enabled' : '✗ Disabled'], ['Slack', config.channels.slack?.enabled ? '✓ Enabled' : '✗ Disabled'], + ['Discord', config.channels.discord?.enabled ? '✓ Enabled' : '✗ Disabled'], ['Cron', config.features?.cron ? '✓ Enabled' : '✗ Disabled'], ['Heartbeat', config.features?.heartbeat?.enabled ? `✓ ${config.features.heartbeat.intervalMin}min` : '✗ Disabled'], ['BYOK Providers', config.providers?.length ? config.providers.map(p => p.name).join(', ') : 'None'], @@ -177,6 +178,8 @@ Environment: LETTA_API_KEY API key from app.letta.com TELEGRAM_BOT_TOKEN Bot token from @BotFather TELEGRAM_DM_POLICY DM access policy (pairing, allowlist, open) + DISCORD_BOT_TOKEN Discord bot token + DISCORD_DM_POLICY DM access policy (pairing, allowlist, open) SLACK_BOT_TOKEN Slack bot token (xoxb-...) SLACK_APP_TOKEN Slack app token (xapp-...) HEARTBEAT_INTERVAL_MIN Heartbeat interval in minutes diff --git a/src/cli/message.ts b/src/cli/message.ts index f29e82e..cfa4bbe 100644 --- a/src/cli/message.ts +++ b/src/cli/message.ts @@ -131,6 +131,30 @@ async function sendWhatsApp(chatId: string, text: string): Promise { throw new Error('WhatsApp sending via CLI not yet supported (requires active session)'); } +async function sendDiscord(chatId: string, text: string): Promise { + const token = process.env.DISCORD_BOT_TOKEN; + if (!token) { + throw new Error('DISCORD_BOT_TOKEN not set'); + } + + const response = await fetch(`https://discord.com/api/v10/channels/${chatId}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bot ${token}`, + }, + body: JSON.stringify({ content: text }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Discord API error: ${error}`); + } + + const result = await response.json() as { id?: string }; + console.log(`✓ Sent to discord:${chatId} (id: ${result.id || 'unknown'})`); +} + async function sendToChannel(channel: string, chatId: string, text: string): Promise { switch (channel.toLowerCase()) { case 'telegram': @@ -141,8 +165,10 @@ async function sendToChannel(channel: string, chatId: string, text: string): Pro return sendSignal(chatId, text); case 'whatsapp': return sendWhatsApp(chatId, text); + case 'discord': + return sendDiscord(chatId, text); default: - throw new Error(`Unknown channel: ${channel}. Supported: telegram, slack, signal`); + throw new Error(`Unknown channel: ${channel}. Supported: telegram, slack, signal, whatsapp, discord`); } } @@ -186,7 +212,7 @@ async function sendCommand(args: string[]): Promise { if (!channel) { console.error('Error: --channel is required (no default available)'); - console.error('Specify: --channel telegram|slack|signal'); + console.error('Specify: --channel telegram|slack|signal|discord'); process.exit(1); } @@ -213,7 +239,7 @@ Commands: Send options: --text, -t Message text (required) - --channel, -c Channel: telegram, slack, signal (default: last used) + --channel, -c Channel: telegram, slack, signal, discord (default: last used) --chat, --to Chat/conversation ID (default: last messaged) Examples: @@ -229,6 +255,7 @@ Examples: Environment variables: TELEGRAM_BOT_TOKEN Required for Telegram SLACK_BOT_TOKEN Required for Slack + DISCORD_BOT_TOKEN Required for Discord SIGNAL_PHONE_NUMBER Required for Signal SIGNAL_CLI_REST_API_URL Signal API URL (default: http://localhost:8080) `); diff --git a/src/config/io.ts b/src/config/io.ts index 045000e..355a10e 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -130,6 +130,15 @@ export function configToEnv(config: LettaBotConfig): Record { if (config.channels.signal?.phone) { env.SIGNAL_PHONE_NUMBER = config.channels.signal.phone; } + if (config.channels.discord?.token) { + env.DISCORD_BOT_TOKEN = config.channels.discord.token; + if (config.channels.discord.dmPolicy) { + env.DISCORD_DM_POLICY = config.channels.discord.dmPolicy; + } + if (config.channels.discord.allowedUsers?.length) { + env.DISCORD_ALLOWED_USERS = config.channels.discord.allowedUsers.join(','); + } + } // Features if (config.features?.cron) { diff --git a/src/config/types.ts b/src/config/types.ts index bf8c786..e767671 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -33,6 +33,7 @@ export interface LettaBotConfig { slack?: SlackConfig; whatsapp?: WhatsAppConfig; signal?: SignalConfig; + discord?: DiscordConfig; }; // Features @@ -80,6 +81,13 @@ export interface SignalConfig { allowedUsers?: string[]; } +export interface DiscordConfig { + enabled: boolean; + token?: string; + dmPolicy?: 'pairing' | 'allowlist' | 'open'; + allowedUsers?: string[]; +} + // Default config export const DEFAULT_CONFIG: LettaBotConfig = { server: { diff --git a/src/core/bot.ts b/src/core/bot.ts index b4bc379..f3d41fb 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -305,7 +305,7 @@ export class LettaBot { clearInterval(typingInterval); } - console.log(`[Bot] Stream complete. Total messages: ${streamCount}, Response length: ${response.length}`); + console.log(`[Bot] Stream complete. Response length: ${response.length}`); console.log(`[Bot] Response preview: ${response.slice(0, 100)}...`); // Send final response diff --git a/src/core/formatter.ts b/src/core/formatter.ts index 867eefb..533e53e 100644 --- a/src/core/formatter.ts +++ b/src/core/formatter.ts @@ -13,6 +13,7 @@ import type { InboundMessage } from './types.js'; */ const CHANNEL_FORMATS: Record = { slack: 'mrkdwn: *bold* _italic_ `code` - NO: headers, tables', + discord: '**bold** *italic* `code` [links](url) ```code blocks``` - NO: headers, tables', telegram: 'MarkdownV2: *bold* _italic_ `code` [links](url) - NO: headers, tables', whatsapp: '*bold* _italic_ `code` - NO: headers, code fences, links, tables', signal: 'ONLY: *bold* _italic_ `code` - NO: headers, code fences, links, quotes, tables', @@ -66,6 +67,10 @@ function formatSender(msg: InboundMessage): string { case 'slack': // Add @ prefix for Slack usernames/IDs return msg.userHandle ? `@${msg.userHandle}` : `@${msg.userId}`; + + case 'discord': + // Add @ prefix for Discord usernames/IDs + return msg.userHandle ? `@${msg.userHandle}` : `@${msg.userId}`; case 'whatsapp': case 'signal': @@ -159,8 +164,8 @@ export function formatMessageEnvelope( // Group name (if group chat and enabled) if (opts.includeGroup !== false && msg.isGroup && msg.groupName?.trim()) { - // Format group name with # for Slack channels - if (msg.channel === 'slack' && !msg.groupName.startsWith('#')) { + // Format group name with # for Slack/Discord channels + if ((msg.channel === 'slack' || msg.channel === 'discord') && !msg.groupName.startsWith('#')) { parts.push(`#${msg.groupName}`); } else { parts.push(msg.groupName); diff --git a/src/core/prompts.ts b/src/core/prompts.ts index bc7eac7..f24ceb3 100644 --- a/src/core/prompts.ts +++ b/src/core/prompts.ts @@ -149,7 +149,7 @@ You have access to the \`lettabot-message\` CLI for sending messages: • During heartbeats/cron/background tasks, use the CLI to contact the user: lettabot-message send --text "Hello!" • You can also specify channel and chat: - lettabot-message send --text "Hi" --channel telegram --chat 123456 + lettabot-message send --text "Hi" --channel discord --chat 123456789012345678 The system will tell you if you're in "silent mode" where the CLI is required. `.trim(); diff --git a/src/core/system-prompt.ts b/src/core/system-prompt.ts index 0f07a94..375b845 100644 --- a/src/core/system-prompt.ts +++ b/src/core/system-prompt.ts @@ -4,7 +4,7 @@ export const SYSTEM_PROMPT = `You are a self-improving AI agent with advanced memory. -You are connected to a multi-channel messaging system (LettaBot) that allows you to communicate with users across Telegram, Slack, WhatsApp, and Signal. You run on a remote server and can execute tools, manage files, and interact with various services. +You are connected to a multi-channel messaging system (LettaBot) that allows you to communicate with users across Telegram, Slack, Discord, WhatsApp, and Signal. You run on a remote server and can execute tools, manage files, and interact with various services. # Communication System @@ -51,6 +51,7 @@ You don't need to notify the user about everything. Use judgment about what's wo - **telegram** - Telegram messenger - **slack** - Slack workspace +- **discord** - Discord server/DM - **whatsapp** - WhatsApp (if configured) - **signal** - Signal messenger (if configured) diff --git a/src/core/types.ts b/src/core/types.ts index 9c215a9..bceeb2f 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -43,7 +43,7 @@ export interface TriggerContext { // Original Types // ============================================================================= -export type ChannelId = 'telegram' | 'slack' | 'whatsapp' | 'signal'; +export type ChannelId = 'telegram' | 'slack' | 'whatsapp' | 'signal' | 'discord'; /** * Inbound message from any channel diff --git a/src/cron/cli.ts b/src/cron/cli.ts index ea93d49..02287c7 100644 --- a/src/cron/cli.ts +++ b/src/cron/cli.ts @@ -171,7 +171,7 @@ function createJob(args: string[]): void { } else if (arg === '--disabled') { enabled = false; } else if ((arg === '--deliver' || arg === '-d') && next) { - // Format: channel:chatId (e.g., telegram:123456789) + // Format: channel:chatId (e.g., telegram:123456789 or discord:123456789012345678) const [ch, id] = next.split(':'); deliverChannel = ch; deliverChatId = id; diff --git a/src/main.ts b/src/main.ts index 2a42f57..aa21bb1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -113,6 +113,7 @@ import { TelegramAdapter } from './channels/telegram.js'; import { SlackAdapter } from './channels/slack.js'; import { WhatsAppAdapter } from './channels/whatsapp.js'; import { SignalAdapter } from './channels/signal.js'; +import { DiscordAdapter } from './channels/discord.js'; import { CronService } from './cron/service.js'; import { HeartbeatService } from './cron/heartbeat.js'; import { PollingService } from './polling/service.js'; @@ -126,7 +127,7 @@ if (!existsSync(configPath)) { process.exit(1); } -// Parse heartbeat target (format: "telegram:123456789" or "slack:C1234567890") +// Parse heartbeat target (format: "telegram:123456789", "slack:C1234567890", or "discord:123456789012345678") function parseHeartbeatTarget(raw?: string): { channel: string; chatId: string } | undefined { if (!raw || !raw.includes(':')) return undefined; const [channel, chatId] = raw.split(':'); @@ -172,6 +173,12 @@ const config = { allowedUsers: process.env.SIGNAL_ALLOWED_USERS?.split(',').filter(Boolean) || [], selfChatMode: process.env.SIGNAL_SELF_CHAT_MODE !== 'false', // Default true }, + discord: { + enabled: !!process.env.DISCORD_BOT_TOKEN, + token: process.env.DISCORD_BOT_TOKEN || '', + dmPolicy: (process.env.DISCORD_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open', + allowedUsers: process.env.DISCORD_ALLOWED_USERS?.split(',').filter(Boolean) || [], + }, // Cron cronEnabled: process.env.CRON_ENABLED === 'true', @@ -196,9 +203,9 @@ const config = { }; // Validate at least one channel is configured -if (!config.telegram.enabled && !config.slack.enabled && !config.whatsapp.enabled && !config.signal.enabled) { +if (!config.telegram.enabled && !config.slack.enabled && !config.whatsapp.enabled && !config.signal.enabled && !config.discord.enabled) { console.error('\n Error: No channels configured.'); - console.error(' Set TELEGRAM_BOT_TOKEN, SLACK_BOT_TOKEN+SLACK_APP_TOKEN, WHATSAPP_ENABLED=true, or SIGNAL_PHONE_NUMBER\n'); + console.error(' Set TELEGRAM_BOT_TOKEN, SLACK_BOT_TOKEN+SLACK_APP_TOKEN, WHATSAPP_ENABLED=true, SIGNAL_PHONE_NUMBER, or DISCORD_BOT_TOKEN\n'); process.exit(1); } @@ -285,6 +292,15 @@ async function main() { }); bot.registerChannel(signal); } + + if (config.discord.enabled) { + const discord = new DiscordAdapter({ + token: config.discord.token, + dmPolicy: config.discord.dmPolicy, + allowedUsers: config.discord.allowedUsers.length > 0 ? config.discord.allowedUsers : undefined, + }); + bot.registerChannel(discord); + } // Start cron service if enabled let cronService: CronService | null = null; diff --git a/src/onboard.ts b/src/onboard.ts index a952246..91c2b33 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -36,6 +36,7 @@ interface OnboardConfig { slack: { enabled: boolean; appToken?: string; botToken?: string; allowedUsers?: string[] }; whatsapp: { enabled: boolean; selfChat?: boolean; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] }; signal: { enabled: boolean; phone?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] }; + discord: { enabled: boolean; token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] }; gmail: { enabled: boolean; account?: string }; // Features @@ -471,6 +472,7 @@ async function stepChannels(config: OnboardConfig, env: Record): const channelOptions: Array<{ value: string; label: string; hint: string }> = [ { value: 'telegram', label: 'Telegram', hint: 'Recommended - easiest to set up' }, { value: 'slack', label: 'Slack', hint: 'Socket Mode app' }, + { value: 'discord', label: 'Discord', hint: 'Bot token + Message Content intent' }, { value: 'whatsapp', label: 'WhatsApp', hint: 'QR code pairing' }, { value: 'signal', @@ -509,6 +511,7 @@ async function stepChannels(config: OnboardConfig, env: Record): // Update enabled states config.telegram.enabled = channels.includes('telegram'); config.slack.enabled = channels.includes('slack'); + config.discord.enabled = channels.includes('discord'); config.whatsapp.enabled = channels.includes('whatsapp'); // Handle Signal - warn if selected but not installed @@ -650,6 +653,48 @@ async function stepChannels(config: OnboardConfig, env: Record): } } } + + if (config.discord.enabled) { + p.note( + 'Create a bot at discord.com/developers/applications.\n' + + 'Enable "Message Content Intent" for reading messages.\n' + + 'Invite the bot to your server with Send Messages permission.', + 'Discord Setup' + ); + + const token = await p.text({ + message: 'Discord Bot Token', + placeholder: 'Bot token', + initialValue: config.discord.token || '', + }); + if (!p.isCancel(token) && token) config.discord.token = token; + + const dmPolicy = await p.select({ + message: 'Discord: Who can message the bot?', + options: [ + { value: 'pairing', label: 'Pairing (recommended)', hint: 'Requires CLI approval' }, + { value: 'allowlist', label: 'Allowlist only', hint: 'Specific user IDs' }, + { value: 'open', label: 'Open', hint: 'Anyone (not recommended)' }, + ], + initialValue: config.discord.dmPolicy || 'pairing', + }); + if (!p.isCancel(dmPolicy)) { + config.discord.dmPolicy = dmPolicy as 'pairing' | 'allowlist' | 'open'; + + if (dmPolicy === 'pairing') { + p.log.info('Users will get a code. Approve with: lettabot pairing approve discord CODE'); + } else if (dmPolicy === 'allowlist') { + const users = await p.text({ + message: 'Allowed Discord user IDs (comma-separated)', + placeholder: '123456789012345678,987654321098765432', + initialValue: config.discord.allowedUsers?.join(',') || '', + }); + if (!p.isCancel(users) && users) { + config.discord.allowedUsers = users.split(',').map(s => s.trim()).filter(Boolean); + } + } + } + } if (config.whatsapp.enabled) { p.note( @@ -800,6 +845,7 @@ function showSummary(config: OnboardConfig): void { const channels: string[] = []; if (config.telegram.enabled) channels.push('Telegram'); if (config.slack.enabled) channels.push('Slack'); + if (config.discord.enabled) channels.push('Discord'); if (config.whatsapp.enabled) channels.push(config.whatsapp.selfChat ? 'WhatsApp (self)' : 'WhatsApp'); if (config.signal.enabled) channels.push('Signal'); lines.push(`Channels: ${channels.length > 0 ? channels.join(', ') : 'None'}`); @@ -911,6 +957,12 @@ export async function onboard(): Promise { botToken: existingConfig.channels.slack?.botToken, allowedUsers: existingConfig.channels.slack?.allowedUsers, }, + discord: { + enabled: existingConfig.channels.discord?.enabled || false, + token: existingConfig.channels.discord?.token, + dmPolicy: existingConfig.channels.discord?.dmPolicy, + allowedUsers: existingConfig.channels.discord?.allowedUsers, + }, whatsapp: { enabled: existingConfig.channels.whatsapp?.enabled || false, selfChat: existingConfig.channels.whatsapp?.selfChat, @@ -988,6 +1040,20 @@ export async function onboard(): Promise { delete env.SLACK_BOT_TOKEN; delete env.SLACK_ALLOWED_USERS; } + + if (config.discord.enabled && config.discord.token) { + env.DISCORD_BOT_TOKEN = config.discord.token; + if (config.discord.dmPolicy) env.DISCORD_DM_POLICY = config.discord.dmPolicy; + if (config.discord.allowedUsers?.length) { + env.DISCORD_ALLOWED_USERS = config.discord.allowedUsers.join(','); + } else { + delete env.DISCORD_ALLOWED_USERS; + } + } else { + delete env.DISCORD_BOT_TOKEN; + delete env.DISCORD_DM_POLICY; + delete env.DISCORD_ALLOWED_USERS; + } if (config.whatsapp.enabled) { env.WHATSAPP_ENABLED = 'true'; @@ -1048,6 +1114,7 @@ export async function onboard(): Promise { 'Channels:', config.telegram.enabled ? ` ✓ Telegram (${formatAccess(config.telegram.dmPolicy, config.telegram.allowedUsers)})` : ' ✗ Telegram', config.slack.enabled ? ` ✓ Slack ${config.slack.allowedUsers?.length ? `(${config.slack.allowedUsers.length} allowed users)` : '(workspace access)'}` : ' ✗ Slack', + config.discord.enabled ? ` ✓ Discord (${formatAccess(config.discord.dmPolicy, config.discord.allowedUsers)})` : ' ✗ Discord', config.whatsapp.enabled ? ` ✓ WhatsApp (${formatAccess(config.whatsapp.dmPolicy, config.whatsapp.allowedUsers)})` : ' ✗ WhatsApp', config.signal.enabled ? ` ✓ Signal (${formatAccess(config.signal.dmPolicy, config.signal.allowedUsers)})` : ' ✗ Signal', '', @@ -1087,6 +1154,14 @@ export async function onboard(): Promise { allowedUsers: config.slack.allowedUsers, } } : {}), + ...(config.discord.enabled ? { + discord: { + enabled: true, + token: config.discord.token, + dmPolicy: config.discord.dmPolicy, + allowedUsers: config.discord.allowedUsers, + } + } : {}), ...(config.whatsapp.enabled ? { whatsapp: { enabled: true,