openclaw/src/discord/monitor/message-handler.preflight.ts

643 lines
21 KiB
TypeScript
Raw Normal View History

2026-01-14 01:08:15 +00:00
import { ChannelType, MessageType, type User } from "@buape/carbon";
import type {
DiscordMessagePreflightContext,
DiscordMessagePreflightParams,
} from "./message-handler.preflight.types.js";
2026-01-14 01:08:15 +00:00
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 { loadConfig } from "../../config/config.js";
2026-01-14 01:08:15 +00:00
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";
2026-01-14 01:08:15 +00:00
import { sendMessageDiscord } from "../send.js";
import {
allowListMatches,
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordAllowListMatch,
2026-01-17 15:26:59 -06:00
resolveDiscordChannelConfigWithFallback,
2026-01-14 01:08:15 +00:00
resolveDiscordGuildEntry,
resolveDiscordShouldRequireMention,
resolveDiscordRoleAllowed,
2026-01-14 01:08:15 +00:00
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";
2026-01-14 01:08:15 +00:00
import { resolveDiscordSystemEvent } from "./system-events.js";
import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js";
2026-01-14 01:08:15 +00:00
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;
}
2026-01-14 01:08:15 +00:00
const allowBots = params.discordConfig?.allowBots ?? false;
if (params.botUserId && author.id === params.botUserId) {
2026-01-14 01:08:15 +00:00
// 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) {
2026-01-14 01:08:15 +00:00
logVerbose("discord: drop bot message (allowBots=false)");
return null;
}
}
const isGuildMessage = Boolean(params.data.guild_id);
const channelInfo = await resolveDiscordChannelInfo(params.client, message.channelId);
2026-01-14 01:08:15 +00:00
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,
},
2026-01-14 01:08:15 +00:00
})
: { allowed: false };
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
const permitted = allowMatch.allowed;
2026-01-14 01:08:15 +00:00
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})`,
2026-01-14 01:08:15 +00:00
);
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)}`);
2026-01-14 01:08:15 +00:00
}
}
} else {
logVerbose(
`Blocked unauthorized discord sender ${sender.id} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
2026-01-14 01:08:15 +00:00
}
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;
}
// Fresh config for bindings lookup; other routing inputs are payload-derived.
// member.roles is already string[] (Snowflake IDs) per Discord API types
const memberRoleIds: string[] = params.data.member?.roles ?? [];
2026-01-14 01:08:15 +00:00
const route = resolveAgentRoute({
cfg: loadConfig(),
2026-01-14 01:08:15 +00:00
channel: "discord",
accountId: params.accountId,
guildId: params.data.guild_id ?? undefined,
memberRoleIds,
2026-01-14 01:08:15 +00:00
peer: {
refactor: unify peer kind to ChatType, rename dm to direct (#11881) * fix: use .js extension for ESM imports of RoutePeerKind The imports incorrectly used .ts extension which doesn't resolve with moduleResolution: NodeNext. Changed to .js and added 'type' import modifier. * fix tsconfig * refactor: unify peer kind to ChatType, rename dm to direct - Replace RoutePeerKind with ChatType throughout codebase - Change 'dm' literal values to 'direct' in routing/session keys - Keep backward compat: normalizeChatType accepts 'dm' -> 'direct' - Add ChatType export to plugin-sdk, deprecate RoutePeerKind - Update session key parsing to accept both 'dm' and 'direct' markers - Update all channel monitors and extensions to use ChatType BREAKING CHANGE: Session keys now use 'direct' instead of 'dm'. Existing 'dm' keys still work via backward compat layer. * fix tests * test: update session key expectations for dmdirect migration - Fix test expectations to expect :direct: in generated output - Add explicit backward compat test for normalizeChatType('dm') - Keep input test data with :dm: keys to verify backward compat * fix: accept legacy 'dm' in session key parsing for backward compat getDmHistoryLimitFromSessionKey now accepts both :dm: and :direct: to ensure old session keys continue to work correctly. * test: add explicit backward compat tests for dmdirect migration - session-key.test.ts: verify both :dm: and :direct: keys are valid - getDmHistoryLimitFromSessionKey: verify both formats work * feat: backward compat for resetByType.dm config key * test: skip unix-path Nix tests on Windows
2026-02-08 16:20:52 -08:00
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
2026-01-14 01:08:15 +00:00
id: isDirectMessage ? author.id : message.channelId,
},
// Pass parent peer for thread binding inheritance
parentPeer: earlyThreadParentId ? { kind: "channel", id: earlyThreadParentId } : undefined,
2026-01-14 01:08:15 +00:00
});
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),
);
2026-01-14 01:08:15 +00:00
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;
2026-01-14 01:08:15 +00:00
const threadName = threadChannel?.name;
const configChannelName = threadParentName ?? channelName;
const configChannelSlug = configChannelName ? normalizeDiscordSlug(configChannelName) : "";
2026-01-14 01:08:15 +00:00
const displayChannelName = threadName ?? channelName;
const displayChannelSlug = displayChannelName ? normalizeDiscordSlug(displayChannelName) : "";
2026-01-14 01:08:15 +00:00
const guildSlug =
guildInfo?.slug ||
(params.data.guild?.name ? normalizeDiscordSlug(params.data.guild.name) : "");
2026-01-14 01:08:15 +00:00
2026-01-17 15:26:59 -06:00
const threadChannelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : "";
2026-01-14 01:08:15 +00:00
const baseSessionKey = route.sessionKey;
const channelConfig = isGuildMessage
2026-01-17 15:26:59 -06:00
? resolveDiscordChannelConfigWithFallback({
2026-01-14 01:08:15 +00:00
guildInfo,
2026-01-17 15:26:59 -06:00
channelId: message.channelId,
channelName,
channelSlug: threadChannelSlug,
parentId: threadParentId ?? undefined,
parentName: threadParentName ?? undefined,
parentSlug: threadParentSlug,
scope: threadChannel ? "thread" : "channel",
2026-01-14 01:08:15 +00:00
})
: null;
const channelMatchMeta = formatAllowlistMatchMeta(channelConfig);
2026-01-14 01:08:15 +00:00
if (isGuildMessage && channelConfig?.enabled === false) {
logVerbose(
`Blocked discord channel ${message.channelId} (channel disabled, ${channelMatchMeta})`,
);
2026-01-14 01:08:15 +00:00
return null;
}
const groupDmAllowed =
isGroupDm &&
resolveGroupDmAllow({
channels: params.groupDmChannels,
channelId: message.channelId,
channelName: displayChannelName,
channelSlug: displayChannelSlug,
});
if (isGroupDm && !groupDmAllowed) {
return null;
}
2026-01-14 01:08:15 +00:00
const channelAllowlistConfigured =
Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0;
2026-01-14 01:08:15 +00:00
const channelAllowed = channelConfig?.allowed !== false;
if (
isGuildMessage &&
!isDiscordGroupAllowedByPolicy({
groupPolicy: params.groupPolicy,
guildAllowlisted: Boolean(guildInfo),
2026-01-14 01:08:15 +00:00
channelAllowlistConfigured,
channelAllowed,
})
) {
if (params.groupPolicy === "disabled") {
logVerbose(`discord: drop guild message (groupPolicy: disabled, ${channelMatchMeta})`);
2026-01-14 01:08:15 +00:00
} else if (!channelAllowlistConfigured) {
logVerbose(
`discord: drop guild message (groupPolicy: allowlist, no channel allowlist, ${channelMatchMeta})`,
);
2026-01-14 01:08:15 +00:00
} else {
logVerbose(
`Blocked discord channel ${message.channelId} not in guild channel allowlist (groupPolicy: allowlist, ${channelMatchMeta})`,
2026-01-14 01:08:15 +00:00
);
}
return null;
}
if (isGuildMessage && channelConfig?.allowed === false) {
logVerbose(
`Blocked discord channel ${message.channelId} not in guild channel allowlist (${channelMatchMeta})`,
);
2026-01-14 01:08:15 +00:00
return null;
}
if (isGuildMessage) {
logVerbose(`discord: allow channel ${message.channelId} (${channelMatchMeta})`);
}
2026-01-14 01:08:15 +00:00
const textForHistory = resolveDiscordMessageText(message, {
includeForwarded: true,
});
const historyEntry =
isGuildMessage && params.historyLimit > 0 && textForHistory
? ({
sender: sender.label,
2026-01-14 01:08:15 +00:00
body: textForHistory,
timestamp: resolveTimestampMs(message.timestamp),
messageId: message.id,
} satisfies HistoryEntry)
: undefined;
const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined;
2026-01-14 01:08:15 +00:00
const shouldRequireMention = resolveDiscordShouldRequireMention({
isGuildMessage,
isThread: Boolean(threadChannel),
botId,
threadOwnerId,
2026-01-14 01:08:15 +00:00
channelConfig,
guildInfo,
});
// Preflight audio transcription for mention detection in guilds
// This allows voice notes to be checked for mentions before being dropped
let preflightTranscript: string | undefined;
const hasAudioAttachment = message.attachments?.some((att: { contentType?: string }) =>
att.contentType?.startsWith("audio/"),
);
const needsPreflightTranscription =
!isDirectMessage &&
shouldRequireMention &&
hasAudioAttachment &&
!baseText &&
mentionRegexes.length > 0;
if (needsPreflightTranscription) {
try {
const { transcribeFirstAudio } = await import("../../media-understanding/audio-preflight.js");
const audioPaths =
message.attachments
?.filter((att: { contentType?: string; url: string }) =>
att.contentType?.startsWith("audio/"),
)
.map((att: { url: string }) => att.url) ?? [];
if (audioPaths.length > 0) {
const tempCtx = {
MediaUrls: audioPaths,
MediaTypes: message.attachments
?.filter((att: { contentType?: string; url: string }) =>
att.contentType?.startsWith("audio/"),
)
.map((att: { contentType?: string }) => att.contentType)
.filter(Boolean) as string[],
};
preflightTranscript = await transcribeFirstAudio({
ctx: tempCtx,
cfg: params.cfg,
agentDir: undefined,
});
}
} catch (err) {
logVerbose(`discord: audio preflight transcription failed: ${String(err)}`);
}
}
const wasMentioned =
!isDirectMessage &&
matchesMentionWithExplicit({
text: baseText,
mentionRegexes,
explicit: {
hasAnyMention,
isExplicitlyMentioned: explicitlyMentioned,
canResolveExplicit: Boolean(botId),
},
transcript: preflightTranscript,
});
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"}`,
);
}
2026-01-14 01:08:15 +00:00
const allowTextCommands = shouldHandleTextCommands({
cfg: params.cfg,
surface: "discord",
});
const hasControlCommandInMessage = hasControlCommand(baseText, params.cfg);
2026-01-17 07:59:53 +00:00
if (!isDirectMessage) {
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, [
"discord:",
"user:",
"pk:",
]);
2026-01-17 07:59:53 +00:00
const ownerOk = ownerAllowList
? allowListMatches(ownerAllowList, {
id: sender.id,
name: sender.name,
tag: sender.tag,
})
: false;
2026-01-17 07:59:53 +00:00
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,
2026-01-17 07:59:53 +00:00
})
: false;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
const commandGate = resolveControlCommandGate({
2026-01-17 07:59:53 +00:00
useAccessGroups,
authorizers: [
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: Array.isArray(channelUsers) && channelUsers.length > 0, allowed: usersOk },
],
modeWhenAccessGroupsOff: "configured",
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
2026-01-17 07:59:53 +00:00
});
commandAuthorized = commandGate.commandAuthorized;
if (commandGate.shouldBlock) {
2026-01-23 23:20:07 +00:00
logInboundDrop({
log: logVerbose,
channel: "discord",
reason: "control command (unauthorized)",
target: sender.id,
2026-01-23 23:20:07 +00:00
});
2026-01-17 07:59:53 +00:00
return null;
}
}
2026-01-14 01:08:15 +00:00
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;
2026-01-14 01:08:15 +00:00
if (isGuildMessage && shouldRequireMention) {
if (botId && mentionGate.shouldSkip) {
logVerbose(`discord: drop guild message (mention required, botId=${botId})`);
2026-01-14 01:08:15 +00:00
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,
});
2026-01-14 01:08:15 +00:00
return null;
}
}
if (isGuildMessage) {
const channelUsers = channelConfig?.users ?? guildInfo?.users;
const channelRoles = channelConfig?.roles ?? guildInfo?.roles;
const hasUserRestriction = Array.isArray(channelUsers) && channelUsers.length > 0;
const hasRoleRestriction = Array.isArray(channelRoles) && channelRoles.length > 0;
if (hasUserRestriction || hasRoleRestriction) {
// member.roles is already string[] (Snowflake IDs) per Discord API types
const memberRoleIds: string[] = params.data.member?.roles ?? [];
const userOk = hasUserRestriction
? resolveDiscordUserAllowed({
allowList: channelUsers,
userId: sender.id,
userName: sender.name,
userTag: sender.tag,
})
: false;
const roleOk = hasRoleRestriction
? resolveDiscordRoleAllowed({
allowList: channelRoles,
memberRoleIds,
})
: false;
if (!userOk && !roleOk) {
logVerbose(`Blocked discord guild sender ${sender.id} (not in users/roles allowlist)`);
2026-01-14 01:08:15 +00:00
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,
2026-01-14 01:08:15 +00:00
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,
2026-01-14 01:08:15 +00:00
effectiveWasMentioned,
canDetectMention,
historyEntry,
};
}