929 lines
31 KiB
TypeScript
929 lines
31 KiB
TypeScript
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). */
|
|
guildId?: string;
|
|
allowedGuildIds: readonly string[];
|
|
allowedChannelIds: readonly string[];
|
|
allowedUserIds: readonly string[];
|
|
/** Provisioned interaction bindings; instance identity is configuration, never code. */
|
|
interactionBindings?: readonly DiscordInteractionBinding[];
|
|
}
|
|
|
|
export type DiscordInteractionOperation = 'bind' | 'attach' | 'send' | 'approve' | 'stop';
|
|
export type DiscordInteractionRole = 'viewer' | 'operator' | 'admin';
|
|
|
|
/** A provisioned Discord-to-Mosaic identity pairing. */
|
|
export interface DiscordInteractionUserBinding {
|
|
role: DiscordInteractionRole;
|
|
/** Required for privileged approval and stop operations. */
|
|
mosaicUserId?: string;
|
|
}
|
|
|
|
/** Legacy role-only pairings remain valid for non-privileged Discord ingress. */
|
|
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[]>
|
|
> = {
|
|
bind: ['admin'],
|
|
attach: ['operator', 'admin'],
|
|
send: ['operator', 'admin'],
|
|
approve: ['admin'],
|
|
stop: ['admin'],
|
|
};
|
|
|
|
/** Resolves a configuration-owned binding and applies pairing/RBAC before ingress. */
|
|
export function resolveDiscordInteractionBinding(
|
|
bindings: readonly DiscordInteractionBinding[],
|
|
guildId: string,
|
|
channelId: string,
|
|
userId: string,
|
|
operation: DiscordInteractionOperation,
|
|
): DiscordInteractionBinding | null {
|
|
const binding = bindings.find(
|
|
(candidate) => candidate.guildId === guildId && candidate.channelId === channelId,
|
|
);
|
|
if (!binding) return null;
|
|
const pairing = binding.pairedUsers[userId];
|
|
const role = typeof pairing === 'string' ? pairing : pairing?.role;
|
|
return role && operationRoles[operation].includes(role) ? binding : null;
|
|
}
|
|
|
|
/** Resolves the provisioned Mosaic identity for an already-authorized Discord user. */
|
|
export function resolveDiscordInteractionActorId(
|
|
binding: DiscordInteractionBinding,
|
|
discordUserId: string,
|
|
): string | null {
|
|
const pairing = binding.pairedUsers[discordUserId];
|
|
if (typeof pairing === 'string') return null;
|
|
const mosaicUserId = pairing?.mosaicUserId?.trim();
|
|
return mosaicUserId || null;
|
|
}
|
|
|
|
/** Parses provisioned binding roster JSON and rejects malformed or empty data. */
|
|
export function parseDiscordInteractionBindings(
|
|
value: string | undefined,
|
|
): DiscordInteractionBinding[] {
|
|
if (!value) throw new Error('DISCORD_INTERACTION_BINDINGS is required when Discord is enabled');
|
|
const parsed: unknown = JSON.parse(value);
|
|
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
throw new Error('DISCORD_INTERACTION_BINDINGS must be a non-empty JSON array');
|
|
}
|
|
return parsed.map((binding: unknown): DiscordInteractionBinding => {
|
|
if (typeof binding !== 'object' || binding === null)
|
|
throw new Error('Invalid Discord interaction binding');
|
|
const candidate = binding as Partial<DiscordInteractionBinding>;
|
|
if (
|
|
!candidate.instanceId ||
|
|
!candidate.agentConfigId ||
|
|
!candidate.guildId ||
|
|
!candidate.channelId ||
|
|
!candidate.pairedUsers ||
|
|
typeof candidate.pairedUsers !== 'object'
|
|
) {
|
|
throw new Error('Invalid Discord interaction binding');
|
|
}
|
|
const pairedUsers = Object.fromEntries(
|
|
Object.entries(candidate.pairedUsers).map(([discordUserId, pairing]: [string, unknown]) => {
|
|
if (!discordUserId.trim()) {
|
|
throw new Error('Invalid Discord interaction user binding');
|
|
}
|
|
if (typeof pairing === 'string') {
|
|
if (!['viewer', 'operator', 'admin'].includes(pairing)) {
|
|
throw new Error('Invalid Discord interaction user binding');
|
|
}
|
|
return [discordUserId, pairing];
|
|
}
|
|
if (typeof pairing !== 'object' || pairing === null) {
|
|
throw new Error('Invalid Discord interaction user binding');
|
|
}
|
|
const userBinding = pairing as Partial<DiscordInteractionUserBinding>;
|
|
if (
|
|
!userBinding.role ||
|
|
!['viewer', 'operator', 'admin'].includes(userBinding.role) ||
|
|
(userBinding.mosaicUserId !== undefined && !userBinding.mosaicUserId.trim())
|
|
) {
|
|
throw new Error('Invalid Discord interaction user binding');
|
|
}
|
|
return [
|
|
discordUserId,
|
|
{
|
|
role: userBinding.role,
|
|
...(userBinding.mosaicUserId ? { mosaicUserId: userBinding.mosaicUserId } : {}),
|
|
},
|
|
];
|
|
}),
|
|
) as Record<string, DiscordInteractionPairing>;
|
|
return {
|
|
instanceId: candidate.instanceId,
|
|
agentConfigId: candidate.agentConfigId,
|
|
guildId: candidate.guildId,
|
|
channelId: candidate.channelId,
|
|
pairedUsers,
|
|
};
|
|
});
|
|
}
|
|
|
|
export interface DiscordIngressPayload {
|
|
correlationId: string;
|
|
messageId: string;
|
|
guildId: string;
|
|
channelId: string;
|
|
userId: string;
|
|
conversationId: string;
|
|
content: string;
|
|
threadId?: string;
|
|
attachments?: readonly DiscordAttachment[];
|
|
}
|
|
|
|
export interface DiscordAttachment {
|
|
id: string;
|
|
name: string;
|
|
url: string;
|
|
contentType: string | null;
|
|
sizeBytes?: number;
|
|
}
|
|
|
|
export interface DiscordIngressEnvelope {
|
|
payload: DiscordIngressPayload;
|
|
signature: string;
|
|
}
|
|
|
|
export interface DiscordIngressAllowlists {
|
|
guildIds: readonly string[];
|
|
channelIds: readonly string[];
|
|
userIds: readonly string[];
|
|
}
|
|
|
|
function signedPayload(payload: DiscordIngressPayload): string {
|
|
return [
|
|
payload.correlationId,
|
|
payload.messageId,
|
|
payload.guildId,
|
|
payload.channelId,
|
|
payload.userId,
|
|
payload.conversationId,
|
|
payload.content,
|
|
payload.threadId ?? '',
|
|
JSON.stringify(payload.attachments ?? []),
|
|
].join('\n');
|
|
}
|
|
|
|
function signPayload(payload: DiscordIngressPayload, serviceToken: string): string {
|
|
return createHmac('sha256', serviceToken).update(signedPayload(payload)).digest('hex');
|
|
}
|
|
|
|
function isSignatureValid(actual: string, expected: string): boolean {
|
|
const actualBuffer = Buffer.from(actual, 'hex');
|
|
const expectedBuffer = Buffer.from(expected, 'hex');
|
|
return (
|
|
actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
|
|
);
|
|
}
|
|
|
|
function includesId(allowedIds: readonly string[], id: string): boolean {
|
|
return allowedIds.includes(id);
|
|
}
|
|
|
|
/** Creates the signed, auditable envelope accepted by the gateway Discord service boundary. */
|
|
export function createDiscordIngressEnvelope(
|
|
payload: DiscordIngressPayload,
|
|
serviceToken: string,
|
|
): DiscordIngressEnvelope {
|
|
return { payload, signature: signPayload(payload, serviceToken) };
|
|
}
|
|
|
|
/**
|
|
* Verifies service-origin integrity and applies default-deny Discord identity allowlists.
|
|
* Returns null instead of a partially trusted payload on every failure path.
|
|
*/
|
|
export function verifyDiscordIngressEnvelope(
|
|
envelope: DiscordIngressEnvelope,
|
|
serviceToken: string,
|
|
allowlists?: DiscordIngressAllowlists,
|
|
): DiscordIngressPayload | null {
|
|
const expectedSignature = signPayload(envelope.payload, serviceToken);
|
|
if (!isSignatureValid(envelope.signature, expectedSignature)) return null;
|
|
|
|
if (
|
|
allowlists &&
|
|
(!includesId(allowlists.guildIds, envelope.payload.guildId) ||
|
|
!includesId(allowlists.channelIds, envelope.payload.channelId) ||
|
|
!includesId(allowlists.userIds, envelope.payload.userId))
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return envelope.payload;
|
|
}
|
|
|
|
export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort {
|
|
readonly name = 'discord';
|
|
|
|
private client: Client;
|
|
private socket: Socket | null = null;
|
|
/** 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,
|
|
private readonly dependencies: DiscordPluginDependencies = {},
|
|
) {
|
|
this.client =
|
|
dependencies.client ??
|
|
new Client({
|
|
intents: [
|
|
GatewayIntentBits.Guilds,
|
|
GatewayIntentBits.GuildMessages,
|
|
GatewayIntentBits.MessageContent,
|
|
GatewayIntentBits.DirectMessages,
|
|
],
|
|
});
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
const socketFactory = this.dependencies.socketFactory ?? io;
|
|
this.socket = socketFactory(`${this.config.gatewayUrl}/chat`, {
|
|
auth: { discordServiceToken: this.config.serviceToken },
|
|
transports: ['websocket'],
|
|
});
|
|
|
|
this.socket.on('connect', () => {
|
|
console.log('[discord] Connected to gateway');
|
|
});
|
|
|
|
this.socket.on('disconnect', (reason: string) => {
|
|
console.error(`[discord] Disconnected from gateway: ${reason}`);
|
|
this.pendingResponses.clear();
|
|
});
|
|
|
|
this.socket.on('connect_error', (err: Error) => {
|
|
console.error(`[discord] Gateway connection error: ${err.message}`);
|
|
});
|
|
|
|
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
|
|
const pending = this.pendingResponses.get(data.conversationId);
|
|
if (pending !== undefined) {
|
|
this.pendingResponses.set(data.conversationId, pending + data.text);
|
|
}
|
|
});
|
|
|
|
this.socket.on('agent:end', (data: { conversationId: string }) => {
|
|
const text = this.pendingResponses.get(data.conversationId);
|
|
this.pendingResponses.delete(data.conversationId);
|
|
if (text) {
|
|
this.sendAgentResponse(data.conversationId, text).catch((err: unknown) => {
|
|
console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
|
|
});
|
|
} else {
|
|
this.conversationRoutes.delete(data.conversationId);
|
|
}
|
|
});
|
|
|
|
this.socket.on('agent:start', (data: { conversationId: string }) => {
|
|
this.pendingResponses.set(data.conversationId, '');
|
|
});
|
|
|
|
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}`);
|
|
});
|
|
|
|
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> {
|
|
this.socket?.disconnect();
|
|
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;
|
|
description?: string;
|
|
}): Promise<{ channelId: string } | null> {
|
|
if (!this.config.guildId) return null;
|
|
|
|
const guild = this.client.guilds.cache.get(this.config.guildId);
|
|
if (!guild) return null;
|
|
|
|
const channelName = `mosaic-${project.name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '')}`;
|
|
|
|
const channel = await guild.channels.create({
|
|
name: channelName,
|
|
type: ChannelType.GuildText,
|
|
topic: project.description ?? `Mosaic project: ${project.name}`,
|
|
});
|
|
|
|
// 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 | 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);
|
|
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 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;
|
|
}
|
|
|
|
// 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 (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: 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.mimeType,
|
|
...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}),
|
|
})),
|
|
},
|
|
this.config.serviceToken,
|
|
);
|
|
socket.emit(
|
|
ingress.operation === 'approval.create'
|
|
? 'discord:approve'
|
|
: ingress.operation === 'session.stop'
|
|
? 'discord:stop'
|
|
: 'message',
|
|
envelope,
|
|
);
|
|
}
|
|
|
|
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;
|
|
return (
|
|
guildId !== null &&
|
|
includesId(this.config.allowedGuildIds, guildId) &&
|
|
includesId(this.config.allowedChannelIds, authorizationChannelId) &&
|
|
includesId(this.config.allowedUserIds, message.author.id)
|
|
);
|
|
}
|
|
|
|
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)) {
|
|
throw new ChannelDeliveryError(
|
|
'destination_unavailable',
|
|
`Discord destination is unavailable for conversation ${egress.route.conversationId}`,
|
|
);
|
|
}
|
|
|
|
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.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[] {
|
|
if (text.length <= maxLength) return [text];
|
|
|
|
const chunks: string[] = [];
|
|
let remaining = text;
|
|
while (remaining.length > 0) {
|
|
if (remaining.length <= maxLength) {
|
|
chunks.push(remaining);
|
|
break;
|
|
}
|
|
let breakPoint = remaining.lastIndexOf('\n', maxLength);
|
|
if (breakPoint <= 0) breakPoint = maxLength;
|
|
chunks.push(remaining.slice(0, breakPoint));
|
|
remaining = remaining.slice(breakPoint).trimStart();
|
|
}
|
|
return chunks;
|
|
}
|
|
}
|