Spaces:
Paused
Paused
| import { ChannelType, MessageType, type User } from "@buape/carbon"; | |
| import type { | |
| DiscordMessagePreflightContext, | |
| DiscordMessagePreflightParams, | |
| } from "./message-handler.preflight.types.js"; | |
| import { hasControlCommand } from "../../auto-reply/command-detection.js"; | |
| import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js"; | |
| import { | |
| recordPendingHistoryEntryIfEnabled, | |
| type HistoryEntry, | |
| } from "../../auto-reply/reply/history.js"; | |
| import { | |
| buildMentionRegexes, | |
| matchesMentionWithExplicit, | |
| } from "../../auto-reply/reply/mentions.js"; | |
| import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; | |
| import { resolveControlCommandGate } from "../../channels/command-gating.js"; | |
| import { logInboundDrop } from "../../channels/logging.js"; | |
| import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js"; | |
| import { logVerbose, shouldLogVerbose } from "../../globals.js"; | |
| import { recordChannelActivity } from "../../infra/channel-activity.js"; | |
| import { enqueueSystemEvent } from "../../infra/system-events.js"; | |
| import { getChildLogger } from "../../logging.js"; | |
| import { buildPairingReply } from "../../pairing/pairing-messages.js"; | |
| import { | |
| readChannelAllowFromStore, | |
| upsertChannelPairingRequest, | |
| } from "../../pairing/pairing-store.js"; | |
| import { resolveAgentRoute } from "../../routing/resolve-route.js"; | |
| import { fetchPluralKitMessageInfo } from "../pluralkit.js"; | |
| import { sendMessageDiscord } from "../send.js"; | |
| import { | |
| allowListMatches, | |
| isDiscordGroupAllowedByPolicy, | |
| normalizeDiscordAllowList, | |
| normalizeDiscordSlug, | |
| resolveDiscordAllowListMatch, | |
| resolveDiscordChannelConfigWithFallback, | |
| resolveDiscordGuildEntry, | |
| resolveDiscordShouldRequireMention, | |
| resolveDiscordUserAllowed, | |
| resolveGroupDmAllow, | |
| } from "./allow-list.js"; | |
| import { | |
| formatDiscordUserTag, | |
| resolveDiscordSystemLocation, | |
| resolveTimestampMs, | |
| } from "./format.js"; | |
| import { resolveDiscordChannelInfo, resolveDiscordMessageText } from "./message-utils.js"; | |
| import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js"; | |
| import { resolveDiscordSystemEvent } from "./system-events.js"; | |
| import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js"; | |
| export type { | |
| DiscordMessagePreflightContext, | |
| DiscordMessagePreflightParams, | |
| } from "./message-handler.preflight.types.js"; | |
| export async function preflightDiscordMessage( | |
| params: DiscordMessagePreflightParams, | |
| ): Promise<DiscordMessagePreflightContext | null> { | |
| const logger = getChildLogger({ module: "discord-auto-reply" }); | |
| const message = params.data.message; | |
| const author = params.data.author; | |
| if (!author) { | |
| return null; | |
| } | |
| const allowBots = params.discordConfig?.allowBots ?? false; | |
| if (params.botUserId && author.id === params.botUserId) { | |
| // Always ignore own messages to prevent self-reply loops | |
| return null; | |
| } | |
| const pluralkitConfig = params.discordConfig?.pluralkit; | |
| const webhookId = resolveDiscordWebhookId(message); | |
| const shouldCheckPluralKit = Boolean(pluralkitConfig?.enabled) && !webhookId; | |
| let pluralkitInfo: Awaited<ReturnType<typeof fetchPluralKitMessageInfo>> = null; | |
| if (shouldCheckPluralKit) { | |
| try { | |
| pluralkitInfo = await fetchPluralKitMessageInfo({ | |
| messageId: message.id, | |
| config: pluralkitConfig, | |
| }); | |
| } catch (err) { | |
| logVerbose(`discord: pluralkit lookup failed for ${message.id}: ${String(err)}`); | |
| } | |
| } | |
| const sender = resolveDiscordSenderIdentity({ | |
| author, | |
| member: params.data.member, | |
| pluralkitInfo, | |
| }); | |
| if (author.bot) { | |
| if (!allowBots && !sender.isPluralKit) { | |
| logVerbose("discord: drop bot message (allowBots=false)"); | |
| return null; | |
| } | |
| } | |
| const isGuildMessage = Boolean(params.data.guild_id); | |
| const channelInfo = await resolveDiscordChannelInfo(params.client, message.channelId); | |
| const isDirectMessage = channelInfo?.type === ChannelType.DM; | |
| const isGroupDm = channelInfo?.type === ChannelType.GroupDM; | |
| if (isGroupDm && !params.groupDmEnabled) { | |
| logVerbose("discord: drop group dm (group dms disabled)"); | |
| return null; | |
| } | |
| if (isDirectMessage && !params.dmEnabled) { | |
| logVerbose("discord: drop dm (dms disabled)"); | |
| return null; | |
| } | |
| const dmPolicy = params.discordConfig?.dm?.policy ?? "pairing"; | |
| let commandAuthorized = true; | |
| if (isDirectMessage) { | |
| if (dmPolicy === "disabled") { | |
| logVerbose("discord: drop dm (dmPolicy: disabled)"); | |
| return null; | |
| } | |
| if (dmPolicy !== "open") { | |
| const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []); | |
| const effectiveAllowFrom = [...(params.allowFrom ?? []), ...storeAllowFrom]; | |
| const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]); | |
| const allowMatch = allowList | |
| ? resolveDiscordAllowListMatch({ | |
| allowList, | |
| candidate: { | |
| id: sender.id, | |
| name: sender.name, | |
| tag: sender.tag, | |
| }, | |
| }) | |
| : { allowed: false }; | |
| const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); | |
| const permitted = allowMatch.allowed; | |
| if (!permitted) { | |
| commandAuthorized = false; | |
| if (dmPolicy === "pairing") { | |
| const { code, created } = await upsertChannelPairingRequest({ | |
| channel: "discord", | |
| id: author.id, | |
| meta: { | |
| tag: formatDiscordUserTag(author), | |
| name: author.username ?? undefined, | |
| }, | |
| }); | |
| if (created) { | |
| logVerbose( | |
| `discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)} (${allowMatchMeta})`, | |
| ); | |
| try { | |
| await sendMessageDiscord( | |
| `user:${author.id}`, | |
| buildPairingReply({ | |
| channel: "discord", | |
| idLine: `Your Discord user id: ${author.id}`, | |
| code, | |
| }), | |
| { | |
| token: params.token, | |
| rest: params.client.rest, | |
| accountId: params.accountId, | |
| }, | |
| ); | |
| } catch (err) { | |
| logVerbose(`discord pairing reply failed for ${author.id}: ${String(err)}`); | |
| } | |
| } | |
| } else { | |
| logVerbose( | |
| `Blocked unauthorized discord sender ${sender.id} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, | |
| ); | |
| } | |
| return null; | |
| } | |
| commandAuthorized = true; | |
| } | |
| } | |
| const botId = params.botUserId; | |
| const baseText = resolveDiscordMessageText(message, { | |
| includeForwarded: false, | |
| }); | |
| const messageText = resolveDiscordMessageText(message, { | |
| includeForwarded: true, | |
| }); | |
| recordChannelActivity({ | |
| channel: "discord", | |
| accountId: params.accountId, | |
| direction: "inbound", | |
| }); | |
| // Resolve thread parent early for binding inheritance | |
| const channelName = | |
| channelInfo?.name ?? | |
| ((isGuildMessage || isGroupDm) && message.channel && "name" in message.channel | |
| ? message.channel.name | |
| : undefined); | |
| const earlyThreadChannel = resolveDiscordThreadChannel({ | |
| isGuildMessage, | |
| message, | |
| channelInfo, | |
| }); | |
| let earlyThreadParentId: string | undefined; | |
| let earlyThreadParentName: string | undefined; | |
| let earlyThreadParentType: ChannelType | undefined; | |
| if (earlyThreadChannel) { | |
| const parentInfo = await resolveDiscordThreadParentInfo({ | |
| client: params.client, | |
| threadChannel: earlyThreadChannel, | |
| channelInfo, | |
| }); | |
| earlyThreadParentId = parentInfo.id; | |
| earlyThreadParentName = parentInfo.name; | |
| earlyThreadParentType = parentInfo.type; | |
| } | |
| const route = resolveAgentRoute({ | |
| cfg: params.cfg, | |
| channel: "discord", | |
| accountId: params.accountId, | |
| guildId: params.data.guild_id ?? undefined, | |
| peer: { | |
| kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel", | |
| id: isDirectMessage ? author.id : message.channelId, | |
| }, | |
| // Pass parent peer for thread binding inheritance | |
| parentPeer: earlyThreadParentId ? { kind: "channel", id: earlyThreadParentId } : undefined, | |
| }); | |
| const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId); | |
| const explicitlyMentioned = Boolean( | |
| botId && message.mentionedUsers?.some((user: User) => user.id === botId), | |
| ); | |
| const hasAnyMention = Boolean( | |
| !isDirectMessage && | |
| (message.mentionedEveryone || | |
| (message.mentionedUsers?.length ?? 0) > 0 || | |
| (message.mentionedRoles?.length ?? 0) > 0), | |
| ); | |
| const wasMentioned = | |
| !isDirectMessage && | |
| matchesMentionWithExplicit({ | |
| text: baseText, | |
| mentionRegexes, | |
| explicit: { | |
| hasAnyMention, | |
| isExplicitlyMentioned: explicitlyMentioned, | |
| canResolveExplicit: Boolean(botId), | |
| }, | |
| }); | |
| const implicitMention = Boolean( | |
| !isDirectMessage && | |
| botId && | |
| message.referencedMessage?.author?.id && | |
| message.referencedMessage.author.id === botId, | |
| ); | |
| if (shouldLogVerbose()) { | |
| logVerbose( | |
| `discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${messageText ? "yes" : "no"}`, | |
| ); | |
| } | |
| if ( | |
| isGuildMessage && | |
| (message.type === MessageType.ChatInputCommand || | |
| message.type === MessageType.ContextMenuCommand) | |
| ) { | |
| logVerbose("discord: drop channel command message"); | |
| return null; | |
| } | |
| const guildInfo = isGuildMessage | |
| ? resolveDiscordGuildEntry({ | |
| guild: params.data.guild ?? undefined, | |
| guildEntries: params.guildEntries, | |
| }) | |
| : null; | |
| if ( | |
| isGuildMessage && | |
| params.guildEntries && | |
| Object.keys(params.guildEntries).length > 0 && | |
| !guildInfo | |
| ) { | |
| logVerbose( | |
| `Blocked discord guild ${params.data.guild_id ?? "unknown"} (not in discord.guilds)`, | |
| ); | |
| return null; | |
| } | |
| // Reuse early thread resolution from above (for binding inheritance) | |
| const threadChannel = earlyThreadChannel; | |
| const threadParentId = earlyThreadParentId; | |
| const threadParentName = earlyThreadParentName; | |
| const threadParentType = earlyThreadParentType; | |
| const threadName = threadChannel?.name; | |
| const configChannelName = threadParentName ?? channelName; | |
| const configChannelSlug = configChannelName ? normalizeDiscordSlug(configChannelName) : ""; | |
| const displayChannelName = threadName ?? channelName; | |
| const displayChannelSlug = displayChannelName ? normalizeDiscordSlug(displayChannelName) : ""; | |
| const guildSlug = | |
| guildInfo?.slug || | |
| (params.data.guild?.name ? normalizeDiscordSlug(params.data.guild.name) : ""); | |
| const threadChannelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; | |
| const threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : ""; | |
| const baseSessionKey = route.sessionKey; | |
| const channelConfig = isGuildMessage | |
| ? resolveDiscordChannelConfigWithFallback({ | |
| guildInfo, | |
| channelId: message.channelId, | |
| channelName, | |
| channelSlug: threadChannelSlug, | |
| parentId: threadParentId ?? undefined, | |
| parentName: threadParentName ?? undefined, | |
| parentSlug: threadParentSlug, | |
| scope: threadChannel ? "thread" : "channel", | |
| }) | |
| : null; | |
| const channelMatchMeta = formatAllowlistMatchMeta(channelConfig); | |
| if (isGuildMessage && channelConfig?.enabled === false) { | |
| logVerbose( | |
| `Blocked discord channel ${message.channelId} (channel disabled, ${channelMatchMeta})`, | |
| ); | |
| return null; | |
| } | |
| const groupDmAllowed = | |
| isGroupDm && | |
| resolveGroupDmAllow({ | |
| channels: params.groupDmChannels, | |
| channelId: message.channelId, | |
| channelName: displayChannelName, | |
| channelSlug: displayChannelSlug, | |
| }); | |
| if (isGroupDm && !groupDmAllowed) { | |
| return null; | |
| } | |
| const channelAllowlistConfigured = | |
| Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; | |
| const channelAllowed = channelConfig?.allowed !== false; | |
| if ( | |
| isGuildMessage && | |
| !isDiscordGroupAllowedByPolicy({ | |
| groupPolicy: params.groupPolicy, | |
| guildAllowlisted: Boolean(guildInfo), | |
| channelAllowlistConfigured, | |
| channelAllowed, | |
| }) | |
| ) { | |
| if (params.groupPolicy === "disabled") { | |
| logVerbose(`discord: drop guild message (groupPolicy: disabled, ${channelMatchMeta})`); | |
| } else if (!channelAllowlistConfigured) { | |
| logVerbose( | |
| `discord: drop guild message (groupPolicy: allowlist, no channel allowlist, ${channelMatchMeta})`, | |
| ); | |
| } else { | |
| logVerbose( | |
| `Blocked discord channel ${message.channelId} not in guild channel allowlist (groupPolicy: allowlist, ${channelMatchMeta})`, | |
| ); | |
| } | |
| return null; | |
| } | |
| if (isGuildMessage && channelConfig?.allowed === false) { | |
| logVerbose( | |
| `Blocked discord channel ${message.channelId} not in guild channel allowlist (${channelMatchMeta})`, | |
| ); | |
| return null; | |
| } | |
| if (isGuildMessage) { | |
| logVerbose(`discord: allow channel ${message.channelId} (${channelMatchMeta})`); | |
| } | |
| const textForHistory = resolveDiscordMessageText(message, { | |
| includeForwarded: true, | |
| }); | |
| const historyEntry = | |
| isGuildMessage && params.historyLimit > 0 && textForHistory | |
| ? ({ | |
| sender: sender.label, | |
| body: textForHistory, | |
| timestamp: resolveTimestampMs(message.timestamp), | |
| messageId: message.id, | |
| } satisfies HistoryEntry) | |
| : undefined; | |
| const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined; | |
| const shouldRequireMention = resolveDiscordShouldRequireMention({ | |
| isGuildMessage, | |
| isThread: Boolean(threadChannel), | |
| botId, | |
| threadOwnerId, | |
| channelConfig, | |
| guildInfo, | |
| }); | |
| const allowTextCommands = shouldHandleTextCommands({ | |
| cfg: params.cfg, | |
| surface: "discord", | |
| }); | |
| const hasControlCommandInMessage = hasControlCommand(baseText, params.cfg); | |
| if (!isDirectMessage) { | |
| const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, [ | |
| "discord:", | |
| "user:", | |
| "pk:", | |
| ]); | |
| const ownerOk = ownerAllowList | |
| ? allowListMatches(ownerAllowList, { | |
| id: sender.id, | |
| name: sender.name, | |
| tag: sender.tag, | |
| }) | |
| : false; | |
| const channelUsers = channelConfig?.users ?? guildInfo?.users; | |
| const usersOk = | |
| Array.isArray(channelUsers) && channelUsers.length > 0 | |
| ? resolveDiscordUserAllowed({ | |
| allowList: channelUsers, | |
| userId: sender.id, | |
| userName: sender.name, | |
| userTag: sender.tag, | |
| }) | |
| : false; | |
| const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; | |
| const commandGate = resolveControlCommandGate({ | |
| useAccessGroups, | |
| authorizers: [ | |
| { configured: ownerAllowList != null, allowed: ownerOk }, | |
| { configured: Array.isArray(channelUsers) && channelUsers.length > 0, allowed: usersOk }, | |
| ], | |
| modeWhenAccessGroupsOff: "configured", | |
| allowTextCommands, | |
| hasControlCommand: hasControlCommandInMessage, | |
| }); | |
| commandAuthorized = commandGate.commandAuthorized; | |
| if (commandGate.shouldBlock) { | |
| logInboundDrop({ | |
| log: logVerbose, | |
| channel: "discord", | |
| reason: "control command (unauthorized)", | |
| target: sender.id, | |
| }); | |
| return null; | |
| } | |
| } | |
| const canDetectMention = Boolean(botId) || mentionRegexes.length > 0; | |
| const mentionGate = resolveMentionGatingWithBypass({ | |
| isGroup: isGuildMessage, | |
| requireMention: Boolean(shouldRequireMention), | |
| canDetectMention, | |
| wasMentioned, | |
| implicitMention, | |
| hasAnyMention, | |
| allowTextCommands, | |
| hasControlCommand: hasControlCommandInMessage, | |
| commandAuthorized, | |
| }); | |
| const effectiveWasMentioned = mentionGate.effectiveWasMentioned; | |
| if (isGuildMessage && shouldRequireMention) { | |
| if (botId && mentionGate.shouldSkip) { | |
| logVerbose(`discord: drop guild message (mention required, botId=${botId})`); | |
| logger.info( | |
| { | |
| channelId: message.channelId, | |
| reason: "no-mention", | |
| }, | |
| "discord: skipping guild message", | |
| ); | |
| recordPendingHistoryEntryIfEnabled({ | |
| historyMap: params.guildHistories, | |
| historyKey: message.channelId, | |
| limit: params.historyLimit, | |
| entry: historyEntry ?? null, | |
| }); | |
| return null; | |
| } | |
| } | |
| if (isGuildMessage) { | |
| const channelUsers = channelConfig?.users ?? guildInfo?.users; | |
| if (Array.isArray(channelUsers) && channelUsers.length > 0) { | |
| const userOk = resolveDiscordUserAllowed({ | |
| allowList: channelUsers, | |
| userId: sender.id, | |
| userName: sender.name, | |
| userTag: sender.tag, | |
| }); | |
| if (!userOk) { | |
| logVerbose(`Blocked discord guild sender ${sender.id} (not in channel users allowlist)`); | |
| return null; | |
| } | |
| } | |
| } | |
| const systemLocation = resolveDiscordSystemLocation({ | |
| isDirectMessage, | |
| isGroupDm, | |
| guild: params.data.guild ?? undefined, | |
| channelName: channelName ?? message.channelId, | |
| }); | |
| const systemText = resolveDiscordSystemEvent(message, systemLocation); | |
| if (systemText) { | |
| enqueueSystemEvent(systemText, { | |
| sessionKey: route.sessionKey, | |
| contextKey: `discord:system:${message.channelId}:${message.id}`, | |
| }); | |
| return null; | |
| } | |
| if (!messageText) { | |
| logVerbose(`discord: drop message ${message.id} (empty content)`); | |
| return null; | |
| } | |
| return { | |
| cfg: params.cfg, | |
| discordConfig: params.discordConfig, | |
| accountId: params.accountId, | |
| token: params.token, | |
| runtime: params.runtime, | |
| botUserId: params.botUserId, | |
| guildHistories: params.guildHistories, | |
| historyLimit: params.historyLimit, | |
| mediaMaxBytes: params.mediaMaxBytes, | |
| textLimit: params.textLimit, | |
| replyToMode: params.replyToMode, | |
| ackReactionScope: params.ackReactionScope, | |
| groupPolicy: params.groupPolicy, | |
| data: params.data, | |
| client: params.client, | |
| message, | |
| author, | |
| sender, | |
| channelInfo, | |
| channelName, | |
| isGuildMessage, | |
| isDirectMessage, | |
| isGroupDm, | |
| commandAuthorized, | |
| baseText, | |
| messageText, | |
| wasMentioned, | |
| route, | |
| guildInfo, | |
| guildSlug, | |
| threadChannel, | |
| threadParentId, | |
| threadParentName, | |
| threadParentType, | |
| threadName, | |
| configChannelName, | |
| configChannelSlug, | |
| displayChannelName, | |
| displayChannelSlug, | |
| baseSessionKey, | |
| channelConfig, | |
| channelAllowlistConfigured, | |
| channelAllowed, | |
| shouldRequireMention, | |
| hasAnyMention, | |
| allowTextCommands, | |
| shouldBypassMention: mentionGate.shouldBypassMention, | |
| effectiveWasMentioned, | |
| canDetectMention, | |
| historyEntry, | |
| }; | |
| } | |