feat(#709): add configured Discord interaction binding (#730)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #730.
This commit is contained in:
2026-07-13 09:02:45 +00:00
parent 8246ee0137
commit 84d884b932
4 changed files with 623 additions and 7 deletions

View File

@@ -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<void> {
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<void> {
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}`,