452 lines
15 KiB
TypeScript
452 lines
15 KiB
TypeScript
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
|
|
import { io, type Socket } from 'socket.io-client';
|
|
|
|
export interface DiscordPluginConfig {
|
|
token: string;
|
|
gatewayUrl: string;
|
|
/** 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 {
|
|
instanceId: string;
|
|
guildId: string;
|
|
channelId: string;
|
|
/** Pairing roster keyed by Discord user ID. */
|
|
pairedUsers: Readonly<Record<string, DiscordInteractionPairing>>;
|
|
}
|
|
|
|
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.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,
|
|
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;
|
|
}
|
|
|
|
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 {
|
|
private client: Client;
|
|
private socket: Socket | null = null;
|
|
/** Map Discord channel ID → Mosaic conversation ID. */
|
|
private channelConversations = new Map<string, string>();
|
|
/** Track in-flight responses to avoid duplicate streaming. */
|
|
private pendingResponses = new Map<string, string>();
|
|
|
|
constructor(private readonly config: DiscordPluginConfig) {
|
|
this.client = new Client({
|
|
intents: [
|
|
GatewayIntentBits.Guilds,
|
|
GatewayIntentBits.GuildMessages,
|
|
GatewayIntentBits.MessageContent,
|
|
GatewayIntentBits.DirectMessages,
|
|
],
|
|
});
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
this.socket = io(`${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);
|
|
if (text) {
|
|
this.pendingResponses.delete(data.conversationId);
|
|
this.sendToDiscord(data.conversationId, text).catch((err: unknown) => {
|
|
console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
|
|
});
|
|
}
|
|
});
|
|
|
|
this.socket.on('agent:start', (data: { conversationId: string }) => {
|
|
this.pendingResponses.set(data.conversationId, '');
|
|
});
|
|
|
|
this.client.on('messageCreate', (message: DiscordMessage) =>
|
|
this.handleDiscordMessage(message),
|
|
);
|
|
|
|
this.client.on('ready', () => {
|
|
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
|
|
});
|
|
|
|
await this.client.login(this.config.token);
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
this.socket?.disconnect();
|
|
await this.client.destroy();
|
|
}
|
|
|
|
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}`,
|
|
});
|
|
|
|
this.channelConversations.set(channel.id, `discord-${channel.id}`);
|
|
return { channelId: channel.id };
|
|
}
|
|
|
|
private handleDiscordMessage(message: DiscordMessage): void {
|
|
if (message.author.bot || !this.client.user) return;
|
|
if (!message.guildId || !this.isAllowedMessage(message)) return;
|
|
|
|
const isMention = message.mentions.has(this.client.user);
|
|
if (!isMention) return;
|
|
|
|
const content = message.content
|
|
.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '')
|
|
.trim();
|
|
if (!content) return;
|
|
if (!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',
|
|
);
|
|
if (!binding) return;
|
|
const conversationId =
|
|
this.channelConversations.get(channelId) ?? `${binding.instanceId}:discord:${channelId}`;
|
|
this.channelConversations.set(channelId, conversationId);
|
|
|
|
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) => ({
|
|
id: attachment.id,
|
|
name: attachment.name,
|
|
url: attachment.url,
|
|
contentType: attachment.contentType,
|
|
})),
|
|
},
|
|
this.config.serviceToken,
|
|
);
|
|
this.socket.emit(
|
|
/^\/approve$/i.test(content)
|
|
? 'discord:approve'
|
|
: content.startsWith('/stop ')
|
|
? 'discord:stop'
|
|
: 'message',
|
|
envelope,
|
|
);
|
|
}
|
|
|
|
private isAllowedMessage(message: DiscordMessage): 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) &&
|
|
includesId(this.config.allowedChannelIds, authorizationChannelId) &&
|
|
includesId(this.config.allowedUserIds, message.author.id)
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const channel = this.client.channels.cache.get(channelId);
|
|
if (!channel || !('send' in channel)) {
|
|
console.error(
|
|
`[discord] Channel ${channelId} not sendable for conversation ${conversationId}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
for (const chunk of this.chunkText(text, 1900)) {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|