feat: add Codex CLI support, dual Discord bots, and Discord typing fix

- Add Codex as alternative agent type (agentType: 'codex' per group)
- Codex container runner with session resume and 100-turn limit
- Dual Discord bot support (DISCORD_BOT_TOKEN + DISCORD_CODEX_BOT_TOKEN)
- Each bot handles only its agentType's registered groups
- Discord typing indicator refreshes every 8s (was single-shot, expired after ~10s)
- Agent runner 100-turn limit to prevent infinite bot-to-bot loops
- Apple Container runtime support (convert-to-apple-container skill)
- Session commands (/compact) support
- DB migration for agent_type column
This commit is contained in:
Eyejoker
2026-03-10 20:45:48 +09:00
parent d572bab5c6
commit 4a34a9c802
24 changed files with 2606 additions and 176 deletions

View File

@@ -0,0 +1,781 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// --- Mocks ---
// Mock registry (registerChannel runs at import time)
vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
// Mock env reader (used by the factory, not needed in unit tests)
vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
// Mock config
vi.mock('../config.js', () => ({
ASSISTANT_NAME: 'Andy',
TRIGGER_PATTERN: /^@Andy\b/i,
}));
// Mock logger
vi.mock('../logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// --- discord.js mock ---
type Handler = (...args: any[]) => any;
const clientRef = vi.hoisted(() => ({ current: null as any }));
vi.mock('discord.js', () => {
const Events = {
MessageCreate: 'messageCreate',
ClientReady: 'ready',
Error: 'error',
};
const GatewayIntentBits = {
Guilds: 1,
GuildMessages: 2,
MessageContent: 4,
DirectMessages: 8,
};
class MockClient {
eventHandlers = new Map<string, Handler[]>();
user: any = { id: '999888777', tag: 'Andy#1234' };
private _ready = false;
constructor(_opts: any) {
clientRef.current = this;
}
on(event: string, handler: Handler) {
const existing = this.eventHandlers.get(event) || [];
existing.push(handler);
this.eventHandlers.set(event, existing);
return this;
}
once(event: string, handler: Handler) {
return this.on(event, handler);
}
async login(_token: string) {
this._ready = true;
// Fire the ready event
const readyHandlers = this.eventHandlers.get('ready') || [];
for (const h of readyHandlers) {
h({ user: this.user });
}
}
isReady() {
return this._ready;
}
channels = {
fetch: vi.fn().mockResolvedValue({
send: vi.fn().mockResolvedValue(undefined),
sendTyping: vi.fn().mockResolvedValue(undefined),
}),
};
destroy() {
this._ready = false;
}
}
// Mock TextChannel type
class TextChannel {}
return {
Client: MockClient,
Events,
GatewayIntentBits,
TextChannel,
};
});
import { DiscordChannel, DiscordChannelOpts } from './discord.js';
// --- Test helpers ---
function createTestOpts(
overrides?: Partial<DiscordChannelOpts>,
): DiscordChannelOpts {
return {
onMessage: vi.fn(),
onChatMetadata: vi.fn(),
registeredGroups: vi.fn(() => ({
'dc:1234567890123456': {
name: 'Test Server #general',
folder: 'test-server',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
...overrides,
};
}
function createMessage(overrides: {
channelId?: string;
content?: string;
authorId?: string;
authorUsername?: string;
authorDisplayName?: string;
memberDisplayName?: string;
isBot?: boolean;
guildName?: string;
channelName?: string;
messageId?: string;
createdAt?: Date;
attachments?: Map<string, any>;
reference?: { messageId?: string };
mentionsBotId?: boolean;
}) {
const channelId = overrides.channelId ?? '1234567890123456';
const authorId = overrides.authorId ?? '55512345';
const botId = '999888777'; // matches mock client user id
const mentionsMap = new Map();
if (overrides.mentionsBotId) {
mentionsMap.set(botId, { id: botId });
}
return {
channelId,
id: overrides.messageId ?? 'msg_001',
content: overrides.content ?? 'Hello everyone',
createdAt: overrides.createdAt ?? new Date('2024-01-01T00:00:00.000Z'),
author: {
id: authorId,
username: overrides.authorUsername ?? 'alice',
displayName: overrides.authorDisplayName ?? 'Alice',
bot: overrides.isBot ?? false,
},
member: overrides.memberDisplayName
? { displayName: overrides.memberDisplayName }
: null,
guild: overrides.guildName
? { name: overrides.guildName }
: null,
channel: {
name: overrides.channelName ?? 'general',
messages: {
fetch: vi.fn().mockResolvedValue({
author: { username: 'Bob', displayName: 'Bob' },
member: { displayName: 'Bob' },
}),
},
},
mentions: {
users: mentionsMap,
},
attachments: overrides.attachments ?? new Map(),
reference: overrides.reference ?? null,
};
}
function currentClient() {
return clientRef.current;
}
async function triggerMessage(message: any) {
const handlers = currentClient().eventHandlers.get('messageCreate') || [];
for (const h of handlers) await h(message);
}
// --- Tests ---
describe('DiscordChannel', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// --- Connection lifecycle ---
describe('connection lifecycle', () => {
it('resolves connect() when client is ready', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
expect(channel.isConnected()).toBe(true);
});
it('registers message handlers on connect', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
expect(currentClient().eventHandlers.has('messageCreate')).toBe(true);
expect(currentClient().eventHandlers.has('error')).toBe(true);
expect(currentClient().eventHandlers.has('ready')).toBe(true);
});
it('disconnects cleanly', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
expect(channel.isConnected()).toBe(true);
await channel.disconnect();
expect(channel.isConnected()).toBe(false);
});
it('isConnected() returns false before connect', () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
expect(channel.isConnected()).toBe(false);
});
});
// --- Text message handling ---
describe('text message handling', () => {
it('delivers message for registered channel', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'Hello everyone',
guildName: 'Test Server',
channelName: 'general',
});
await triggerMessage(msg);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.any(String),
'Test Server #general',
'discord',
true,
);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
id: 'msg_001',
chat_jid: 'dc:1234567890123456',
sender: '55512345',
sender_name: 'Alice',
content: 'Hello everyone',
is_from_me: false,
}),
);
});
it('only emits metadata for unregistered channels', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
channelId: '9999999999999999',
content: 'Unknown channel',
guildName: 'Other Server',
});
await triggerMessage(msg);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'dc:9999999999999999',
expect.any(String),
expect.any(String),
'discord',
true,
);
expect(opts.onMessage).not.toHaveBeenCalled();
});
it('delivers bot messages with is_bot_message flag', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({ isBot: true, content: 'I am a bot' });
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
content: 'I am a bot',
is_bot_message: true,
}),
);
});
it('uses member displayName when available (server nickname)', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'Hi',
memberDisplayName: 'Alice Nickname',
authorDisplayName: 'Alice Global',
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({ sender_name: 'Alice Nickname' }),
);
});
it('falls back to author displayName when no member', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'Hi',
memberDisplayName: undefined,
authorDisplayName: 'Alice Global',
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({ sender_name: 'Alice Global' }),
);
});
it('uses sender name for DM chats (no guild)', async () => {
const opts = createTestOpts({
registeredGroups: vi.fn(() => ({
'dc:1234567890123456': {
name: 'DM',
folder: 'dm',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
});
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'Hello',
guildName: undefined,
authorDisplayName: 'Alice',
});
await triggerMessage(msg);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.any(String),
'Alice',
'discord',
false,
);
});
it('uses guild name + channel name for server messages', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'Hello',
guildName: 'My Server',
channelName: 'bot-chat',
});
await triggerMessage(msg);
expect(opts.onChatMetadata).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.any(String),
'My Server #bot-chat',
'discord',
true,
);
});
});
// --- @mention translation ---
describe('@mention translation', () => {
it('translates <@botId> mention to trigger format', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: '<@999888777> what time is it?',
mentionsBotId: true,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '@Andy what time is it?',
}),
);
});
it('does not translate if message already matches trigger', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: '@Andy hello <@999888777>',
mentionsBotId: true,
guildName: 'Server',
});
await triggerMessage(msg);
// Should NOT prepend @Andy — already starts with trigger
// But the <@botId> should still be stripped
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '@Andy hello',
}),
);
});
it('does not translate when bot is not mentioned', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'hello everyone',
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: 'hello everyone',
}),
);
});
it('handles <@!botId> (nickname mention format)', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: '<@!999888777> check this',
mentionsBotId: true,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '@Andy check this',
}),
);
});
});
// --- Attachments ---
describe('attachments', () => {
it('stores image attachment with placeholder', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'photo.png', contentType: 'image/png' }],
]);
const msg = createMessage({
content: '',
attachments,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[Image: photo.png]',
}),
);
});
it('stores video attachment with placeholder', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'clip.mp4', contentType: 'video/mp4' }],
]);
const msg = createMessage({
content: '',
attachments,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[Video: clip.mp4]',
}),
);
});
it('stores file attachment with placeholder', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'report.pdf', contentType: 'application/pdf' }],
]);
const msg = createMessage({
content: '',
attachments,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[File: report.pdf]',
}),
);
});
it('includes text content with attachments', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'photo.jpg', contentType: 'image/jpeg' }],
]);
const msg = createMessage({
content: 'Check this out',
attachments,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: 'Check this out\n[Image: photo.jpg]',
}),
);
});
it('handles multiple attachments', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const attachments = new Map([
['att1', { name: 'a.png', contentType: 'image/png' }],
['att2', { name: 'b.txt', contentType: 'text/plain' }],
]);
const msg = createMessage({
content: '',
attachments,
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[Image: a.png]\n[File: b.txt]',
}),
);
});
});
// --- Reply context ---
describe('reply context', () => {
it('includes reply author in content', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const msg = createMessage({
content: 'I agree with that',
reference: { messageId: 'original_msg_id' },
guildName: 'Server',
});
await triggerMessage(msg);
expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456',
expect.objectContaining({
content: '[Reply to Bob] I agree with that',
}),
);
});
});
// --- sendMessage ---
describe('sendMessage', () => {
it('sends message via channel', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
await channel.sendMessage('dc:1234567890123456', 'Hello');
const fetchedChannel = await currentClient().channels.fetch('1234567890123456');
expect(currentClient().channels.fetch).toHaveBeenCalledWith('1234567890123456');
});
it('strips dc: prefix from JID', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
await channel.sendMessage('dc:9876543210', 'Test');
expect(currentClient().channels.fetch).toHaveBeenCalledWith('9876543210');
});
it('handles send failure gracefully', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
currentClient().channels.fetch.mockRejectedValueOnce(
new Error('Channel not found'),
);
// Should not throw
await expect(
channel.sendMessage('dc:1234567890123456', 'Will fail'),
).resolves.toBeUndefined();
});
it('does nothing when client is not initialized', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
// Don't connect — client is null
await channel.sendMessage('dc:1234567890123456', 'No client');
// No error, no API call
});
it('splits messages exceeding 2000 characters', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const mockChannel = {
send: vi.fn().mockResolvedValue(undefined),
sendTyping: vi.fn(),
};
currentClient().channels.fetch.mockResolvedValue(mockChannel);
const longText = 'x'.repeat(3000);
await channel.sendMessage('dc:1234567890123456', longText);
expect(mockChannel.send).toHaveBeenCalledTimes(2);
expect(mockChannel.send).toHaveBeenNthCalledWith(1, 'x'.repeat(2000));
expect(mockChannel.send).toHaveBeenNthCalledWith(2, 'x'.repeat(1000));
});
});
// --- ownsJid ---
describe('ownsJid', () => {
it('owns dc: JIDs', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.ownsJid('dc:1234567890123456')).toBe(true);
});
it('does not own WhatsApp group JIDs', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.ownsJid('12345@g.us')).toBe(false);
});
it('does not own Telegram JIDs', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.ownsJid('tg:123456789')).toBe(false);
});
it('does not own unknown JID formats', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.ownsJid('random-string')).toBe(false);
});
});
// --- setTyping ---
describe('setTyping', () => {
it('sends typing indicator when isTyping is true', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
const mockChannel = {
send: vi.fn(),
sendTyping: vi.fn().mockResolvedValue(undefined),
};
currentClient().channels.fetch.mockResolvedValue(mockChannel);
await channel.setTyping('dc:1234567890123456', true);
expect(mockChannel.sendTyping).toHaveBeenCalled();
});
it('does nothing when isTyping is false', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
await channel.connect();
await channel.setTyping('dc:1234567890123456', false);
// channels.fetch should NOT be called
expect(currentClient().channels.fetch).not.toHaveBeenCalled();
});
it('does nothing when client is not initialized', async () => {
const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts);
// Don't connect
await channel.setTyping('dc:1234567890123456', true);
// No error
});
});
// --- Channel properties ---
describe('channel properties', () => {
it('has name "discord"', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.name).toBe('discord');
});
});
});

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

@@ -0,0 +1,291 @@
import { Client, Events, GatewayIntentBits, Message, TextChannel } from 'discord.js';
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
import { readEnvFile } from '../env.js';
import { logger } from '../logger.js';
import { registerChannel, ChannelOpts } from './registry.js';
import {
AgentType,
Channel,
OnChatMetadata,
OnInboundMessage,
RegisteredGroup,
} from '../types.js';
export interface DiscordChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
export class DiscordChannel implements Channel {
name = 'discord';
private client: Client | null = null;
private opts: DiscordChannelOpts;
private botToken: string;
private typingIntervals = new Map<string, NodeJS.Timeout>();
private agentTypeFilter?: AgentType;
constructor(botToken: string, opts: DiscordChannelOpts, agentTypeFilter?: AgentType) {
this.botToken = botToken;
this.opts = opts;
this.agentTypeFilter = agentTypeFilter;
if (agentTypeFilter) {
this.name = `discord-${agentTypeFilter}`;
}
}
async connect(): Promise<void> {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
});
this.client.on(Events.MessageCreate, async (message: Message) => {
// Ignore own messages only
if (message.author.id === this.client?.user?.id) return;
const channelId = message.channelId;
const chatJid = `dc:${channelId}`;
let content = message.content;
const timestamp = message.createdAt.toISOString();
const senderName =
message.member?.displayName ||
message.author.displayName ||
message.author.username;
const sender = message.author.id;
const msgId = message.id;
// Determine chat name
let chatName: string;
if (message.guild) {
const textChannel = message.channel as TextChannel;
chatName = `${message.guild.name} #${textChannel.name}`;
} else {
chatName = senderName;
}
// Translate Discord @bot mentions into TRIGGER_PATTERN format.
// Discord mentions look like <@botUserId> — these won't match
// TRIGGER_PATTERN (e.g., ^@Andy\b), so we prepend the trigger
// when the bot is @mentioned.
if (this.client?.user) {
const botId = this.client.user.id;
const isBotMentioned =
message.mentions.users.has(botId) ||
content.includes(`<@${botId}>`) ||
content.includes(`<@!${botId}>`);
if (isBotMentioned) {
// Strip the <@botId> mention to avoid visual clutter
content = content
.replace(new RegExp(`<@!?${botId}>`, 'g'), '')
.trim();
// Prepend trigger if not already present
if (!TRIGGER_PATTERN.test(content)) {
content = `@${ASSISTANT_NAME} ${content}`;
}
}
}
// Handle attachments — store placeholders so the agent knows something was sent
if (message.attachments.size > 0) {
const attachmentDescriptions = [...message.attachments.values()].map((att) => {
const contentType = att.contentType || '';
if (contentType.startsWith('image/')) {
return `[Image: ${att.name || 'image'}]`;
} else if (contentType.startsWith('video/')) {
return `[Video: ${att.name || 'video'}]`;
} else if (contentType.startsWith('audio/')) {
return `[Audio: ${att.name || 'audio'}]`;
} else {
return `[File: ${att.name || 'file'}]`;
}
});
if (content) {
content = `${content}\n${attachmentDescriptions.join('\n')}`;
} else {
content = attachmentDescriptions.join('\n');
}
}
// Handle reply context — include who the user is replying to
if (message.reference?.messageId) {
try {
const repliedTo = await message.channel.messages.fetch(
message.reference.messageId,
);
const replyAuthor =
repliedTo.member?.displayName ||
repliedTo.author.displayName ||
repliedTo.author.username;
content = `[Reply to ${replyAuthor}] ${content}`;
} catch {
// Referenced message may have been deleted
}
}
// Store chat metadata for discovery
const isGroup = message.guild !== null;
this.opts.onChatMetadata(chatJid, timestamp, chatName, 'discord', isGroup);
// Only deliver full message for registered groups matching our agent type
const group = this.opts.registeredGroups()[chatJid];
if (!group) {
logger.debug(
{ chatJid, chatName },
'Message from unregistered Discord channel',
);
return;
}
if (this.agentTypeFilter && (group.agentType || 'claude-code') !== this.agentTypeFilter) {
return; // This JID belongs to a different agent type's bot
}
// Deliver message — startMessageLoop() will pick it up
this.opts.onMessage(chatJid, {
id: msgId,
chat_jid: chatJid,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: false,
is_bot_message: message.author.bot ?? false,
});
logger.info(
{ chatJid, chatName, sender: senderName },
'Discord message stored',
);
});
// Handle errors gracefully
this.client.on(Events.Error, (err) => {
logger.error({ err: err.message }, 'Discord client error');
});
return new Promise<void>((resolve) => {
this.client!.once(Events.ClientReady, (readyClient) => {
logger.info(
{ username: readyClient.user.tag, id: readyClient.user.id },
'Discord bot connected',
);
console.log(`\n Discord bot: ${readyClient.user.tag}`);
console.log(
` Use /chatid command or check channel IDs in Discord settings\n`,
);
resolve();
});
this.client!.login(this.botToken);
});
}
async sendMessage(jid: string, text: string): Promise<void> {
if (!this.client) {
logger.warn('Discord client not initialized');
return;
}
try {
const channelId = jid.replace(/^dc:/, '');
const channel = await this.client.channels.fetch(channelId);
if (!channel || !('send' in channel)) {
logger.warn({ jid }, 'Discord channel not found or not text-based');
return;
}
const textChannel = channel as TextChannel;
// Discord has a 2000 character limit per message — split if needed
const MAX_LENGTH = 2000;
if (text.length <= MAX_LENGTH) {
await textChannel.send(text);
} else {
for (let i = 0; i < text.length; i += MAX_LENGTH) {
await textChannel.send(text.slice(i, i + MAX_LENGTH));
}
}
logger.info({ jid, length: text.length }, 'Discord message sent');
} catch (err) {
logger.error({ jid, err }, 'Failed to send Discord message');
}
}
isConnected(): boolean {
return this.client !== null && this.client.isReady();
}
ownsJid(jid: string): boolean {
if (!jid.startsWith('dc:')) return false;
if (!this.agentTypeFilter) return true;
const group = this.opts.registeredGroups()[jid];
if (!group) return false;
return (group.agentType || 'claude-code') === this.agentTypeFilter;
}
async disconnect(): Promise<void> {
if (this.client) {
this.client.destroy();
this.client = null;
logger.info('Discord bot stopped');
}
}
async setTyping(jid: string, isTyping: boolean): Promise<void> {
if (!this.client) return;
// Clear any existing interval for this channel
const existing = this.typingIntervals.get(jid);
if (existing) {
clearInterval(existing);
this.typingIntervals.delete(jid);
}
if (!isTyping) return;
const sendOnce = async () => {
try {
const channelId = jid.replace(/^dc:/, '');
const channel = await this.client!.channels.fetch(channelId);
if (channel && 'sendTyping' in channel) {
await (channel as TextChannel).sendTyping();
}
} catch (err) {
logger.debug({ jid, err }, 'Failed to send Discord typing indicator');
}
};
// Send immediately, then refresh every 8 seconds (Discord expires at ~10s)
await sendOnce();
this.typingIntervals.set(jid, setInterval(sendOnce, 8000));
}
}
registerChannel('discord', (opts: ChannelOpts) => {
const envVars = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_CODEX_BOT_TOKEN']);
const token =
process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN || '';
if (!token) {
logger.warn('Discord: DISCORD_BOT_TOKEN not set');
return null;
}
// If a second Codex bot token exists, this instance only handles claude-code groups
const hasCodexBot = !!(process.env.DISCORD_CODEX_BOT_TOKEN || envVars.DISCORD_CODEX_BOT_TOKEN);
return new DiscordChannel(token, opts, hasCodexBot ? 'claude-code' : undefined);
});
registerChannel('discord-codex', (opts: ChannelOpts) => {
const envVars = readEnvFile(['DISCORD_CODEX_BOT_TOKEN']);
const token =
process.env.DISCORD_CODEX_BOT_TOKEN || envVars.DISCORD_CODEX_BOT_TOKEN || '';
if (!token) return null; // Codex Discord bot is optional
return new DiscordChannel(token, opts, 'codex');
});

View File

@@ -2,6 +2,7 @@
// Each import triggers the channel module's registerChannel() call.
// discord
import './discord.js';
// gmail