1139 lines
30 KiB
TypeScript
1139 lines
30 KiB
TypeScript
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
|
|
// --- Mocks ---
|
|
|
|
// Mock registry (registerChannel runs at import time)
|
|
const registeredChannelFactories = vi.hoisted(
|
|
() => new Map<string, (...args: any[]) => any>(),
|
|
);
|
|
|
|
vi.mock('./registry.js', () => ({
|
|
registerChannel: vi.fn((name: string, factory: (...args: any[]) => any) => {
|
|
registeredChannelFactories.set(name, factory);
|
|
}),
|
|
}));
|
|
|
|
// Mock env reader (used by the factory, not needed in unit tests)
|
|
vi.mock('../env.js', () => ({
|
|
readEnvFile: vi.fn(() => ({})),
|
|
getEnv: vi.fn(() => undefined),
|
|
}));
|
|
|
|
// Mock config
|
|
vi.mock('../config.js', () => ({
|
|
ASSISTANT_NAME: 'Andy',
|
|
TRIGGER_PATTERN: /^@Andy\b/i,
|
|
DATA_DIR: '/tmp/ejclaw-test-data',
|
|
CACHE_DIR: '/tmp/ejclaw-test-cache',
|
|
STATUS_CHANNEL_ID: 'status-channel',
|
|
}));
|
|
|
|
// Mock logger
|
|
vi.mock('../logger.js', () => ({
|
|
logger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const hasReviewerLeaseMock = vi.hoisted(() => vi.fn(() => false));
|
|
|
|
vi.mock('../service-routing.js', () => ({
|
|
hasReviewerLease: hasReviewerLeaseMock,
|
|
}));
|
|
|
|
// --- discord.js mock ---
|
|
|
|
type Handler = (...args: any[]) => any;
|
|
|
|
const clientRef = vi.hoisted(() => ({ current: null as any }));
|
|
const loginShouldRejectRef = vi.hoisted(() => ({ value: false }));
|
|
|
|
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) {
|
|
if (loginShouldRejectRef.value) {
|
|
throw new Error('An invalid token was provided.');
|
|
}
|
|
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,
|
|
MessageFlags: { SuppressEmbeds: 1 << 2, IsVoiceMessage: 1 << 13 },
|
|
TextChannel,
|
|
};
|
|
});
|
|
|
|
import { DiscordChannel, DiscordChannelOpts } from './discord.js';
|
|
import { logger } from '../logger.js';
|
|
|
|
// --- Test helpers ---
|
|
|
|
const ONE_PIXEL_PNG = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
|
'base64',
|
|
);
|
|
|
|
function createTempPng(name = 'image.png'): { dir: string; filePath: string } {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-discord-image-'));
|
|
const filePath = path.join(dir, name);
|
|
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
|
|
return { dir, filePath };
|
|
}
|
|
|
|
function createTestOpts(
|
|
overrides?: Partial<DiscordChannelOpts>,
|
|
): DiscordChannelOpts {
|
|
return {
|
|
onMessage: vi.fn(),
|
|
onChatMetadata: vi.fn(),
|
|
roomBindings: 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,
|
|
flags: { has: () => false },
|
|
};
|
|
}
|
|
|
|
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 ---
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
hasReviewerLeaseMock.mockReturnValue(false);
|
|
loginShouldRejectRef.value = false;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('channel registration', () => {
|
|
it('warns when the canonical owner token is not configured', () => {
|
|
const ownerFactory = registeredChannelFactories.get('discord');
|
|
|
|
expect(ownerFactory).toBeTypeOf('function');
|
|
expect(ownerFactory?.(createTestOpts() as any)).toBeNull();
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'Discord: DISCORD_OWNER_BOT_TOKEN not set',
|
|
);
|
|
});
|
|
});
|
|
|
|
// --- 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('rejects connect() when login fails', async () => {
|
|
loginShouldRejectRef.value = true;
|
|
const opts = createTestOpts();
|
|
const channel = new DiscordChannel('bad-token', opts);
|
|
|
|
await expect(channel.connect()).rejects.toThrow(
|
|
'An invalid token was provided.',
|
|
);
|
|
expect(channel.isConnected()).toBe(false);
|
|
});
|
|
|
|
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('ignores its own bot messages', async () => {
|
|
const opts = createTestOpts();
|
|
const channel = new DiscordChannel('test-token', opts);
|
|
await channel.connect();
|
|
|
|
const msg = createMessage({
|
|
authorId: '999888777',
|
|
isBot: true,
|
|
content: 'I am the connected bot',
|
|
});
|
|
await triggerMessage(msg);
|
|
|
|
expect(opts.onMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ignores other bot messages in normal rooms', async () => {
|
|
const opts = createTestOpts();
|
|
const channel = new DiscordChannel('test-token', opts);
|
|
await channel.connect();
|
|
|
|
const msg = createMessage({
|
|
authorId: '111222333',
|
|
isBot: true,
|
|
content: 'I am another bot',
|
|
});
|
|
await triggerMessage(msg);
|
|
|
|
expect(opts.onMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('delivers other bot messages in paired rooms', async () => {
|
|
hasReviewerLeaseMock.mockReturnValue(true);
|
|
|
|
const opts = createTestOpts();
|
|
const channel = new DiscordChannel('test-token', opts);
|
|
await channel.connect();
|
|
|
|
const msg = createMessage({
|
|
authorId: '111222333',
|
|
isBot: true,
|
|
content: 'I am another bot',
|
|
});
|
|
await triggerMessage(msg);
|
|
|
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
'dc:1234567890123456',
|
|
expect.objectContaining({
|
|
content: 'I am another 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({
|
|
roomBindings: 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,
|
|
);
|
|
});
|
|
});
|
|
|
|
// --- bot mention handling ---
|
|
|
|
describe('bot mention handling', () => {
|
|
it('passes through <@botId> mentions without rewriting them', 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: '<@999888777> what time is it?',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('leaves mixed text and mentions untouched', 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 <@999888777>',
|
|
}),
|
|
);
|
|
});
|
|
|
|
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('passes through <@!botId> nickname mentions without rewriting them', 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: '<@!999888777> check this',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
// --- Attachments ---
|
|
|
|
describe('attachments', () => {
|
|
let originalFetch: typeof globalThis.fetch;
|
|
|
|
beforeEach(() => {
|
|
originalFetch = globalThis.fetch;
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: () =>
|
|
Promise.resolve(
|
|
new TextEncoder().encode('Hello from text file').buffer,
|
|
),
|
|
text: () => Promise.resolve('Hello from text file'),
|
|
}),
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
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',
|
|
{
|
|
id: 'att1',
|
|
name: 'photo.png',
|
|
contentType: 'image/png',
|
|
url: 'https://cdn.example.com/photo.png',
|
|
},
|
|
],
|
|
]);
|
|
const msg = createMessage({
|
|
content: '',
|
|
attachments,
|
|
guildName: 'Server',
|
|
});
|
|
await triggerMessage(msg);
|
|
|
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
'dc:1234567890123456',
|
|
expect.objectContaining({
|
|
content: expect.stringMatching(/^\[Image: .+\.png\]$/),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('stores video attachment with local path', async () => {
|
|
const opts = createTestOpts();
|
|
const channel = new DiscordChannel('test-token', opts);
|
|
await channel.connect();
|
|
|
|
const attachments = new Map([
|
|
[
|
|
'att1',
|
|
{
|
|
id: 'att1',
|
|
name: 'clip.mp4',
|
|
contentType: 'video/mp4',
|
|
url: 'https://cdn.example.com/clip.mp4',
|
|
},
|
|
],
|
|
]);
|
|
const msg = createMessage({
|
|
content: '',
|
|
attachments,
|
|
guildName: 'Server',
|
|
});
|
|
await triggerMessage(msg);
|
|
|
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
'dc:1234567890123456',
|
|
expect.objectContaining({
|
|
content: expect.stringMatching(/^\[Video: clip\.mp4 → .+\.mp4\]$/),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('stores file attachment with local path', async () => {
|
|
const opts = createTestOpts();
|
|
const channel = new DiscordChannel('test-token', opts);
|
|
await channel.connect();
|
|
|
|
const attachments = new Map([
|
|
[
|
|
'att1',
|
|
{
|
|
id: 'att1',
|
|
name: 'report.pdf',
|
|
contentType: 'application/pdf',
|
|
url: 'https://cdn.example.com/report.pdf',
|
|
},
|
|
],
|
|
]);
|
|
const msg = createMessage({
|
|
content: '',
|
|
attachments,
|
|
guildName: 'Server',
|
|
});
|
|
await triggerMessage(msg);
|
|
|
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
'dc:1234567890123456',
|
|
expect.objectContaining({
|
|
content: expect.stringMatching(/^\[File: report\.pdf → .+\.pdf\]$/),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('stores zip attachment with local path', async () => {
|
|
const opts = createTestOpts();
|
|
const channel = new DiscordChannel('test-token', opts);
|
|
await channel.connect();
|
|
|
|
const attachments = new Map([
|
|
[
|
|
'att1',
|
|
{
|
|
id: 'att1',
|
|
name: 'display_latency_atopile_rev09.zip',
|
|
contentType: 'application/zip',
|
|
url: 'https://cdn.example.com/display_latency_atopile_rev09.zip',
|
|
},
|
|
],
|
|
]);
|
|
const msg = createMessage({
|
|
content: '',
|
|
attachments,
|
|
guildName: 'Server',
|
|
});
|
|
await triggerMessage(msg);
|
|
|
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
'dc:1234567890123456',
|
|
expect.objectContaining({
|
|
content: expect.stringMatching(
|
|
/^\[File: display_latency_atopile_rev09\.zip → .+\.zip\]$/,
|
|
),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('skips oversized file attachments before download', async () => {
|
|
const opts = createTestOpts();
|
|
const channel = new DiscordChannel('test-token', opts);
|
|
await channel.connect();
|
|
|
|
const attachments = new Map([
|
|
[
|
|
'att1',
|
|
{
|
|
id: 'att1',
|
|
name: 'huge.zip',
|
|
contentType: 'application/zip',
|
|
size: 51 * 1024 * 1024,
|
|
url: 'https://cdn.example.com/huge.zip',
|
|
},
|
|
],
|
|
]);
|
|
const msg = createMessage({
|
|
content: '',
|
|
attachments,
|
|
guildName: 'Server',
|
|
});
|
|
await triggerMessage(msg);
|
|
|
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
'dc:1234567890123456',
|
|
expect.objectContaining({
|
|
content:
|
|
'[File: huge.zip (too large to download: 51.0 MiB > 50.0 MiB)]',
|
|
}),
|
|
);
|
|
});
|
|
|
|
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',
|
|
{
|
|
id: 'att1',
|
|
name: 'photo.jpg',
|
|
contentType: 'image/jpeg',
|
|
url: 'https://cdn.example.com/photo.jpg',
|
|
},
|
|
],
|
|
]);
|
|
const msg = createMessage({
|
|
content: 'Check this out',
|
|
attachments,
|
|
guildName: 'Server',
|
|
});
|
|
await triggerMessage(msg);
|
|
|
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
'dc:1234567890123456',
|
|
expect.objectContaining({
|
|
content: expect.stringMatching(/^Check this out\n\[Image: .+\.jpg\]$/),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('handles multiple attachments', async () => {
|
|
const opts = createTestOpts();
|
|
const channel = new DiscordChannel('test-token', opts);
|
|
await channel.connect();
|
|
|
|
const attachments = new Map([
|
|
[
|
|
'att1',
|
|
{
|
|
id: 'att1',
|
|
name: 'a.png',
|
|
contentType: 'image/png',
|
|
url: 'https://cdn.example.com/a.png',
|
|
},
|
|
],
|
|
[
|
|
'att2',
|
|
{
|
|
id: 'att2',
|
|
name: 'b.txt',
|
|
contentType: 'text/plain',
|
|
url: 'https://cdn.example.com/b.txt',
|
|
},
|
|
],
|
|
]);
|
|
const msg = createMessage({
|
|
content: '',
|
|
attachments,
|
|
guildName: 'Server',
|
|
});
|
|
await triggerMessage(msg);
|
|
|
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
'dc:1234567890123456',
|
|
expect.objectContaining({
|
|
content: expect.stringMatching(
|
|
/^\[Image: .+\.png\]\n\[File: b\.txt → .+\.txt\]\nHello from text file$/,
|
|
),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
// --- 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');
|
|
|
|
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('propagates send failure to the caller', async () => {
|
|
const opts = createTestOpts();
|
|
const channel = new DiscordChannel('test-token', opts);
|
|
await channel.connect();
|
|
|
|
currentClient().channels.fetch.mockRejectedValueOnce(
|
|
new Error('Channel not found'),
|
|
);
|
|
|
|
await expect(
|
|
channel.sendMessage('dc:1234567890123456', 'Will fail'),
|
|
).rejects.toThrow('Channel not found');
|
|
});
|
|
|
|
it('rejects when client is not initialized', async () => {
|
|
const opts = createTestOpts();
|
|
const channel = new DiscordChannel('test-token', opts);
|
|
|
|
// Don't connect — client is null
|
|
await expect(
|
|
channel.sendMessage('dc:1234567890123456', 'No client'),
|
|
).rejects.toThrow('Discord client not initialized');
|
|
});
|
|
|
|
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, {
|
|
content: 'x'.repeat(2000),
|
|
files: undefined,
|
|
flags: 1 << 2,
|
|
});
|
|
expect(mockChannel.send).toHaveBeenNthCalledWith(2, {
|
|
content: 'x'.repeat(1000),
|
|
files: undefined,
|
|
flags: 1 << 2,
|
|
});
|
|
});
|
|
|
|
it('sends structured attachments as Discord files', async () => {
|
|
const opts = createTestOpts();
|
|
const channel = new DiscordChannel('test-token', opts);
|
|
await channel.connect();
|
|
const { dir, filePath } = createTempPng('structured.png');
|
|
const mockChannel = {
|
|
send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
|
|
sendTyping: vi.fn(),
|
|
};
|
|
currentClient().channels.fetch.mockResolvedValue(mockChannel);
|
|
|
|
await channel.sendMessage('dc:1234567890123456', '이미지를 생성했습니다.', {
|
|
attachments: [{ path: filePath, name: 'result.png', mime: 'image/png' }],
|
|
});
|
|
|
|
expect(mockChannel.send).toHaveBeenCalledWith({
|
|
content: '이미지를 생성했습니다.',
|
|
files: [{ attachment: filePath, name: 'result.png' }],
|
|
flags: 1 << 2,
|
|
});
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('uses legacy image tags as Discord attachment fallback', async () => {
|
|
const opts = createTestOpts();
|
|
const channel = new DiscordChannel('test-token', opts);
|
|
await channel.connect();
|
|
const { dir, filePath } = createTempPng('screenshot.png');
|
|
const mockChannel = {
|
|
send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
|
|
sendTyping: vi.fn(),
|
|
};
|
|
currentClient().channels.fetch.mockResolvedValue(mockChannel);
|
|
|
|
await channel.sendMessage(
|
|
'dc:1234567890123456',
|
|
`스크린샷입니다.\n[Image: screenshot.png → ${filePath}]`,
|
|
);
|
|
|
|
expect(mockChannel.send).toHaveBeenCalledWith({
|
|
content: '스크린샷입니다.',
|
|
files: [{ attachment: filePath, name: 'screenshot.png' }],
|
|
flags: 1 << 2,
|
|
});
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('logs channel name and Discord message ids after sending', async () => {
|
|
const opts = createTestOpts();
|
|
const channel = new DiscordChannel('test-token', opts);
|
|
await channel.connect();
|
|
|
|
const mockChannel = {
|
|
send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
|
|
sendTyping: vi.fn(),
|
|
};
|
|
currentClient().channels.fetch.mockResolvedValue(mockChannel);
|
|
|
|
await channel.sendMessage('dc:1234567890123456', 'Hello');
|
|
|
|
expect(logger.info).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
jid: 'dc:1234567890123456',
|
|
channelName: 'discord',
|
|
deliveryMode: 'send',
|
|
chunkCount: 1,
|
|
messageId: 'discord-message-1',
|
|
messageIds: ['discord-message-1'],
|
|
}),
|
|
'Discord message sent',
|
|
);
|
|
});
|
|
});
|
|
|
|
// --- ownsJid ---
|
|
|
|
describe('ownsJid', () => {
|
|
it('owns dc: JIDs', () => {
|
|
const channel = new DiscordChannel('test-token', createTestOpts());
|
|
expect(channel.ownsJid('dc:1234567890123456')).toBe(true);
|
|
});
|
|
|
|
it('can be configured as an outbound-only role bot', () => {
|
|
const channel = new DiscordChannel(
|
|
'test-token',
|
|
createTestOpts(),
|
|
undefined,
|
|
'discord-review',
|
|
false,
|
|
false,
|
|
);
|
|
expect(channel.ownsJid('dc:1234567890123456')).toBe(false);
|
|
});
|
|
|
|
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 not send stale typing after typing was disabled mid-flight', async () => {
|
|
const opts = createTestOpts();
|
|
const channel = new DiscordChannel('test-token', opts);
|
|
await channel.connect();
|
|
|
|
let resolveFetch!: (value: unknown) => void;
|
|
const fetchPromise = new Promise((resolve) => {
|
|
resolveFetch = resolve;
|
|
});
|
|
const mockChannel = {
|
|
send: vi.fn(),
|
|
sendTyping: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
currentClient().channels.fetch.mockImplementationOnce(() => fetchPromise);
|
|
|
|
const typingPromise = channel.setTyping('dc:1234567890123456', true);
|
|
await Promise.resolve();
|
|
await channel.setTyping('dc:1234567890123456', false);
|
|
resolveFetch(mockChannel);
|
|
await typingPromise;
|
|
|
|
expect(mockChannel.sendTyping).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');
|
|
});
|
|
});
|