feat(#709): add configured Discord interaction binding
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
This commit is contained in:
@@ -12,6 +12,75 @@ 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';
|
||||
export type DiscordInteractionRole = 'viewer' | 'operator' | 'admin';
|
||||
|
||||
export interface DiscordInteractionBinding {
|
||||
instanceId: string;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
/** Pairing roster keyed by Discord user ID. */
|
||||
pairedUsers: Readonly<Record<string, DiscordInteractionRole>>;
|
||||
}
|
||||
|
||||
const operationRoles: Readonly<
|
||||
Record<DiscordInteractionOperation, readonly DiscordInteractionRole[]>
|
||||
> = {
|
||||
bind: ['admin'],
|
||||
attach: ['operator', 'admin'],
|
||||
send: ['operator', 'admin'],
|
||||
approve: ['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 role = binding.pairedUsers[userId];
|
||||
return role && operationRoles[operation].includes(role) ? binding : 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');
|
||||
}
|
||||
return {
|
||||
instanceId: candidate.instanceId,
|
||||
guildId: candidate.guildId,
|
||||
channelId: candidate.channelId,
|
||||
pairedUsers: candidate.pairedUsers,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface DiscordIngressPayload {
|
||||
@@ -22,6 +91,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 +122,8 @@ function signedPayload(payload: DiscordIngressPayload): string {
|
||||
payload.userId,
|
||||
payload.conversationId,
|
||||
payload.content,
|
||||
payload.threadId ?? '',
|
||||
JSON.stringify(payload.attachments ?? []),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -214,7 +294,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,10 +313,17 @@ 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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user