Add Discord channel support (#16)

* Add Discord channel support and pairing (#15)

* Address Discord adapter review feedback

* Fix stream counter after merge

* Remove stream count logging

---------

Co-authored-by: Jason Carreira <jason@visotrust.com>
This commit is contained in:
Jason Carreira
2026-01-29 17:11:50 -05:00
committed by GitHub
parent 0804367d4b
commit 0f68b9b52f
17 changed files with 696 additions and 16 deletions

271
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
}
}

265
src/channels/discord.ts Normal file
View File

@@ -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<typeof Client> | null = null;
private config: DiscordConfig;
private running = false;
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string) => Promise<string | null>;
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<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.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<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) {
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<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 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<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) {
console.warn('[Discord] Cannot edit message not sent by bot');
return;
}
await message.edit(text);
}
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
}
}
supportsEditing(): boolean {
return true;
}
}

View File

@@ -7,3 +7,4 @@ export * from './telegram.js';
export * from './slack.js';
export * from './whatsapp.js';
export * from './signal.js';
export * from './discord.js';

View File

@@ -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';

View File

@@ -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

View File

@@ -131,6 +131,30 @@ async function sendWhatsApp(chatId: string, text: string): Promise<void> {
throw new Error('WhatsApp sending via CLI not yet supported (requires active session)');
}
async function sendDiscord(chatId: string, text: string): Promise<void> {
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<void> {
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<void> {
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 <text> Message text (required)
--channel, -c <name> Channel: telegram, slack, signal (default: last used)
--channel, -c <name> Channel: telegram, slack, signal, discord (default: last used)
--chat, --to <id> 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)
`);

View File

@@ -130,6 +130,15 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
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) {

View File

@@ -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: {

View File

@@ -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

View File

@@ -13,6 +13,7 @@ import type { InboundMessage } from './types.js';
*/
const CHANNEL_FORMATS: Record<string, string> = {
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);

View File

@@ -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();

View File

@@ -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)

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<string, string>):
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<string, string>):
// 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<string, string>):
}
}
}
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<void> {
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<void> {
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<void> {
'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<void> {
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,