951 lines
31 KiB
TypeScript
951 lines
31 KiB
TypeScript
import { EventEmitter } from 'node:events';
|
|
import type {
|
|
ChannelConversationRouteDto,
|
|
ChannelEgressDto,
|
|
ChannelIngressDto,
|
|
ChannelIngressPort,
|
|
} from '@mosaicstack/types';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
createDiscordIngressEnvelope,
|
|
DiscordPlugin,
|
|
parseDiscordInteractionBindings,
|
|
resolveDiscordInteractionActorId,
|
|
resolveDiscordInteractionBinding,
|
|
verifyDiscordIngressEnvelope,
|
|
type DiscordIngressEnvelope,
|
|
type DiscordInteractionRole,
|
|
type DiscordPluginConfig,
|
|
} from './index.js';
|
|
|
|
const SERVICE_TOKEN = 'test-service-token';
|
|
|
|
interface FakeDiscordMessageOptions {
|
|
id?: string;
|
|
guildId?: string;
|
|
content: string;
|
|
mentioned?: boolean;
|
|
userId?: string;
|
|
channelId?: string;
|
|
parentChannelId?: string | null;
|
|
isThread?: boolean;
|
|
hasThread?: boolean;
|
|
existingThreadId?: string;
|
|
fetchedThreadId?: string;
|
|
createdThreadId?: string;
|
|
attachments?: Map<string, FakeDiscordAttachment>;
|
|
}
|
|
|
|
interface FakeDiscordAttachment {
|
|
id: string;
|
|
name: string;
|
|
url: string;
|
|
contentType: string | null;
|
|
size?: number;
|
|
}
|
|
|
|
interface FakeDiscordMessage {
|
|
id: string;
|
|
guildId: string;
|
|
channelId: string;
|
|
author: { id: string; bot: boolean };
|
|
mentions: { has(user: { id: string }): boolean };
|
|
content: string;
|
|
createdAt: Date;
|
|
channel: {
|
|
parentId: string | null;
|
|
isThread(): boolean;
|
|
threads?: { fetch(id: string): Promise<{ id: string }> };
|
|
};
|
|
attachments: Map<string, FakeDiscordAttachment>;
|
|
hasThread: boolean;
|
|
thread: { id: string } | null;
|
|
startThread: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
interface FakeGatewaySocket extends EventEmitter {
|
|
connected: boolean;
|
|
disconnect?: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
interface FakeLifecycleClient extends EventEmitter {
|
|
user: { id: string; tag: string };
|
|
login: ReturnType<typeof vi.fn>;
|
|
destroy: ReturnType<typeof vi.fn>;
|
|
isReady(): boolean;
|
|
guilds: { cache: Map<string, object> };
|
|
channels: { cache: Map<string, object> };
|
|
}
|
|
|
|
interface DiscordPluginInternals {
|
|
client: {
|
|
user: { id: string };
|
|
isReady(): boolean;
|
|
channels?: {
|
|
cache: { get(id: string): { send(options: unknown): Promise<void> } | undefined };
|
|
};
|
|
};
|
|
socket: {
|
|
connected: boolean;
|
|
emit: ReturnType<typeof vi.fn>;
|
|
} | null;
|
|
conversationRoutes: Map<string, ChannelConversationRouteDto>;
|
|
handleDiscordMessage(message: FakeDiscordMessage): void | Promise<void>;
|
|
sendToDiscord(conversationId: string, text: string): Promise<void>;
|
|
}
|
|
|
|
function lifecyclePlugin(): {
|
|
plugin: DiscordPlugin;
|
|
socket: FakeGatewaySocket;
|
|
client: FakeLifecycleClient;
|
|
} {
|
|
const socket = Object.assign(new EventEmitter(), {
|
|
connected: false,
|
|
disconnect: vi.fn(),
|
|
});
|
|
const client = Object.assign(new EventEmitter(), {
|
|
user: { id: 'bot-001', tag: 'mosaic-bot' },
|
|
login: vi.fn().mockResolvedValue('token'),
|
|
destroy: vi.fn().mockResolvedValue(undefined),
|
|
isReady: (): boolean => true,
|
|
guilds: { cache: new Map<string, object>() },
|
|
channels: { cache: new Map<string, object>() },
|
|
});
|
|
const plugin = new DiscordPlugin(
|
|
{
|
|
token: 'unused',
|
|
gatewayUrl: 'http://gateway.invalid',
|
|
serviceToken: SERVICE_TOKEN,
|
|
allowedGuildIds: ['guild-001'],
|
|
allowedChannelIds: ['channel-001'],
|
|
allowedUserIds: ['user-001'],
|
|
interactionBindings: [
|
|
{
|
|
instanceId: 'Nova',
|
|
agentConfigId: 'agent-config-nova',
|
|
guildId: 'guild-001',
|
|
channelId: 'channel-001',
|
|
pairedUsers: {
|
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-user-001' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
client: client as never,
|
|
socketFactory: vi.fn().mockReturnValue(socket) as never,
|
|
},
|
|
);
|
|
return { plugin, socket, client };
|
|
}
|
|
|
|
function createPlugin(
|
|
role: DiscordInteractionRole = 'operator',
|
|
paired = true,
|
|
ingressPort?: ChannelIngressPort,
|
|
configOverrides: Partial<DiscordPluginConfig> = {},
|
|
): {
|
|
plugin: DiscordPlugin;
|
|
internals: DiscordPluginInternals;
|
|
emit: ReturnType<typeof vi.fn>;
|
|
} {
|
|
const plugin = new DiscordPlugin(
|
|
{
|
|
token: 'unused',
|
|
gatewayUrl: 'http://unused',
|
|
serviceToken: SERVICE_TOKEN,
|
|
allowedGuildIds: ['guild-001'],
|
|
allowedChannelIds: ['channel-001'],
|
|
allowedUserIds: ['user-001'],
|
|
interactionBindings: [
|
|
{
|
|
instanceId: 'Nova',
|
|
agentConfigId: 'agent-config-nova',
|
|
guildId: 'guild-001',
|
|
channelId: 'channel-001',
|
|
pairedUsers: paired
|
|
? {
|
|
'user-001': { role, mosaicUserId: 'mosaic-user-001' },
|
|
}
|
|
: {},
|
|
},
|
|
],
|
|
...configOverrides,
|
|
},
|
|
{ ingressPort },
|
|
);
|
|
const emit = vi.fn();
|
|
const internals = plugin as unknown as DiscordPluginInternals;
|
|
internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => true };
|
|
internals.socket = { connected: true, emit };
|
|
return { plugin, internals, emit };
|
|
}
|
|
|
|
function fakeMessage(options: FakeDiscordMessageOptions): FakeDiscordMessage {
|
|
const channelId = options.channelId ?? 'channel-001';
|
|
const parentChannelId = options.parentChannelId;
|
|
const existingThreadId = options.existingThreadId;
|
|
return {
|
|
id: options.id ?? 'message-001',
|
|
guildId: options.guildId ?? 'guild-001',
|
|
channelId,
|
|
author: { id: options.userId ?? 'user-001', bot: false },
|
|
mentions: { has: (): boolean => options.mentioned ?? false },
|
|
content: options.content,
|
|
createdAt: new Date('2026-07-14T12:00:00.000Z'),
|
|
channel: {
|
|
parentId: parentChannelId ?? null,
|
|
isThread: (): boolean =>
|
|
options.isThread ?? (parentChannelId !== undefined && parentChannelId !== null),
|
|
...(options.fetchedThreadId
|
|
? {
|
|
threads: {
|
|
fetch: vi.fn().mockResolvedValue({ id: options.fetchedThreadId }),
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
attachments: options.attachments ?? new Map<string, FakeDiscordAttachment>(),
|
|
hasThread: options.hasThread ?? existingThreadId !== undefined,
|
|
thread: existingThreadId ? { id: existingThreadId } : null,
|
|
startThread: vi.fn().mockResolvedValue({ id: options.createdThreadId ?? 'thread-created-001' }),
|
|
};
|
|
}
|
|
|
|
function emittedEnvelope(emit: ReturnType<typeof vi.fn>): DiscordIngressEnvelope {
|
|
const call = emit.mock.calls[0] as [string, DiscordIngressEnvelope] | undefined;
|
|
expect(call?.[0]).toBe('message');
|
|
expect(call?.[1]).toBeDefined();
|
|
return (
|
|
call?.[1] ??
|
|
createDiscordIngressEnvelope(
|
|
{
|
|
correlationId: 'unreachable',
|
|
messageId: 'unreachable',
|
|
guildId: 'unreachable',
|
|
channelId: 'unreachable',
|
|
userId: 'unreachable',
|
|
conversationId: 'unreachable',
|
|
content: 'unreachable',
|
|
},
|
|
SERVICE_TOKEN,
|
|
)
|
|
);
|
|
}
|
|
|
|
afterEach((): void => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('Discord binding configuration', () => {
|
|
it('parses role-only and Mosaic-linked pairings', () => {
|
|
const bindings = parseDiscordInteractionBindings(
|
|
JSON.stringify([
|
|
{
|
|
instanceId: 'Nova',
|
|
agentConfigId: 'agent-config-nova',
|
|
guildId: 'guild-001',
|
|
channelId: 'channel-001',
|
|
pairedUsers: {
|
|
'legacy-user': 'operator',
|
|
'linked-user': { role: 'admin', mosaicUserId: 'mosaic-admin-001' },
|
|
'linked-without-id': { role: 'operator' },
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(bindings).toHaveLength(1);
|
|
expect(resolveDiscordInteractionActorId(bindings[0]!, 'legacy-user')).toBeNull();
|
|
expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-user')).toBe('mosaic-admin-001');
|
|
expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-without-id')).toBeNull();
|
|
expect(resolveDiscordInteractionActorId(bindings[0]!, 'missing-user')).toBeNull();
|
|
expect(
|
|
resolveDiscordInteractionBinding(bindings, 'guild-001', 'channel-001', 'linked-user', 'bind'),
|
|
).toBe(bindings[0]);
|
|
expect(
|
|
resolveDiscordInteractionBinding(
|
|
bindings,
|
|
'guild-001',
|
|
'channel-001',
|
|
'linked-without-id',
|
|
'attach',
|
|
),
|
|
).toBe(bindings[0]);
|
|
expect(
|
|
resolveDiscordInteractionBinding(
|
|
bindings,
|
|
'other-guild',
|
|
'channel-001',
|
|
'linked-user',
|
|
'send',
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
['missing value', undefined],
|
|
['empty array', '[]'],
|
|
['non-object binding', '[null]'],
|
|
['missing binding fields', '[{"instanceId":"Nova"}]'],
|
|
[
|
|
'empty Discord user ID',
|
|
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"":"operator"}}]',
|
|
],
|
|
[
|
|
'invalid role-only pairing',
|
|
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":"owner"}}]',
|
|
],
|
|
[
|
|
'non-object pairing',
|
|
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":42}}]',
|
|
],
|
|
[
|
|
'invalid linked pairing',
|
|
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":{"role":"admin","mosaicUserId":" "}}}]',
|
|
],
|
|
])('rejects %s', (_case: string, value: string | undefined) => {
|
|
expect(() => parseDiscordInteractionBindings(value)).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('Discord ingress integrity', () => {
|
|
it.each([
|
|
['guild', { guildIds: ['other'], channelIds: ['channel-001'], userIds: ['user-001'] }],
|
|
['channel', { guildIds: ['guild-001'], channelIds: ['other'], userIds: ['user-001'] }],
|
|
['user', { guildIds: ['guild-001'], channelIds: ['channel-001'], userIds: ['other'] }],
|
|
])('rejects an unallowlisted %s', (_field: string, allowlists) => {
|
|
const payload = {
|
|
correlationId: 'correlation-001',
|
|
messageId: 'message-001',
|
|
guildId: 'guild-001',
|
|
channelId: 'channel-001',
|
|
userId: 'user-001',
|
|
conversationId: 'Nova:discord:channel-001',
|
|
content: 'hello',
|
|
};
|
|
const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);
|
|
|
|
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, allowlists)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Discord adapter lifecycle', () => {
|
|
it('starts while the in-process gateway is still connecting, then reports connected', async () => {
|
|
const { plugin, socket, client } = lifecyclePlugin();
|
|
|
|
await plugin.start();
|
|
|
|
expect(client.login).toHaveBeenCalledWith('unused');
|
|
expect(await plugin.health()).toEqual({ status: 'degraded' });
|
|
socket.connected = true;
|
|
socket.emit('connect');
|
|
expect(await plugin.health()).toEqual({ status: 'connected' });
|
|
await plugin.stop();
|
|
expect(socket.disconnect).toHaveBeenCalledOnce();
|
|
expect(client.destroy).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('keeps running while Socket.IO reconnects after an initial gateway error', async () => {
|
|
const { plugin, socket } = lifecyclePlugin();
|
|
const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined);
|
|
|
|
await plugin.start();
|
|
socket.emit('connect_error', new Error('gateway not listening yet'));
|
|
|
|
expect(error).toHaveBeenCalledWith(
|
|
'[discord] Gateway connection error: gateway not listening yet',
|
|
);
|
|
expect(await plugin.health()).toEqual({ status: 'degraded' });
|
|
});
|
|
|
|
it('cleans up when Discord login fails', async () => {
|
|
const { plugin, socket, client } = lifecyclePlugin();
|
|
client.login.mockRejectedValueOnce(new Error('Discord authentication rejected'));
|
|
|
|
await expect(plugin.start()).rejects.toThrow('Discord authentication rejected');
|
|
|
|
expect(socket.disconnect).toHaveBeenCalledOnce();
|
|
expect(client.destroy).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe('Discord project channel provisioning', () => {
|
|
it('returns null without a configured guild or visible guild', async () => {
|
|
const { plugin } = createPlugin();
|
|
await expect(
|
|
plugin.createProjectChannel({ id: 'project-1', name: 'Alpha' }),
|
|
).resolves.toBeNull();
|
|
|
|
const { client } = lifecyclePlugin();
|
|
const configured = new DiscordPlugin(
|
|
{
|
|
token: 'unused',
|
|
gatewayUrl: 'http://unused',
|
|
serviceToken: SERVICE_TOKEN,
|
|
guildId: 'missing-guild',
|
|
allowedGuildIds: ['guild-001'],
|
|
allowedChannelIds: ['channel-001'],
|
|
allowedUserIds: ['user-001'],
|
|
interactionBindings: [],
|
|
},
|
|
{ client: client as never },
|
|
);
|
|
await expect(
|
|
configured.createProjectChannel({ id: 'project-1', name: 'Alpha' }),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it('creates a normalized Discord project channel', async () => {
|
|
const create = vi.fn().mockResolvedValue({ id: 'created-channel-001' });
|
|
const { client } = lifecyclePlugin();
|
|
client.guilds.cache.set('guild-001', { channels: { create } });
|
|
const plugin = new DiscordPlugin(
|
|
{
|
|
token: 'unused',
|
|
gatewayUrl: 'http://unused',
|
|
serviceToken: SERVICE_TOKEN,
|
|
guildId: 'guild-001',
|
|
allowedGuildIds: ['guild-001'],
|
|
allowedChannelIds: ['channel-001'],
|
|
allowedUserIds: ['user-001'],
|
|
interactionBindings: [],
|
|
},
|
|
{ client: client as never },
|
|
);
|
|
|
|
await expect(
|
|
plugin.createProjectChannel({
|
|
id: 'project-1',
|
|
name: ' Project Alpha! ',
|
|
description: 'Alpha workspace',
|
|
}),
|
|
).resolves.toEqual({ channelId: 'created-channel-001' });
|
|
expect(create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: 'mosaic-project-alpha', topic: 'Alpha workspace' }),
|
|
);
|
|
|
|
await plugin.createProjectChannel({ id: 'project-2', name: 'Beta' });
|
|
expect(create).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ topic: 'Mosaic project: Beta' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
function egressFor(
|
|
route: ChannelConversationRouteDto,
|
|
content = 'agent response',
|
|
): ChannelEgressDto {
|
|
return {
|
|
correlationId: 'egress-correlation-001',
|
|
route,
|
|
message: {
|
|
id: 'egress-message-001',
|
|
channelName: 'discord',
|
|
channelId: route.responseTarget.channelId,
|
|
senderId: route.logicalAgentId,
|
|
senderKind: 'agent',
|
|
content,
|
|
contentKind: 'markdown',
|
|
timestamp: '2026-07-14T12:00:00.000Z',
|
|
metadata: {},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('official Discord channel routing', () => {
|
|
it('normalizes an authorized turn through the shared channel ingress port', async () => {
|
|
const receive = vi.fn<(ingress: ChannelIngressDto) => Promise<void>>().mockResolvedValue();
|
|
const { internals, emit } = createPlugin('operator', true, { receive });
|
|
internals.socket = null;
|
|
|
|
await internals.handleDiscordMessage(fakeMessage({ content: 'normalized turn' }));
|
|
|
|
expect(emit).not.toHaveBeenCalled();
|
|
expect(receive).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
operation: 'message.send',
|
|
principal: {
|
|
channelUserId: 'user-001',
|
|
role: 'operator',
|
|
mosaicUserId: 'mosaic-user-001',
|
|
},
|
|
message: expect.objectContaining({
|
|
channelName: 'discord',
|
|
channelId: 'channel-001',
|
|
content: 'normalized turn',
|
|
senderKind: 'user',
|
|
}),
|
|
route: expect.objectContaining({
|
|
logicalAgentId: 'Nova',
|
|
conversationId: 'Nova:discord:channel-001',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('releases a response route when typed ingress rejects', async () => {
|
|
const receive = vi.fn().mockRejectedValue(new Error('gateway rejected ingress'));
|
|
const { internals } = createPlugin('operator', true, { receive });
|
|
|
|
await expect(
|
|
internals.handleDiscordMessage(fakeMessage({ content: 'rejected typed ingress' })),
|
|
).rejects.toThrow('gateway rejected ingress');
|
|
|
|
expect(internals.conversationRoutes).toHaveLength(0);
|
|
});
|
|
|
|
it('routes an authorized untagged parent-channel message and responds in that channel', async () => {
|
|
const { internals, emit } = createPlugin();
|
|
const message = fakeMessage({ content: 'channel conversation' });
|
|
|
|
await internals.handleDiscordMessage(message);
|
|
|
|
expect(message.startThread).not.toHaveBeenCalled();
|
|
const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN);
|
|
expect(payload).toMatchObject({
|
|
channelId: 'channel-001',
|
|
conversationId: 'Nova:discord:channel-001',
|
|
content: 'channel conversation',
|
|
});
|
|
expect(payload?.threadId).toBeUndefined();
|
|
});
|
|
|
|
it('creates a thread for a mentioned parent-channel message and routes the response there', async () => {
|
|
const { internals, emit } = createPlugin();
|
|
const message = fakeMessage({
|
|
content: '<@bot-001> investigate this topic',
|
|
mentioned: true,
|
|
createdThreadId: 'thread-created-001',
|
|
});
|
|
|
|
await internals.handleDiscordMessage(message);
|
|
|
|
expect(message.startThread).toHaveBeenCalledOnce();
|
|
const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN);
|
|
expect(payload).toMatchObject({
|
|
channelId: 'channel-001',
|
|
conversationId: 'Nova:discord:thread-created-001',
|
|
content: 'investigate this topic',
|
|
threadId: 'thread-created-001',
|
|
});
|
|
});
|
|
|
|
it('fetches and reuses an existing thread that is missing from the cache', async () => {
|
|
const { internals, emit } = createPlugin();
|
|
const message = fakeMessage({
|
|
content: '<@bot-001> continue uncached topic',
|
|
mentioned: true,
|
|
hasThread: true,
|
|
fetchedThreadId: 'thread-fetched-001',
|
|
});
|
|
|
|
await internals.handleDiscordMessage(message);
|
|
|
|
expect(message.startThread).not.toHaveBeenCalled();
|
|
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
|
|
conversationId: 'Nova:discord:thread-fetched-001',
|
|
threadId: 'thread-fetched-001',
|
|
});
|
|
});
|
|
|
|
it('delivers the agent response to the thread selected by the mentioned turn', async () => {
|
|
const { internals } = createPlugin();
|
|
const send = vi.fn().mockResolvedValue(undefined);
|
|
internals.client = {
|
|
user: { id: 'bot-001' },
|
|
isReady: (): boolean => true,
|
|
channels: {
|
|
cache: {
|
|
get: (id: string): { send(options: unknown): Promise<void> } | undefined =>
|
|
id === 'thread-response-001' ? { send } : undefined,
|
|
},
|
|
},
|
|
};
|
|
|
|
await internals.handleDiscordMessage(
|
|
fakeMessage({
|
|
content: '<@bot-001> threaded response',
|
|
mentioned: true,
|
|
createdThreadId: 'thread-response-001',
|
|
}),
|
|
);
|
|
await internals.sendToDiscord('Nova:discord:thread-response-001', 'agent answer');
|
|
|
|
expect(send).toHaveBeenCalledWith(
|
|
expect.objectContaining({ content: 'agent answer', enforceNonce: true }),
|
|
);
|
|
});
|
|
|
|
it('reuses a thread already attached to a mentioned message instead of creating another', async () => {
|
|
const { internals, emit } = createPlugin();
|
|
const message = fakeMessage({
|
|
content: '<@bot-001> continue existing topic',
|
|
mentioned: true,
|
|
existingThreadId: 'thread-existing-001',
|
|
});
|
|
|
|
await internals.handleDiscordMessage(message);
|
|
|
|
expect(message.startThread).not.toHaveBeenCalled();
|
|
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
|
|
conversationId: 'Nova:discord:thread-existing-001',
|
|
threadId: 'thread-existing-001',
|
|
});
|
|
});
|
|
|
|
it('keeps an untagged follow-up inside an authorized thread without nesting threads', async () => {
|
|
const { internals, emit } = createPlugin();
|
|
const message = fakeMessage({
|
|
content: 'thread follow-up',
|
|
channelId: 'thread-001',
|
|
parentChannelId: 'channel-001',
|
|
});
|
|
|
|
await internals.handleDiscordMessage(message);
|
|
|
|
expect(message.startThread).not.toHaveBeenCalled();
|
|
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
|
|
channelId: 'channel-001',
|
|
conversationId: 'Nova:discord:thread-001',
|
|
content: 'thread follow-up',
|
|
threadId: 'thread-001',
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['guild', { guildId: 'guild-not-allowed' }],
|
|
['channel', { channelId: 'channel-not-allowed' }],
|
|
['user', { userId: 'user-not-allowed' }],
|
|
])(
|
|
'rejects an unauthorized %s before thread creation or gateway dispatch',
|
|
async (_boundary: string, override: Partial<FakeDiscordMessageOptions>) => {
|
|
const { internals, emit } = createPlugin();
|
|
const message = fakeMessage({
|
|
content: '<@bot-001> unauthorized topic',
|
|
mentioned: true,
|
|
...override,
|
|
});
|
|
|
|
await internals.handleDiscordMessage(message);
|
|
|
|
expect(message.startThread).not.toHaveBeenCalled();
|
|
expect(emit).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
['parent channel', {}],
|
|
['existing thread', { channelId: 'thread-unpaired-001', parentChannelId: 'channel-001' }],
|
|
])(
|
|
'rejects an allowlisted but unpaired user in %s',
|
|
async (_location: string, override: Partial<FakeDiscordMessageOptions>) => {
|
|
const { internals, emit } = createPlugin('operator', false);
|
|
const message = fakeMessage({ content: 'unpaired message', ...override });
|
|
|
|
await internals.handleDiscordMessage(message);
|
|
|
|
expect(message.startThread).not.toHaveBeenCalled();
|
|
expect(emit).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('rejects a paired viewer before thread creation or gateway dispatch', async () => {
|
|
const { internals, emit } = createPlugin('viewer');
|
|
const message = fakeMessage({
|
|
content: '<@bot-001> viewer cannot send',
|
|
mentioned: true,
|
|
});
|
|
|
|
await internals.handleDiscordMessage(message);
|
|
|
|
expect(message.startThread).not.toHaveBeenCalled();
|
|
expect(emit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses a normal channel ID rather than its category parent for authorization', async () => {
|
|
const { internals, emit } = createPlugin();
|
|
const message = fakeMessage({
|
|
content: 'message from categorized channel',
|
|
parentChannelId: 'category-001',
|
|
isThread: false,
|
|
});
|
|
|
|
await internals.handleDiscordMessage(message);
|
|
|
|
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
|
|
channelId: 'channel-001',
|
|
conversationId: 'Nova:discord:channel-001',
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['parent channel', {}],
|
|
['existing thread', { channelId: 'thread-attachment-001', parentChannelId: 'channel-001' }],
|
|
])(
|
|
'preserves an attachment-only turn in an authorized %s',
|
|
async (_location: string, override: Partial<FakeDiscordMessageOptions>) => {
|
|
const { internals, emit } = createPlugin();
|
|
const attachments = new Map<string, FakeDiscordAttachment>([
|
|
[
|
|
'attachment-001',
|
|
{
|
|
id: 'attachment-001',
|
|
name: 'diagram.png',
|
|
url: 'https://cdn.example.invalid/diagram.png',
|
|
contentType: 'image/png',
|
|
size: 4_096,
|
|
},
|
|
],
|
|
]);
|
|
|
|
await internals.handleDiscordMessage(fakeMessage({ content: '', attachments, ...override }));
|
|
|
|
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
|
|
content: '',
|
|
attachments: [
|
|
{
|
|
id: 'attachment-001',
|
|
name: 'diagram.png',
|
|
contentType: 'image/png',
|
|
sizeBytes: 4_096,
|
|
},
|
|
],
|
|
});
|
|
},
|
|
);
|
|
|
|
it('does not dispatch when Discord cannot create the requested thread', async () => {
|
|
const { internals, emit } = createPlugin();
|
|
const message = fakeMessage({ content: '<@bot-001> new topic', mentioned: true });
|
|
message.startThread.mockRejectedValueOnce(new Error('missing thread permission'));
|
|
|
|
await expect(internals.handleDiscordMessage(message)).rejects.toThrow(
|
|
'missing thread permission',
|
|
);
|
|
expect(emit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(['/approve', '/stop approval-001'])(
|
|
'rejects operator use of privileged command %s before gateway dispatch',
|
|
async (command: string) => {
|
|
const { internals, emit } = createPlugin('operator');
|
|
const message = fakeMessage({ content: `<@bot-001> ${command}`, mentioned: true });
|
|
|
|
await internals.handleDiscordMessage(message);
|
|
|
|
expect(message.startThread).not.toHaveBeenCalled();
|
|
expect(emit).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('keeps runtime control commands on the current durable session', async () => {
|
|
const { internals, emit } = createPlugin('admin');
|
|
const message = fakeMessage({ content: '<@bot-001> /approve', mentioned: true });
|
|
|
|
await internals.handleDiscordMessage(message);
|
|
|
|
expect(message.startThread).not.toHaveBeenCalled();
|
|
expect(emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
|
|
});
|
|
|
|
it('keeps the stable conversation address independent of a runtime harness', async () => {
|
|
const first = createPlugin();
|
|
const second = createPlugin();
|
|
|
|
await first.internals.handleDiscordMessage(
|
|
fakeMessage({ id: 'message-claude', content: 'before runtime handoff' }),
|
|
);
|
|
await second.internals.handleDiscordMessage(
|
|
fakeMessage({ id: 'message-pi', content: 'after runtime handoff' }),
|
|
);
|
|
|
|
const firstPayload = verifyDiscordIngressEnvelope(emittedEnvelope(first.emit), SERVICE_TOKEN);
|
|
const secondPayload = verifyDiscordIngressEnvelope(emittedEnvelope(second.emit), SERVICE_TOKEN);
|
|
expect(firstPayload?.conversationId).toBe('Nova:discord:channel-001');
|
|
expect(secondPayload?.conversationId).toBe(firstPayload?.conversationId);
|
|
});
|
|
|
|
it('rate-limits authorized turns before thread creation or dispatch', async () => {
|
|
const { internals, emit } = createPlugin('operator', true, undefined, {
|
|
messageRateLimitPerMinute: 1,
|
|
threadRateLimitPerMinute: 1,
|
|
});
|
|
const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined);
|
|
const first = fakeMessage({ id: 'rate-first', content: 'first turn' });
|
|
const second = fakeMessage({
|
|
id: 'rate-second',
|
|
content: '<@bot-001> second turn',
|
|
mentioned: true,
|
|
});
|
|
|
|
await internals.handleDiscordMessage(first);
|
|
await internals.handleDiscordMessage(second);
|
|
|
|
expect(emit).toHaveBeenCalledOnce();
|
|
expect(second.startThread).not.toHaveBeenCalled();
|
|
expect(error).toHaveBeenCalledWith(expect.stringContaining('Message rate limit reached'));
|
|
});
|
|
|
|
it('applies a stricter mention-thread rate limit before Discord side effects', async () => {
|
|
const { internals, emit } = createPlugin('operator', true, undefined, {
|
|
messageRateLimitPerMinute: 10,
|
|
threadRateLimitPerMinute: 1,
|
|
});
|
|
vi.spyOn(console, 'error').mockImplementation((): void => undefined);
|
|
const first = fakeMessage({ id: 'thread-rate-first', content: 'first topic', mentioned: true });
|
|
const second = fakeMessage({
|
|
id: 'thread-rate-second',
|
|
content: 'second topic',
|
|
mentioned: true,
|
|
});
|
|
|
|
await internals.handleDiscordMessage(first);
|
|
await internals.handleDiscordMessage(second);
|
|
|
|
expect(first.startThread).toHaveBeenCalledOnce();
|
|
expect(second.startThread).not.toHaveBeenCalled();
|
|
expect(emit).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('rejects unconfigured egress and handles missing or failed Discord destinations', async () => {
|
|
const { plugin, internals } = createPlugin();
|
|
const route: ChannelConversationRouteDto = {
|
|
bindingId: 'guild-001:channel-001:Nova',
|
|
logicalAgentId: 'Nova',
|
|
conversationId: 'Nova:discord:channel-001',
|
|
channelName: 'discord',
|
|
authorizationChannelId: 'channel-001',
|
|
responseTarget: { channelId: 'channel-001' },
|
|
};
|
|
|
|
await expect(
|
|
plugin.send(
|
|
egressFor({
|
|
...route,
|
|
bindingId: 'forged-binding',
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ code: 'invalid_route' });
|
|
await expect(
|
|
plugin.send(
|
|
egressFor({
|
|
...route,
|
|
conversationId: 'Nova:discord:unrelated-conversation',
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ code: 'invalid_route' });
|
|
await expect(
|
|
plugin.send({
|
|
...egressFor(route),
|
|
message: { ...egressFor(route).message, channelId: 'other-channel' },
|
|
}),
|
|
).rejects.toMatchObject({ code: 'invalid_route' });
|
|
|
|
const forgedSend = vi.fn().mockResolvedValue(undefined);
|
|
internals.client = {
|
|
user: { id: 'bot-001' },
|
|
isReady: (): boolean => true,
|
|
channels: {
|
|
cache: {
|
|
get: (): { send(options: unknown): Promise<void> } => ({ send: forgedSend }),
|
|
},
|
|
},
|
|
};
|
|
await expect(
|
|
plugin.send(
|
|
egressFor({
|
|
...route,
|
|
conversationId: 'Nova:discord:forged-channel',
|
|
responseTarget: { channelId: 'forged-channel', threadId: 'forged-channel' },
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ code: 'invalid_route' });
|
|
expect(forgedSend).not.toHaveBeenCalled();
|
|
|
|
internals.client = {
|
|
user: { id: 'bot-001' },
|
|
isReady: (): boolean => true,
|
|
channels: { cache: { get: (): undefined => undefined } },
|
|
};
|
|
await expect(plugin.send(egressFor(route))).rejects.toMatchObject({
|
|
code: 'destination_unavailable',
|
|
});
|
|
await expect(
|
|
internals.sendToDiscord('missing-conversation', 'missing route'),
|
|
).rejects.toMatchObject({ code: 'invalid_route' });
|
|
|
|
vi.useFakeTimers();
|
|
const send = vi
|
|
.fn()
|
|
.mockRejectedValue(Object.assign(new Error('Discord unavailable'), { status: 503 }));
|
|
internals.client.channels = {
|
|
cache: { get: (): { send(options: unknown): Promise<void> } => ({ send }) },
|
|
};
|
|
const delivery = plugin.send(egressFor(route));
|
|
const rejection = expect(delivery).rejects.toMatchObject({
|
|
code: 'delivery_failed',
|
|
retryable: true,
|
|
});
|
|
await vi.runAllTimersAsync();
|
|
await rejection;
|
|
|
|
expect(send).toHaveBeenCalledTimes(3);
|
|
expect(new Set(send.mock.calls.map(([options]) => options.nonce)).size).toBe(1);
|
|
expect(send).toHaveBeenCalledWith(
|
|
expect.objectContaining({ enforceNonce: true, content: 'agent response' }),
|
|
);
|
|
|
|
const permanentSend = vi
|
|
.fn()
|
|
.mockRejectedValue(Object.assign(new Error('Discord forbidden'), { status: 403 }));
|
|
internals.client.channels = {
|
|
cache: {
|
|
get: (): { send(options: unknown): Promise<void> } => ({ send: permanentSend }),
|
|
},
|
|
};
|
|
await expect(plugin.send(egressFor(route))).rejects.toMatchObject({
|
|
code: 'delivery_failed',
|
|
retryable: false,
|
|
});
|
|
expect(permanentSend).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('chunks long egress responses at Discord-safe boundaries', async () => {
|
|
const { plugin, internals } = createPlugin();
|
|
const send = vi.fn().mockResolvedValue(undefined);
|
|
internals.client = {
|
|
user: { id: 'bot-001' },
|
|
isReady: (): boolean => true,
|
|
channels: {
|
|
cache: { get: (): { send(options: unknown): Promise<void> } => ({ send }) },
|
|
},
|
|
};
|
|
const route: ChannelConversationRouteDto = {
|
|
bindingId: 'guild-001:channel-001:Nova',
|
|
logicalAgentId: 'Nova',
|
|
conversationId: 'Nova:discord:channel-001',
|
|
channelName: 'discord',
|
|
authorizationChannelId: 'channel-001',
|
|
responseTarget: { channelId: 'channel-001' },
|
|
};
|
|
|
|
await plugin.send(egressFor(route, `${'a'.repeat(1_500)}\n${'b'.repeat(1_500)}`));
|
|
|
|
expect(send).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('reports channel adapter health without exposing runtime-provider state', async () => {
|
|
const { plugin, internals } = createPlugin();
|
|
|
|
expect(await plugin.health()).toEqual({ status: 'connected' });
|
|
internals.socket = { connected: false, emit: vi.fn() };
|
|
expect(await plugin.health()).toEqual({ status: 'degraded' });
|
|
internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => false };
|
|
expect(await plugin.health()).toEqual({ status: 'disconnected' });
|
|
internals.socket = { connected: true, emit: vi.fn() };
|
|
expect(await plugin.health()).toEqual({ status: 'degraded' });
|
|
});
|
|
});
|