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:
781
src/channels/discord.test.ts
Normal file
781
src/channels/discord.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user