67
plugins/discord/README.md
Normal file
67
plugins/discord/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# @mosaicstack/discord-plugin
|
||||
|
||||
Official Discord channel adapter for Mosaic Stack.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Runs independently of the bound agent's Claude, Codex, Pi, OpenCode, or future harness.
|
||||
- Routes authorized untagged messages in configured agent channels and replies in-channel.
|
||||
- Creates a public Discord thread when the bot is mentioned in a parent channel, or reuses the thread already attached to that same message.
|
||||
- Keeps follow-ups in existing threads without requiring repeated mentions.
|
||||
- Keeps `/approve` and `/stop <approval>` on the current durable session.
|
||||
- Applies guild, parent-channel, user, pairing, role, and per-minute abuse limits before thread creation or gateway dispatch.
|
||||
- Authenticates to the gateway and signs ingress envelopes with the injected service token.
|
||||
|
||||
## Required configuration
|
||||
|
||||
| Variable | Purpose |
|
||||
| --------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot credential |
|
||||
| `DISCORD_SERVICE_TOKEN` | High-entropy plugin-to-gateway credential |
|
||||
| `DISCORD_SERVICE_USER_ID` | Provisioned Mosaic service principal |
|
||||
| `DISCORD_ALLOWED_GUILD_IDS` | Comma-separated guild allowlist |
|
||||
| `DISCORD_ALLOWED_CHANNEL_IDS` | Comma-separated parent-channel allowlist |
|
||||
| `DISCORD_ALLOWED_USER_IDS` | Comma-separated Discord user allowlist |
|
||||
| `DISCORD_INTERACTION_BINDINGS` | JSON channel→logical-agent bindings and paired-user roles |
|
||||
| `DISCORD_GATEWAY_URL` | Gateway base URL; defaults to the gateway's local development URL |
|
||||
| `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Authorized turns per guild/channel/user per minute; default `30` |
|
||||
| `DISCORD_THREAD_RATE_LIMIT_PER_MINUTE` | Mention-thread routes per guild/channel/user per minute; default `5` |
|
||||
|
||||
Supply credentials through the approved runtime secret mechanism. Never commit tokens or binding data containing secrets.
|
||||
|
||||
Example binding shape (identifiers are placeholders):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"instanceId": "interaction-agent",
|
||||
"agentConfigId": "agent-config-id",
|
||||
"guildId": "guild-id",
|
||||
"channelId": "channel-id",
|
||||
"pairedUsers": {
|
||||
"discord-user-id": {
|
||||
"role": "operator",
|
||||
"mosaicUserId": "mosaic-user-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Each binding's trusted `agentConfigId` must identify a provisioned database agent configuration whose name exactly matches its `instanceId`. Roles are `viewer`, `operator`, and `admin`. `viewer` cannot send agent turns. Runtime approval and stop operations require an `admin` pairing with a provisioned `mosaicUserId`.
|
||||
|
||||
The bot needs Discord permissions to view/send messages in configured channels and create/send in public threads. A channel category is not an authorization boundary; threads inherit authorization only from their configured parent text channel.
|
||||
|
||||
## Shared contract
|
||||
|
||||
The adapter implements `OfficialChannelAdapter` from `@mosaicstack/types`. `ChannelConversationRouteDto` carries only stable logical-agent/channel identity and a response target. Gateway durable-session and provider layers own runtime selection and handoff; Discord code must not import a harness SDK.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/types build
|
||||
pnpm --filter @mosaicstack/discord-plugin typecheck
|
||||
pnpm --filter @mosaicstack/discord-plugin lint
|
||||
pnpm --filter @mosaicstack/discord-plugin test
|
||||
pnpm --filter @mosaicstack/discord-plugin build
|
||||
```
|
||||
@@ -19,13 +19,16 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
"test": "vitest run --coverage",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/types": "workspace:^",
|
||||
"discord.js": "^14.16.0",
|
||||
"socket.io-client": "^4.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^2.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
|
||||
950
plugins/discord/src/index.test.ts
Normal file
950
plugins/discord/src/index.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
@@ -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[] {
|
||||
|
||||
@@ -4,5 +4,15 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
thresholds: {
|
||||
lines: 85,
|
||||
functions: 85,
|
||||
branches: 85,
|
||||
statements: 85,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user