diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 7b4f0b3..f3fd1b5 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -1,4 +1,5 @@ -import { Inject, Logger } from '@nestjs/common'; +import { createHash } from 'node:crypto'; +import { Inject, Logger, Optional } from '@nestjs/common'; import { WebSocketGateway, WebSocketServer, @@ -13,6 +14,9 @@ import { Server, Socket } from 'socket.io'; import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; import { verifyDiscordIngressEnvelope, + parseDiscordInteractionBindings, + resolveDiscordInteractionActorId, + resolveDiscordInteractionBinding, type DiscordIngressEnvelope, type DiscordIngressPayload, } from '@mosaicstack/discord-plugin'; @@ -28,6 +32,7 @@ import type { AbortPayload, } from '@mosaicstack/types'; import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; +import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js'; import { AUTH } from '../auth/auth.tokens.js'; import { scopeFromUser, @@ -37,6 +42,7 @@ import { import { BRAIN } from '../brain/brain.tokens.js'; import { CommandRegistryService } from '../commands/command-registry.service.js'; import { CommandExecutorService } from '../commands/command-executor.service.js'; +import { CommandAuthorizationService } from '../commands/command-authorization.service.js'; import { RoutingEngineService } from '../agent/routing/routing-engine.service.js'; import { v4 as uuid } from 'uuid'; import { ChatSocketMessageDto } from './chat.dto.js'; @@ -122,6 +128,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService, @Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService, @Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService, + @Optional() + @Inject(CommandAuthorizationService) + private readonly commandAuthorization: CommandAuthorizationService | null = null, + @Optional() + @Inject(RuntimeProviderService) + private readonly runtimeRegistry: RuntimeProviderService | null = null, ) {} afterInit(): void { @@ -637,9 +649,130 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa * Creates it if absent — safe to call concurrently since a duplicate insert * would fail on the PK constraint and be caught here. */ + @SubscribeMessage('discord:approve') + async handleDiscordApproval( + @ConnectedSocket() client: Socket, + @MessageBody() envelope: DiscordIngressEnvelope, + ): Promise { + if (!client.data.discordService) return; + const ingress = this.resolveDiscordIngress(client, envelope, 'approve'); + const actionParts = ingress?.content.match(/^\/approve\s+([^\s]+)\s+([^\s]+)$/i); + const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim(); + if (!ingress || !actionParts || !tenantId || !this.commandAuthorization) return; + const binding = resolveDiscordInteractionBinding( + parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), + ingress.guildId, + ingress.channelId, + ingress.userId, + 'approve', + ); + const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); + const agentName = process.env['MOSAIC_AGENT_NAME']?.trim(); + if (!actorId || !agentName || binding.instanceId !== agentName) { + this.logger.warn( + `Rejected Discord approval without a matching runtime agent from ${client.id}`, + ); + client.emit('discord:approval', { + correlationId: ingress.correlationId, + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + return; + } + const providerId = actionParts[1]!; + const sessionId = actionParts[2]!; + const approval = await this.commandAuthorization.createRuntimeTerminationApproval({ + providerId, + sessionId, + actorId, + tenantId, + channelId: ingress.channelId, + correlationId: this.discordRuntimeActionCorrelation( + binding.instanceId, + ingress, + providerId, + sessionId, + ), + agentName, + }); + client.emit('discord:approval', { + correlationId: ingress.correlationId, + success: approval !== null, + approvalId: approval?.approvalId, + expiresAt: approval?.expiresAt, + }); + } + + @SubscribeMessage('discord:stop') + async handleDiscordStop( + @ConnectedSocket() client: Socket, + @MessageBody() envelope: DiscordIngressEnvelope, + ): Promise { + if (!client.data.discordService) return; + const ingress = this.resolveDiscordIngress(client, envelope, 'stop'); + const actionParts = ingress?.content.match(/^\/stop\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)$/i); + const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim(); + if (!ingress || !actionParts || !tenantId || !this.runtimeRegistry) return; + const binding = resolveDiscordInteractionBinding( + parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), + ingress.guildId, + ingress.channelId, + ingress.userId, + 'stop', + ); + const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); + if (!actorId) return; + const providerId = actionParts[1]!; + const sessionId = actionParts[2]!; + + try { + // RuntimeProviderService consumes the durable approval exactly once using the + // provisioned approving-admin identity, never the Discord service account. + await this.runtimeRegistry.terminate(providerId, sessionId, actionParts[3]!, { + actorScope: { + userId: actorId, + tenantId, + }, + channelId: ingress.channelId, + correlationId: this.discordRuntimeActionCorrelation( + binding.instanceId, + ingress, + providerId, + sessionId, + ), + }); + client.emit('discord:stop', { correlationId: ingress.correlationId, success: true }); + } catch { + client.emit('discord:stop', { correlationId: ingress.correlationId, success: false }); + } + } + + /** + * Correlates the immutable termination target rather than either Discord message. + * Approval and stop are distinct ingress events, but must consume the same seven-field action. + */ + private discordRuntimeActionCorrelation( + instanceId: string, + ingress: DiscordIngressPayload, + providerId: string, + sessionId: string, + ): string { + const target = [ + instanceId, + ingress.guildId, + ingress.channelId, + ingress.conversationId, + providerId, + sessionId, + ]; + return `discord-action:v1:${createHash('sha256').update(JSON.stringify(target)).digest('hex')}`; + } + private resolveDiscordIngress( client: Socket, envelope: DiscordIngressEnvelope, + operation: 'send' | 'approve' | 'stop' = 'send', ): DiscordIngressPayload | null { const payload = verifyDiscordIngressEnvelope( envelope, @@ -654,6 +787,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`); return null; } + try { + const binding = resolveDiscordInteractionBinding( + parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), + payload.guildId, + payload.channelId, + payload.userId, + operation, + ); + if (!binding) { + this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`); + return null; + } + } catch { + this.logger.warn( + `Rejected Discord ingress without valid binding configuration from ${client.id}`, + ); + return null; + } if (!this.discordReplayProtector.claim(payload.messageId)) { this.logger.warn( `Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`, diff --git a/apps/gateway/src/plugin/discord-ingress.security.spec.ts b/apps/gateway/src/plugin/discord-ingress.security.spec.ts index 0530170..3ceb840 100644 --- a/apps/gateway/src/plugin/discord-ingress.security.spec.ts +++ b/apps/gateway/src/plugin/discord-ingress.security.spec.ts @@ -1,13 +1,126 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createDiscordIngressEnvelope, verifyDiscordIngressEnvelope, + DiscordPlugin, type DiscordIngressPayload, + parseDiscordInteractionBindings, + resolveDiscordInteractionActorId, + resolveDiscordInteractionBinding, } from '@mosaicstack/discord-plugin'; +import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js'; +import { ChatGateway } from '../chat/chat.gateway.js'; +import { CommandAuthorizationService } from '../commands/command-authorization.service.js'; import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js'; import { DiscordReplayProtector } from './discord-replay-protector.js'; const SERVICE_TOKEN = 'test-service-token'; +const ENV_KEYS = [ + 'DISCORD_SERVICE_TOKEN', + 'DISCORD_SERVICE_USER_ID', + 'DISCORD_SERVICE_TENANT_ID', + 'DISCORD_INTERACTION_BINDINGS', + 'DISCORD_ALLOWED_GUILD_IDS', + 'DISCORD_ALLOWED_CHANNEL_IDS', + 'DISCORD_ALLOWED_USER_IDS', + 'MOSAIC_AGENT_NAME', +] as const; +const savedEnv = new Map(); + +function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void { + for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]); + process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN; + process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service'; + process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord'; + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { + role: role === 'admin' ? 'admin' : 'operator', + mosaicUserId: 'mosaic-admin-001', + }, + }, + }, + ]); +} + +afterEach((): void => { + for (const key of ENV_KEYS) { + const value = savedEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + savedEnv.clear(); +}); + +function commandAuthorization(role: 'admin' | 'member'): CommandAuthorizationService { + const entries = new Map(); + const db = { + select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }), + }; + const redis = { + get: async (key: string) => entries.get(key) ?? null, + set: async (key: string, value: string) => entries.set(key, value), + del: async (key: string) => Number(entries.delete(key)), + }; + return new CommandAuthorizationService(db as never, redis); +} + +function discordGateway(role: 'admin' | 'member'): { + gateway: ChatGateway; + client: { data: { discordService: boolean }; emit: ReturnType }; + consumedActions: Array<{ actorId: string; correlationId: string }>; +} { + const authorization = commandAuthorization(role); + const consumedActions: Array<{ actorId: string; correlationId: string }> = []; + const runtimeRegistry = new RuntimeProviderService( + { + require: () => ({ + capabilities: async () => ({ supported: ['session.terminate'] }), + terminate: async () => undefined, + }), + } as never, + { record: async () => undefined } as never, + { + consume: async (approvalId, action) => { + consumedActions.push({ actorId: action.actorId, correlationId: action.correlationId }); + return authorization.consumeRuntimeTerminationApproval(approvalId, action); + }, + }, + ); + return { + gateway: new ChatGateway( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + authorization, + runtimeRegistry, + ), + client: { data: { discordService: true }, emit: vi.fn() }, + consumedActions, + }; +} + +function ingressEnvelope( + content: string, + messageId: string, + overrides: Partial = {}, +): ReturnType { + return createDiscordIngressEnvelope( + createPayload({ content, messageId, ...overrides }), + SERVICE_TOKEN, + ); +} function createPayload(overrides: Partial = {}): DiscordIngressPayload { return { @@ -23,6 +136,42 @@ function createPayload(overrides: Partial = {}): DiscordI } describe('Discord ingress security', () => { + it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => { + const [binding] = parseDiscordInteractionBindings( + JSON.stringify([ + { + instanceId: 'Nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { 'user-001': 'admin' }, + }, + ]), + ); + expect( + resolveDiscordInteractionBinding([binding!], 'guild-001', 'channel-001', 'user-001', 'send'), + ).toEqual(binding); + expect(resolveDiscordInteractionActorId(binding!, 'user-001')).toBeNull(); + }); + + it('binds a differently named configured interaction instance without code changes', () => { + const binding = resolveDiscordInteractionBinding( + [ + { + instanceId: 'Nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, + }, + ], + 'guild-001', + 'channel-001', + 'user-001', + 'send', + ); + + expect(binding?.instanceId).toBe('Nova'); + }); + it('accepts only the configured Discord service identity', () => { expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true); expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false); @@ -86,4 +235,155 @@ describe('Discord ingress security', () => { expect(replayProtector.claim('discord-message-003')).toBe(true); expect(replayProtector.size).toBe(2); }); + + it('consumes the exact target once when approval and stop are separate Discord messages', async () => { + configureDiscordEnv(); + const { gateway, client, consumedActions } = discordGateway('admin'); + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve fleet runtime-1', 'approve-message', { + correlationId: 'approval-ingress-correlation', + }), + ); + const approval = client.emit.mock.calls.find( + ([event]) => event === 'discord:approval', + )?.[1] as { + approvalId: string; + success: boolean; + }; + expect(approval.success).toBe(true); + + await gateway.handleDiscordStop( + client as never, + ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'stop-message', { + correlationId: 'stop-ingress-correlation', + }), + ); + expect(client.emit).toHaveBeenCalledWith('discord:stop', { + correlationId: 'stop-ingress-correlation', + success: true, + }); + expect(consumedActions).toEqual([ + { + actorId: 'mosaic-admin-001', + correlationId: expect.stringMatching(/^discord-action:v1:/), + }, + ]); + }); + + it('rejects approval when the binding targets a different runtime agent', async () => { + configureDiscordEnv(); + process.env['MOSAIC_AGENT_NAME'] = 'Other'; + const { gateway, client } = discordGateway('admin'); + + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve fleet runtime-1', 'mismatched-agent-approve'), + ); + + expect(client.emit).toHaveBeenCalledWith('discord:approval', { + correlationId: 'correlation-001', + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + }); + + it('rejects unpaired and non-admin Discord users for approval and stop', async () => { + configureDiscordEnv(); + const { gateway, client } = discordGateway('member'); + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve fleet runtime-1', 'member-approve'), + ); + expect(client.emit).toHaveBeenCalledWith('discord:approval', { + correlationId: 'correlation-001', + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([]); + await gateway.handleDiscordStop( + client as never, + ingressEnvelope('/stop fleet runtime-1 forged', 'unpaired-stop'), + ); + expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything()); + }); + + it('rejects replaying a Discord-created termination approval', async () => { + configureDiscordEnv(); + const { gateway, client } = discordGateway('admin'); + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve fleet runtime-1', 'replay-approve', { + correlationId: 'replay-approval-correlation', + }), + ); + const approval = client.emit.mock.calls.find( + ([event]) => event === 'discord:approval', + )?.[1] as { + approvalId: string; + }; + await gateway.handleDiscordStop( + client as never, + ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'replay-stop-one', { + correlationId: 'replay-stop-correlation-one', + }), + ); + await gateway.handleDiscordStop( + client as never, + ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'replay-stop-two', { + correlationId: 'replay-stop-correlation-two', + }), + ); + const stopResults = client.emit.mock.calls.filter(([event]) => event === 'discord:stop'); + expect(stopResults.map(([, result]) => (result as { success: boolean }).success)).toEqual([ + true, + false, + ]); + }); + + it('accepts a thread message through its allowed bound parent channel', () => { + const emitted = 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', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, + }, + ], + }); + const internals = plugin as unknown as { + client: { user: { id: string } }; + socket: { connected: boolean; emit: ReturnType }; + handleDiscordMessage(message: unknown): void; + }; + internals.client = { user: { id: 'bot-001' } }; + internals.socket = { connected: true, emit: emitted }; + internals.handleDiscordMessage({ + id: 'thread-message', + guildId: 'guild-001', + channelId: 'thread-001', + author: { id: 'user-001', bot: false }, + mentions: { has: () => true }, + content: '<@bot-001> hello from thread', + channel: { parentId: 'channel-001' }, + attachments: new Map(), + }); + + const [, envelope] = emitted.mock.calls[0] as [ + string, + ReturnType, + ]; + expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)?.channelId).toBe('channel-001'); + }); }); diff --git a/apps/gateway/src/plugin/plugin.module.ts b/apps/gateway/src/plugin/plugin.module.ts index dc059ef..fdf13cf 100644 --- a/apps/gateway/src/plugin/plugin.module.ts +++ b/apps/gateway/src/plugin/plugin.module.ts @@ -6,7 +6,7 @@ import { type OnModuleDestroy, type OnModuleInit, } from '@nestjs/common'; -import { DiscordPlugin } from '@mosaicstack/discord-plugin'; +import { DiscordPlugin, parseDiscordInteractionBindings } from '@mosaicstack/discord-plugin'; import { TelegramPlugin } from '@mosaicstack/telegram-plugin'; import { PluginService } from './plugin.service.js'; import type { IChannelPlugin } from './plugin.interface.js'; @@ -85,6 +85,9 @@ function createPluginRegistry(): IChannelPlugin[] { allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), + interactionBindings: parseDiscordInteractionBindings( + process.env['DISCORD_INTERACTION_BINDINGS'], + ), }), ), ); diff --git a/plugins/discord/src/index.ts b/plugins/discord/src/index.ts index d25e555..d111e87 100644 --- a/plugins/discord/src/index.ts +++ b/plugins/discord/src/index.ts @@ -12,6 +12,129 @@ export interface DiscordPluginConfig { 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>; +} + +const operationRoles: Readonly< + Record +> = { + 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; + 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; + 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; + return { + instanceId: candidate.instanceId, + guildId: candidate.guildId, + channelId: candidate.channelId, + pairedUsers, + }; + }); } export interface DiscordIngressPayload { @@ -22,6 +145,15 @@ export interface DiscordIngressPayload { 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 { @@ -44,6 +176,8 @@ function signedPayload(payload: DiscordIngressPayload): string { payload.userId, payload.conversationId, payload.content, + payload.threadId ?? '', + JSON.stringify(payload.attachments ?? []), ].join('\n'); } @@ -214,7 +348,18 @@ export class DiscordPlugin { } const channelId = message.channelId; - const conversationId = this.channelConversations.get(channelId) ?? `discord-${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( @@ -222,22 +367,39 @@ export class DiscordPlugin { correlationId: randomUUID(), messageId: message.id, guildId: message.guildId, - channelId, + 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('message', envelope); + this.socket.emit( + content.startsWith('/approve ') + ? '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, message.channelId) && + includesId(this.config.allowedChannelIds, authorizationChannelId) && includesId(this.config.allowedUserIds, message.author.id) ); }