Fixes #756 (#763)
Some checks failed
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled

This commit was merged in pull request #763.
This commit is contained in:
2026-07-14 20:26:15 +00:00
parent c32d85a337
commit ba13c08890
30 changed files with 2914 additions and 365 deletions

View File

@@ -0,0 +1,950 @@
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' });
});
});

View File

@@ -1,10 +1,38 @@
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { ChannelDeliveryError } from '@mosaicstack/types';
import type {
ChannelAdapterHealthDto,
ChannelAuthorizedPrincipalDto,
ChannelConversationRouteDto,
ChannelEgressDto,
ChannelEgressPort,
ChannelIngressDto,
ChannelIngressPort,
ChannelMessageDto,
OfficialChannelAdapter,
} from '@mosaicstack/types';
import {
ChannelType,
Client,
GatewayIntentBits,
ThreadAutoArchiveDuration,
type Message as DiscordMessage,
} from 'discord.js';
import { io, type Socket } from 'socket.io-client';
export interface DiscordPluginDependencies {
ingressPort?: ChannelIngressPort;
client?: Client;
socketFactory?: (url: string, options: Parameters<typeof io>[1]) => Socket;
}
export interface DiscordPluginConfig {
token: string;
gatewayUrl: string;
/** Maximum authorized turns per Discord user/channel per minute. */
messageRateLimitPerMinute?: number;
/** Maximum mention-triggered thread routes per Discord user/channel per minute. */
threadRateLimitPerMinute?: number;
/** Shared service credential injected by the approved secret mechanism. */
serviceToken: string;
/** Which guild to bind to (single-guild only for v0.1.0). */
@@ -30,13 +58,23 @@ export interface DiscordInteractionUserBinding {
export type DiscordInteractionPairing = DiscordInteractionRole | DiscordInteractionUserBinding;
export interface DiscordInteractionBinding {
/** Stable logical agent identity used in channel conversation routes. */
instanceId: string;
/** Trusted gateway database agent-config ID selected for this binding. */
agentConfigId: string;
guildId: string;
channelId: string;
/** Pairing roster keyed by Discord user ID. */
pairedUsers: Readonly<Record<string, DiscordInteractionPairing>>;
}
const DEFAULT_MESSAGE_RATE_LIMIT_PER_MINUTE = 30;
const DEFAULT_THREAD_RATE_LIMIT_PER_MINUTE = 5;
const RATE_LIMIT_WINDOW_MS = 60_000;
const DELIVERY_MAX_ATTEMPTS = 3;
const DELIVERY_RETRY_BASE_MS = 50;
const MAX_CONVERSATION_ROUTES = 1_000;
const operationRoles: Readonly<
Record<DiscordInteractionOperation, readonly DiscordInteractionRole[]>
> = {
@@ -90,6 +128,7 @@ export function parseDiscordInteractionBindings(
const candidate = binding as Partial<DiscordInteractionBinding>;
if (
!candidate.instanceId ||
!candidate.agentConfigId ||
!candidate.guildId ||
!candidate.channelId ||
!candidate.pairedUsers ||
@@ -130,6 +169,7 @@ export function parseDiscordInteractionBindings(
) as Record<string, DiscordInteractionPairing>;
return {
instanceId: candidate.instanceId,
agentConfigId: candidate.agentConfigId,
guildId: candidate.guildId,
channelId: candidate.channelId,
pairedUsers,
@@ -154,6 +194,7 @@ export interface DiscordAttachment {
name: string;
url: string;
contentType: string | null;
sizeBytes?: number;
}
export interface DiscordIngressEnvelope {
@@ -229,27 +270,37 @@ export function verifyDiscordIngressEnvelope(
return envelope.payload;
}
export class DiscordPlugin {
export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort {
readonly name = 'discord';
private client: Client;
private socket: Socket | null = null;
/** Map Discord channel ID → Mosaic conversation ID. */
private channelConversations = new Map<string, string>();
/** Bounded last-authorized routes for response-target egress validation. */
private conversationRoutes = new Map<string, ChannelConversationRouteDto>();
/** Track in-flight responses to avoid duplicate streaming. */
private pendingResponses = new Map<string, string>();
private readonly messageRateWindows = new Map<string, number[]>();
private readonly threadRateWindows = new Map<string, number[]>();
constructor(private readonly config: DiscordPluginConfig) {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
});
constructor(
private readonly config: DiscordPluginConfig,
private readonly dependencies: DiscordPluginDependencies = {},
) {
this.client =
dependencies.client ??
new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
});
}
async start(): Promise<void> {
this.socket = io(`${this.config.gatewayUrl}/chat`, {
const socketFactory = this.dependencies.socketFactory ?? io;
this.socket = socketFactory(`${this.config.gatewayUrl}/chat`, {
auth: { discordServiceToken: this.config.serviceToken },
transports: ['websocket'],
});
@@ -276,11 +327,13 @@ export class DiscordPlugin {
this.socket.on('agent:end', (data: { conversationId: string }) => {
const text = this.pendingResponses.get(data.conversationId);
this.pendingResponses.delete(data.conversationId);
if (text) {
this.pendingResponses.delete(data.conversationId);
this.sendToDiscord(data.conversationId, text).catch((err: unknown) => {
this.sendAgentResponse(data.conversationId, text).catch((err: unknown) => {
console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
});
} else {
this.conversationRoutes.delete(data.conversationId);
}
});
@@ -288,15 +341,33 @@ export class DiscordPlugin {
this.pendingResponses.set(data.conversationId, '');
});
this.client.on('messageCreate', (message: DiscordMessage) =>
this.handleDiscordMessage(message),
);
this.socket.on('error', (data: { conversationId?: unknown }) => {
if (typeof data.conversationId !== 'string') return;
this.pendingResponses.delete(data.conversationId);
this.conversationRoutes.delete(data.conversationId);
});
this.client.on('messageCreate', (message: DiscordMessage) => {
void Promise.resolve(this.handleDiscordMessage(message)).catch((error: unknown): void => {
const errorName = error instanceof Error ? error.name : 'UnknownError';
console.error(
`[discord] Message routing failed. channel=${message.channelId} message=${message.id} error=${errorName}`,
);
});
});
this.client.on('ready', () => {
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
});
await this.client.login(this.config.token);
try {
await this.client.login(this.config.token);
} catch (error: unknown) {
this.socket.disconnect();
this.socket = null;
await this.client.destroy();
throw error;
}
}
async stop(): Promise<void> {
@@ -304,6 +375,14 @@ export class DiscordPlugin {
await this.client.destroy();
}
async health(): Promise<ChannelAdapterHealthDto> {
const discordReady = this.client.isReady();
const gatewayConnected = this.socket?.connected === true;
if (discordReady && gatewayConnected) return { status: 'connected' };
if (discordReady || gatewayConnected) return { status: 'degraded' };
return { status: 'disconnected' };
}
async createProjectChannel(project: {
id: string;
name: string;
@@ -325,77 +404,224 @@ export class DiscordPlugin {
topic: project.description ?? `Mosaic project: ${project.name}`,
});
this.channelConversations.set(channel.id, `discord-${channel.id}`);
// A project channel has no logical-agent conversation until a configured
// binding authorizes a message. Do not seed a legacy channel-only key.
return { channelId: channel.id };
}
private handleDiscordMessage(message: DiscordMessage): void {
if (message.author.bot || !this.client.user) return;
if (!message.guildId || !this.isAllowedMessage(message)) return;
private handleDiscordMessage(message: DiscordMessage): void | Promise<void> {
if (message.author.bot || !this.client.user || !message.guildId) return;
const authorizationChannelId = this.authorizationChannelId(message);
if (!this.isAllowedMessage(message, authorizationChannelId)) return;
const isMention = message.mentions.has(this.client.user);
if (!isMention) return;
const content = isMention
? message.content.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '').trim()
: message.content.trim();
if (!content && message.attachments.size === 0) return;
const operation = this.interactionOperation(content);
const binding = resolveDiscordInteractionBinding(
this.config.interactionBindings ?? [],
message.guildId,
authorizationChannelId,
message.author.id,
operation,
);
// Pairing and operation-specific role checks happen before thread creation
// or any gateway dispatch, including privileged control events.
if (!binding) return;
const content = message.content
.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '')
.trim();
if (!content) return;
if (!this.socket?.connected) {
const rateKey = `${message.guildId}:${authorizationChannelId}:${message.author.id}`;
if (
!this.consumeRateLimit(
this.messageRateWindows,
rateKey,
this.config.messageRateLimitPerMinute ?? DEFAULT_MESSAGE_RATE_LIMIT_PER_MINUTE,
)
) {
console.error(
`[discord] Message rate limit reached. guild=${message.guildId} channel=${authorizationChannelId} user=${message.author.id}`,
);
return;
}
const createThread = isMention && operation === 'send';
if (
createThread &&
!this.consumeRateLimit(
this.threadRateWindows,
rateKey,
this.config.threadRateLimitPerMinute ?? DEFAULT_THREAD_RATE_LIMIT_PER_MINUTE,
)
) {
console.error(
`[discord] Thread rate limit reached. guild=${message.guildId} channel=${authorizationChannelId} user=${message.author.id}`,
);
return;
}
if (!this.dependencies.ingressPort && !this.socket?.connected) {
console.error(
`[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`,
);
return;
}
const channelId = message.channelId;
const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null;
const bindingChannelId = parentChannelId ?? channelId;
const binding = resolveDiscordInteractionBinding(
this.config.interactionBindings ?? [],
message.guildId,
bindingChannelId,
message.author.id,
'send',
// Approval/stop commands act on the current durable session. They remain
// at the current channel/thread target rather than creating a new topic.
const route = this.resolveConversationRoute(
message,
binding,
authorizationChannelId,
createThread,
);
if (!binding) return;
const conversationId =
this.channelConversations.get(channelId) ?? `${binding.instanceId}:discord:${channelId}`;
this.channelConversations.set(channelId, conversationId);
if (route instanceof Promise) {
return route.then((resolved: ChannelConversationRouteDto): void | Promise<void> =>
this.dispatchDiscordIngress(message, content, operation, binding, resolved),
);
}
return this.dispatchDiscordIngress(message, content, operation, binding, route);
}
private dispatchDiscordIngress(
message: DiscordMessage,
content: string,
operation: 'send' | 'approve' | 'stop',
binding: DiscordInteractionBinding,
route: ChannelConversationRouteDto,
): void | Promise<void> {
const guildId = message.guildId;
if (!guildId) return;
if (operation === 'send') this.rememberConversationRoute(route);
const correlationId = randomUUID();
const ingress: ChannelIngressDto = {
correlationId,
nativeMessageId: message.id,
operation:
operation === 'approve'
? 'approval.create'
: operation === 'stop'
? 'session.stop'
: 'message.send',
principal: this.authorizedPrincipal(binding, message.author.id),
message: this.channelMessage(message, content, route),
route,
};
if (this.dependencies.ingressPort) {
return this.dependencies.ingressPort.receive(ingress).catch((error: unknown) => {
if (operation === 'send') this.conversationRoutes.delete(route.conversationId);
throw error;
});
}
const socket = this.socket;
if (!socket?.connected) {
console.error(
`[discord] Cannot dispatch routed message: gateway disconnected. channel=${message.channelId} message=${message.id}`,
);
return;
}
this.emitDiscordIngress(socket, ingress);
}
private authorizedPrincipal(
binding: DiscordInteractionBinding,
channelUserId: string,
): ChannelAuthorizedPrincipalDto {
const pairing = binding.pairedUsers[channelUserId];
if (!pairing) throw new Error('Authorized Discord pairing is unavailable');
if (typeof pairing === 'string') return { channelUserId, role: pairing };
return {
channelUserId,
role: pairing.role,
...(pairing.mosaicUserId ? { mosaicUserId: pairing.mosaicUserId } : {}),
};
}
private channelMessage(
message: DiscordMessage,
content: string,
route: ChannelConversationRouteDto,
): ChannelMessageDto {
const attachments = Array.from(message.attachments.values()).map((attachment) => ({
id: attachment.id,
name: attachment.name,
url: attachment.url,
mimeType: attachment.contentType,
sizeBytes: attachment.size,
}));
const firstContentType = attachments[0]?.mimeType;
return {
id: randomUUID(),
channelName: this.name,
channelId: route.responseTarget.channelId,
senderId: message.author.id,
senderKind: 'user',
content,
contentKind:
content.length > 0 ? 'markdown' : firstContentType?.startsWith('image/') ? 'image' : 'file',
timestamp:
message.createdAt instanceof Date
? message.createdAt.toISOString()
: new Date().toISOString(),
...(route.responseTarget.threadId ? { threadId: route.responseTarget.threadId } : {}),
...(attachments.length > 0 ? { attachments } : {}),
metadata: {
channelMessageId: message.id,
guildId: message.guildId ?? '',
},
};
}
private emitDiscordIngress(socket: Socket, ingress: ChannelIngressDto): void {
const envelope = createDiscordIngressEnvelope(
{
correlationId: randomUUID(),
messageId: message.id,
guildId: message.guildId,
channelId: bindingChannelId,
userId: message.author.id,
conversationId,
content,
threadId: parentChannelId ? channelId : undefined,
attachments: Array.from(message.attachments.values()).map((attachment) => ({
correlationId: ingress.correlationId,
messageId: ingress.nativeMessageId,
guildId: String(ingress.message.metadata['guildId'] ?? ''),
channelId: ingress.route.authorizationChannelId,
userId: ingress.principal.channelUserId,
conversationId: ingress.route.conversationId,
content: ingress.message.content,
...(ingress.route.responseTarget.threadId
? { threadId: ingress.route.responseTarget.threadId }
: {}),
attachments: ingress.message.attachments?.map((attachment) => ({
id: attachment.id,
name: attachment.name,
url: attachment.url,
contentType: attachment.contentType,
contentType: attachment.mimeType,
...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}),
})),
},
this.config.serviceToken,
);
this.socket.emit(
/^\/approve$/i.test(content)
socket.emit(
ingress.operation === 'approval.create'
? 'discord:approve'
: content.startsWith('/stop ')
: ingress.operation === 'session.stop'
? 'discord:stop'
: 'message',
envelope,
);
}
private isAllowedMessage(message: DiscordMessage): boolean {
private authorizationChannelId(message: DiscordMessage): string {
// A normal guild channel can itself have a category parent. Only Discord
// threads inherit authorization from a configured parent text channel.
const channel = message.channel as DiscordMessage['channel'] & {
isThread?: () => boolean;
parentId?: string | null;
};
const isThread =
typeof channel.isThread === 'function'
? channel.isThread()
: channel.parentId !== undefined && channel.parentId !== null;
return isThread && channel.parentId ? channel.parentId : message.channelId;
}
private isAllowedMessage(message: DiscordMessage, authorizationChannelId: string): boolean {
const guildId = message.guildId;
const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null;
// Threads inherit their authorization boundary from their configured parent.
const authorizationChannelId = parentChannelId ?? message.channelId;
return (
guildId !== null &&
includesId(this.config.allowedGuildIds, guildId) &&
@@ -404,31 +630,282 @@ export class DiscordPlugin {
);
}
private async sendToDiscord(conversationId: string, text: string): Promise<void> {
const channelId = Array.from(this.channelConversations.entries()).find(
([, convId]) => convId === conversationId,
)?.[0];
if (!channelId) {
console.error(`[discord] No channel found for conversation ${conversationId}`);
return;
private resolveConversationRoute(
message: DiscordMessage,
binding: DiscordInteractionBinding,
authorizationChannelId: string,
createThread: boolean,
): ChannelConversationRouteDto | Promise<ChannelConversationRouteDto> {
if (authorizationChannelId !== message.channelId) {
return this.createConversationRoute(
binding,
authorizationChannelId,
message.channelId,
message.channelId,
);
}
if (!createThread) {
return this.createConversationRoute(binding, authorizationChannelId, message.channelId);
}
if (message.hasThread) {
const cachedThread = message.thread;
if (cachedThread) {
return this.createConversationRoute(
binding,
authorizationChannelId,
cachedThread.id,
cachedThread.id,
);
}
if (!('threads' in message.channel)) {
return Promise.reject(new Error('Existing Discord thread manager is unavailable'));
}
return message.channel.threads
.fetch(message.id)
.then((thread): ChannelConversationRouteDto => {
if (!thread) throw new Error('Existing Discord thread is unavailable');
return this.createConversationRoute(
binding,
authorizationChannelId,
thread.id,
thread.id,
);
});
}
return message
.startThread({
name: `Mosaic conversation ${message.id.slice(-8)}`,
autoArchiveDuration: ThreadAutoArchiveDuration.OneHour,
reason: 'Authorized Mosaic mention',
})
.then(
(thread): ChannelConversationRouteDto =>
this.createConversationRoute(binding, authorizationChannelId, thread.id, thread.id),
);
}
private createConversationRoute(
binding: DiscordInteractionBinding,
authorizationChannelId: string,
responseChannelId: string,
threadId?: string,
): ChannelConversationRouteDto {
// Recompute from configuration on every turn so a stale in-memory map can
// never carry a channel-only or differently bound agent identity forward.
const conversationId = `${binding.instanceId}:discord:${responseChannelId}`;
return {
bindingId: `${binding.guildId}:${binding.channelId}:${binding.instanceId}`,
logicalAgentId: binding.instanceId,
conversationId,
channelName: this.name,
authorizationChannelId,
responseTarget: {
channelId: responseChannelId,
...(threadId ? { threadId } : {}),
},
};
}
private consumeRateLimit(
windows: Map<string, number[]>,
key: string,
limit: number,
now = Date.now(),
): boolean {
const active = (windows.get(key) ?? []).filter(
(timestamp: number): boolean => now - timestamp < RATE_LIMIT_WINDOW_MS,
);
if (active.length >= Math.max(1, limit)) {
windows.set(key, active);
return false;
}
active.push(now);
windows.set(key, active);
return true;
}
private interactionOperation(content: string): 'send' | 'approve' | 'stop' {
if (/^\/approve$/i.test(content)) return 'approve';
if (/^\/stop\s+\S+/i.test(content)) return 'stop';
return 'send';
}
async send(egress: ChannelEgressDto): Promise<void> {
if (!this.isConfiguredRoute(egress.route) || !this.isMessageAlignedWithRoute(egress)) {
throw new ChannelDeliveryError(
'invalid_route',
`Discord egress route is not authorized for conversation ${egress.route.conversationId}`,
);
}
const channelId = egress.route.responseTarget.channelId;
const channel = this.client.channels.cache.get(channelId);
if (!channel || !('send' in channel)) {
console.error(
`[discord] Channel ${channelId} not sendable for conversation ${conversationId}`,
throw new ChannelDeliveryError(
'destination_unavailable',
`Discord destination is unavailable for conversation ${egress.route.conversationId}`,
);
return;
}
for (const chunk of this.chunkText(text, 1900)) {
const chunks = this.chunkText(egress.message.content, 1900);
for (const [chunkIndex, chunk] of chunks.entries()) {
await this.sendChunkWithRetry(
channel as {
send(options: { content: string; nonce: string; enforceNonce: true }): Promise<unknown>;
},
chunk,
egress.correlationId,
chunkIndex,
egress.route.conversationId,
);
}
}
private async sendChunkWithRetry(
channel: {
send(options: { content: string; nonce: string; enforceNonce: true }): Promise<unknown>;
},
chunk: string,
correlationId: string,
chunkIndex: number,
conversationId: string,
): Promise<void> {
const nonce = createHash('sha256')
.update(`${correlationId}:${chunkIndex}`)
.digest('hex')
.slice(0, 25);
let lastError: unknown;
for (let attempt = 1; attempt <= DELIVERY_MAX_ATTEMPTS; attempt += 1) {
try {
await (channel as { send: (content: string) => Promise<unknown> }).send(chunk);
} catch (err: unknown) {
console.error(`[discord] Failed to send message to channel ${channelId}:`, err);
await channel.send({ content: chunk, nonce, enforceNonce: true });
return;
} catch (error: unknown) {
lastError = error;
const retryable = this.isTransientDeliveryError(error);
if (!retryable) {
throw new ChannelDeliveryError(
'delivery_failed',
`Discord delivery failed for conversation ${conversationId}`,
false,
{ cause: error },
);
}
if (attempt < DELIVERY_MAX_ATTEMPTS) {
await new Promise<void>((resolve): void => {
setTimeout(resolve, DELIVERY_RETRY_BASE_MS * 2 ** (attempt - 1));
});
}
}
}
throw new ChannelDeliveryError(
'delivery_failed',
`Discord delivery failed for conversation ${conversationId}`,
true,
{ cause: lastError },
);
}
private isTransientDeliveryError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const candidate = error as { status?: unknown; code?: unknown };
if (
typeof candidate.status === 'number' &&
(candidate.status === 429 || candidate.status >= 500)
) {
return true;
}
return (
typeof candidate.code === 'string' &&
['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'].includes(candidate.code)
);
}
private async sendAgentResponse(conversationId: string, text: string): Promise<void> {
const route = this.conversationRoutes.get(conversationId);
if (!route) {
throw new ChannelDeliveryError(
'invalid_route',
`Discord response route is unavailable for conversation ${conversationId}`,
);
}
try {
await this.send({
correlationId: randomUUID(),
route,
message: {
id: randomUUID(),
channelName: this.name,
channelId: route.responseTarget.channelId,
senderId: route.logicalAgentId,
senderKind: 'agent',
content: text,
contentKind: 'markdown',
timestamp: new Date().toISOString(),
...(route.responseTarget.threadId ? { threadId: route.responseTarget.threadId } : {}),
metadata: {},
},
});
} finally {
this.conversationRoutes.delete(conversationId);
}
}
/** Compatibility wrapper while Socket.IO agent events carry only conversation ID. */
private async sendToDiscord(conversationId: string, text: string): Promise<void> {
await this.sendAgentResponse(conversationId, text);
}
private rememberConversationRoute(route: ChannelConversationRouteDto): void {
if (
!this.conversationRoutes.has(route.conversationId) &&
this.conversationRoutes.size >= MAX_CONVERSATION_ROUTES
) {
throw new ChannelDeliveryError(
'delivery_failed',
'Discord has reached its active response-route limit',
true,
);
}
this.conversationRoutes.set(route.conversationId, route);
}
private isConfiguredRoute(route: ChannelConversationRouteDto): boolean {
if (
route.channelName !== this.name ||
route.conversationId !==
`${route.logicalAgentId}:${this.name}:${route.responseTarget.channelId}`
) {
return false;
}
const bindingMatches = (this.config.interactionBindings ?? []).some(
(binding): boolean =>
route.bindingId === `${binding.guildId}:${binding.channelId}:${binding.instanceId}` &&
route.logicalAgentId === binding.instanceId &&
route.authorizationChannelId === binding.channelId,
);
if (!bindingMatches) return false;
if (
route.responseTarget.channelId === route.authorizationChannelId &&
route.responseTarget.threadId === undefined
) {
return true;
}
const observed = this.conversationRoutes.get(route.conversationId);
return (
observed?.bindingId === route.bindingId &&
observed.logicalAgentId === route.logicalAgentId &&
observed.authorizationChannelId === route.authorizationChannelId &&
observed.responseTarget.channelId === route.responseTarget.channelId &&
observed.responseTarget.threadId === route.responseTarget.threadId
);
}
private isMessageAlignedWithRoute(egress: ChannelEgressDto): boolean {
return (
egress.message.channelName === this.name &&
egress.message.channelId === egress.route.responseTarget.channelId &&
egress.message.threadId === egress.route.responseTarget.threadId
);
}
private chunkText(text: string, maxLength: number): string[] {