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

@@ -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<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 {
@@ -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)
);
}