mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-18 13:30:48 +00:00
* ACPX: keep plugin-local runtime installs out of dist * Gateway: harden ACP startup and service PATH * ACP: reinitialize error-state configured bindings * ACP: classify pre-turn runtime failures as session init failures * Plugins: move configured ACP routing behind channel seams * Telegram tests: align startup probe assertions after rebase * Discord: harden ACP configured binding recovery * ACP: recover Discord bindings after stale runtime exits * ACPX: replace dead sessions during ensure * Discord: harden ACP binding recovery * Discord: fix review follow-ups * ACP bindings: load channel snapshots across workspaces * ACP bindings: cache snapshot channel plugin resolution * Experiments: add ACP pluginification holy grail plan * Experiments: rename ACP pluginification plan doc * Experiments: drop old ACP pluginification doc path * ACP: move configured bindings behind plugin services * Experiments: update bindings capability architecture plan * Bindings: isolate configured binding routing and targets * Discord tests: fix runtime env helper path * Tests: fix channel binding CI regressions * Tests: normalize ACP workspace assertion on Windows * Bindings: isolate configured binding registry * Bindings: finish configured binding cleanup * Bindings: finish generic cleanup * Bindings: align runtime approval callbacks * ACP: delete residual bindings barrel * Bindings: restore legacy compatibility * Revert "Bindings: restore legacy compatibility" This reverts commit ac2ed68fa2426ecc874d68278c71c71ad363fcfe. * Tests: drop ACP route legacy helper names * Discord/ACP: fix binding regressions --------- Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
945 lines
32 KiB
TypeScript
945 lines
32 KiB
TypeScript
import { ChannelType, MessageType, type Message, type User } from "@buape/carbon";
|
|
import { Routes, type APIMessage } from "discord-api-types/v10";
|
|
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime";
|
|
import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime";
|
|
import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime";
|
|
import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime";
|
|
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
|
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
|
|
import {
|
|
ensureConfiguredBindingRouteReady,
|
|
resolveConfiguredBindingRoute,
|
|
} from "openclaw/plugin-sdk/conversation-runtime";
|
|
import {
|
|
getSessionBindingService,
|
|
type SessionBindingRecord,
|
|
} from "openclaw/plugin-sdk/conversation-runtime";
|
|
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
|
|
import { isPluginOwnedSessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime";
|
|
import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
|
|
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
|
|
import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime";
|
|
import { shouldHandleTextCommands } from "openclaw/plugin-sdk/reply-runtime";
|
|
import {
|
|
recordPendingHistoryEntryIfEnabled,
|
|
type HistoryEntry,
|
|
} from "openclaw/plugin-sdk/reply-runtime";
|
|
import { buildMentionRegexes, matchesMentionWithExplicit } from "openclaw/plugin-sdk/reply-runtime";
|
|
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
|
|
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
|
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
|
|
import { logDebug } from "openclaw/plugin-sdk/text-runtime";
|
|
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
|
|
import { sendMessageDiscord } from "../send.js";
|
|
import {
|
|
isDiscordGroupAllowedByPolicy,
|
|
normalizeDiscordSlug,
|
|
resolveDiscordChannelConfigWithFallback,
|
|
resolveDiscordGuildEntry,
|
|
resolveDiscordMemberAccessState,
|
|
resolveDiscordOwnerAccess,
|
|
resolveDiscordShouldRequireMention,
|
|
resolveGroupDmAllow,
|
|
} from "./allow-list.js";
|
|
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
|
|
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
|
|
import {
|
|
formatDiscordUserTag,
|
|
resolveDiscordSystemLocation,
|
|
resolveTimestampMs,
|
|
} from "./format.js";
|
|
import type {
|
|
DiscordMessagePreflightContext,
|
|
DiscordMessagePreflightParams,
|
|
} from "./message-handler.preflight.types.js";
|
|
import {
|
|
resolveDiscordChannelInfo,
|
|
resolveDiscordMessageChannelId,
|
|
resolveDiscordMessageText,
|
|
} from "./message-utils.js";
|
|
import { resolveDiscordPreflightAudioMentionContext } from "./preflight-audio.js";
|
|
import {
|
|
buildDiscordRoutePeer,
|
|
resolveDiscordConversationRoute,
|
|
resolveDiscordEffectiveRoute,
|
|
} from "./route-resolution.js";
|
|
import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js";
|
|
import { resolveDiscordSystemEvent } from "./system-events.js";
|
|
import { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.js";
|
|
import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js";
|
|
|
|
export type {
|
|
DiscordMessagePreflightContext,
|
|
DiscordMessagePreflightParams,
|
|
} from "./message-handler.preflight.types.js";
|
|
|
|
const DISCORD_BOUND_THREAD_SYSTEM_PREFIXES = ["⚙️", "🤖", "🧰"];
|
|
|
|
function isPreflightAborted(abortSignal?: AbortSignal): boolean {
|
|
return Boolean(abortSignal?.aborted);
|
|
}
|
|
|
|
function isBoundThreadBotSystemMessage(params: {
|
|
isBoundThreadSession: boolean;
|
|
isBotAuthor: boolean;
|
|
text?: string;
|
|
}): boolean {
|
|
if (!params.isBoundThreadSession || !params.isBotAuthor) {
|
|
return false;
|
|
}
|
|
const text = params.text?.trim();
|
|
if (!text) {
|
|
return false;
|
|
}
|
|
return DISCORD_BOUND_THREAD_SYSTEM_PREFIXES.some((prefix) => text.startsWith(prefix));
|
|
}
|
|
|
|
export function resolvePreflightMentionRequirement(params: {
|
|
shouldRequireMention: boolean;
|
|
bypassMentionRequirement: boolean;
|
|
}): boolean {
|
|
if (!params.shouldRequireMention) {
|
|
return false;
|
|
}
|
|
return !params.bypassMentionRequirement;
|
|
}
|
|
|
|
export function shouldIgnoreBoundThreadWebhookMessage(params: {
|
|
accountId?: string;
|
|
threadId?: string;
|
|
webhookId?: string | null;
|
|
threadBinding?: SessionBindingRecord;
|
|
}): boolean {
|
|
const webhookId = params.webhookId?.trim() || "";
|
|
if (!webhookId) {
|
|
return false;
|
|
}
|
|
const boundWebhookId =
|
|
typeof params.threadBinding?.metadata?.webhookId === "string"
|
|
? params.threadBinding.metadata.webhookId.trim()
|
|
: "";
|
|
if (!boundWebhookId) {
|
|
const threadId = params.threadId?.trim() || "";
|
|
if (!threadId) {
|
|
return false;
|
|
}
|
|
return isRecentlyUnboundThreadWebhookMessage({
|
|
accountId: params.accountId,
|
|
threadId,
|
|
webhookId,
|
|
});
|
|
}
|
|
return webhookId === boundWebhookId;
|
|
}
|
|
|
|
function mergeFetchedDiscordMessage(base: Message, fetched: APIMessage): Message {
|
|
const baseReferenced = (
|
|
base as unknown as {
|
|
referencedMessage?: {
|
|
mentionedUsers?: unknown[];
|
|
mentionedRoles?: unknown[];
|
|
mentionedEveryone?: boolean;
|
|
};
|
|
}
|
|
).referencedMessage;
|
|
const fetchedMentions = Array.isArray(fetched.mentions)
|
|
? fetched.mentions.map((mention) => ({
|
|
...mention,
|
|
globalName: mention.global_name ?? undefined,
|
|
}))
|
|
: undefined;
|
|
const referencedMessage = fetched.referenced_message
|
|
? ({
|
|
...((base as { referencedMessage?: object }).referencedMessage ?? {}),
|
|
...fetched.referenced_message,
|
|
mentionedUsers: Array.isArray(fetched.referenced_message.mentions)
|
|
? fetched.referenced_message.mentions.map((mention) => ({
|
|
...mention,
|
|
globalName: mention.global_name ?? undefined,
|
|
}))
|
|
: (baseReferenced?.mentionedUsers ?? []),
|
|
mentionedRoles:
|
|
fetched.referenced_message.mention_roles ?? baseReferenced?.mentionedRoles ?? [],
|
|
mentionedEveryone:
|
|
fetched.referenced_message.mention_everyone ?? baseReferenced?.mentionedEveryone ?? false,
|
|
} satisfies Record<string, unknown>)
|
|
: (base as { referencedMessage?: Message }).referencedMessage;
|
|
const rawData = {
|
|
...((base as { rawData?: Record<string, unknown> }).rawData ?? {}),
|
|
message_snapshots:
|
|
fetched.message_snapshots ??
|
|
(base as { rawData?: { message_snapshots?: unknown } }).rawData?.message_snapshots,
|
|
sticker_items:
|
|
(fetched as { sticker_items?: unknown }).sticker_items ??
|
|
(base as { rawData?: { sticker_items?: unknown } }).rawData?.sticker_items,
|
|
};
|
|
return {
|
|
...base,
|
|
...fetched,
|
|
content: fetched.content ?? base.content,
|
|
attachments: fetched.attachments ?? base.attachments,
|
|
embeds: fetched.embeds ?? base.embeds,
|
|
stickers:
|
|
(fetched as { stickers?: unknown }).stickers ??
|
|
(fetched as { sticker_items?: unknown }).sticker_items ??
|
|
base.stickers,
|
|
mentionedUsers: fetchedMentions ?? base.mentionedUsers,
|
|
mentionedRoles: fetched.mention_roles ?? base.mentionedRoles,
|
|
mentionedEveryone: fetched.mention_everyone ?? base.mentionedEveryone,
|
|
referencedMessage,
|
|
rawData,
|
|
} as unknown as Message;
|
|
}
|
|
|
|
async function hydrateDiscordMessageIfEmpty(params: {
|
|
client: DiscordMessagePreflightParams["client"];
|
|
message: Message;
|
|
messageChannelId: string;
|
|
}): Promise<Message> {
|
|
const currentText = resolveDiscordMessageText(params.message, {
|
|
includeForwarded: true,
|
|
});
|
|
if (currentText) {
|
|
return params.message;
|
|
}
|
|
const rest = params.client.rest as { get?: (route: string) => Promise<unknown> } | undefined;
|
|
if (typeof rest?.get !== "function") {
|
|
return params.message;
|
|
}
|
|
try {
|
|
const fetched = (await rest.get(
|
|
Routes.channelMessage(params.messageChannelId, params.message.id),
|
|
)) as APIMessage | null | undefined;
|
|
if (!fetched) {
|
|
return params.message;
|
|
}
|
|
logVerbose(`discord: hydrated empty inbound payload via REST for ${params.message.id}`);
|
|
return mergeFetchedDiscordMessage(params.message, fetched);
|
|
} catch (err) {
|
|
logVerbose(`discord: failed to hydrate message ${params.message.id}: ${String(err)}`);
|
|
return params.message;
|
|
}
|
|
}
|
|
|
|
export async function preflightDiscordMessage(
|
|
params: DiscordMessagePreflightParams,
|
|
): Promise<DiscordMessagePreflightContext | null> {
|
|
if (isPreflightAborted(params.abortSignal)) {
|
|
return null;
|
|
}
|
|
const logger = getChildLogger({ module: "discord-auto-reply" });
|
|
let message = params.data.message;
|
|
const author = params.data.author;
|
|
if (!author) {
|
|
return null;
|
|
}
|
|
const messageChannelId = resolveDiscordMessageChannelId({
|
|
message,
|
|
eventChannelId: params.data.channel_id,
|
|
});
|
|
if (!messageChannelId) {
|
|
logVerbose(`discord: drop message ${message.id} (missing channel id)`);
|
|
return null;
|
|
}
|
|
|
|
const allowBotsSetting = params.discordConfig?.allowBots;
|
|
const allowBotsMode =
|
|
allowBotsSetting === "mentions" ? "mentions" : allowBotsSetting === true ? "all" : "off";
|
|
if (params.botUserId && author.id === params.botUserId) {
|
|
// Always ignore own messages to prevent self-reply loops
|
|
return null;
|
|
}
|
|
|
|
message = await hydrateDiscordMessageIfEmpty({
|
|
client: params.client,
|
|
message,
|
|
messageChannelId,
|
|
});
|
|
if (isPreflightAborted(params.abortSignal)) {
|
|
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,
|
|
});
|
|
if (isPreflightAborted(params.abortSignal)) {
|
|
return null;
|
|
}
|
|
} 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 (allowBotsMode === "off" && !sender.isPluralKit) {
|
|
logVerbose("discord: drop bot message (allowBots=false)");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const isGuildMessage = Boolean(params.data.guild_id);
|
|
const channelInfo = await resolveDiscordChannelInfo(params.client, messageChannelId);
|
|
if (isPreflightAborted(params.abortSignal)) {
|
|
return null;
|
|
}
|
|
const isDirectMessage = channelInfo?.type === ChannelType.DM;
|
|
const isGroupDm = channelInfo?.type === ChannelType.GroupDM;
|
|
const data = message === params.data.message ? params.data : { ...params.data, message };
|
|
logDebug(
|
|
`[discord-preflight] channelId=${messageChannelId} guild_id=${params.data.guild_id} channelType=${channelInfo?.type} isGuild=${isGuildMessage} isDM=${isDirectMessage} isGroupDm=${isGroupDm}`,
|
|
);
|
|
|
|
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?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing";
|
|
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
|
const resolvedAccountId = params.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
const allowNameMatching = isDangerousNameMatchingEnabled(params.discordConfig);
|
|
let commandAuthorized = true;
|
|
if (isDirectMessage) {
|
|
if (dmPolicy === "disabled") {
|
|
logVerbose("discord: drop dm (dmPolicy: disabled)");
|
|
return null;
|
|
}
|
|
const dmAccess = await resolveDiscordDmCommandAccess({
|
|
accountId: resolvedAccountId,
|
|
dmPolicy,
|
|
configuredAllowFrom: params.allowFrom ?? [],
|
|
sender: {
|
|
id: sender.id,
|
|
name: sender.name,
|
|
tag: sender.tag,
|
|
},
|
|
allowNameMatching,
|
|
useAccessGroups,
|
|
});
|
|
if (isPreflightAborted(params.abortSignal)) {
|
|
return null;
|
|
}
|
|
commandAuthorized = dmAccess.commandAuthorized;
|
|
if (dmAccess.decision !== "allow") {
|
|
const allowMatchMeta = formatAllowlistMatchMeta(
|
|
dmAccess.allowMatch.allowed ? dmAccess.allowMatch : undefined,
|
|
);
|
|
await handleDiscordDmCommandDecision({
|
|
dmAccess,
|
|
accountId: resolvedAccountId,
|
|
sender: {
|
|
id: author.id,
|
|
tag: formatDiscordUserTag(author),
|
|
name: author.username ?? undefined,
|
|
},
|
|
onPairingCreated: async (code) => {
|
|
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)}`);
|
|
}
|
|
},
|
|
onUnauthorized: async () => {
|
|
logVerbose(
|
|
`Blocked unauthorized discord sender ${sender.id} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
|
);
|
|
},
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const botId = params.botUserId;
|
|
const baseText = resolveDiscordMessageText(message, {
|
|
includeForwarded: false,
|
|
});
|
|
const messageText = resolveDiscordMessageText(message, {
|
|
includeForwarded: true,
|
|
});
|
|
|
|
// Intercept text-only slash commands (e.g. user typing "/reset" instead of using Discord's slash command picker)
|
|
// These should not be forwarded to the agent; proper slash command interactions are handled elsewhere
|
|
if (!isDirectMessage && baseText && hasControlCommand(baseText, params.cfg)) {
|
|
logVerbose(`discord: drop text-based slash command ${message.id} (intercepted at gateway)`);
|
|
return null;
|
|
}
|
|
|
|
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,
|
|
messageChannelId,
|
|
});
|
|
let earlyThreadParentId: string | undefined;
|
|
let earlyThreadParentName: string | undefined;
|
|
let earlyThreadParentType: ChannelType | undefined;
|
|
if (earlyThreadChannel) {
|
|
const parentInfo = await resolveDiscordThreadParentInfo({
|
|
client: params.client,
|
|
threadChannel: earlyThreadChannel,
|
|
channelInfo,
|
|
});
|
|
if (isPreflightAborted(params.abortSignal)) {
|
|
return null;
|
|
}
|
|
earlyThreadParentId = parentInfo.id;
|
|
earlyThreadParentName = parentInfo.name;
|
|
earlyThreadParentType = parentInfo.type;
|
|
}
|
|
|
|
// Fresh config for bindings lookup; other routing inputs are payload-derived.
|
|
const memberRoleIds = Array.isArray(params.data.rawMember?.roles)
|
|
? params.data.rawMember.roles.map((roleId: string) => String(roleId))
|
|
: [];
|
|
const freshCfg = loadConfig();
|
|
const route = resolveDiscordConversationRoute({
|
|
cfg: freshCfg,
|
|
accountId: params.accountId,
|
|
guildId: params.data.guild_id ?? undefined,
|
|
memberRoleIds,
|
|
peer: buildDiscordRoutePeer({
|
|
isDirectMessage,
|
|
isGroupDm,
|
|
directUserId: author.id,
|
|
conversationId: messageChannelId,
|
|
}),
|
|
parentConversationId: earlyThreadParentId,
|
|
});
|
|
const bindingConversationId = isDirectMessage ? `user:${author.id}` : messageChannelId;
|
|
let threadBinding: SessionBindingRecord | undefined;
|
|
threadBinding =
|
|
getSessionBindingService().resolveByConversation({
|
|
channel: "discord",
|
|
accountId: params.accountId,
|
|
conversationId: bindingConversationId,
|
|
parentConversationId: earlyThreadParentId,
|
|
}) ?? undefined;
|
|
const configuredRoute =
|
|
threadBinding == null
|
|
? resolveConfiguredBindingRoute({
|
|
cfg: freshCfg,
|
|
route,
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: params.accountId,
|
|
conversationId: messageChannelId,
|
|
parentConversationId: earlyThreadParentId,
|
|
},
|
|
})
|
|
: null;
|
|
const configuredBinding = configuredRoute?.bindingResolution ?? null;
|
|
if (!threadBinding && configuredBinding) {
|
|
threadBinding = configuredBinding.record;
|
|
}
|
|
if (
|
|
shouldIgnoreBoundThreadWebhookMessage({
|
|
accountId: params.accountId,
|
|
threadId: messageChannelId,
|
|
webhookId,
|
|
threadBinding,
|
|
})
|
|
) {
|
|
logVerbose(`discord: drop bound-thread webhook echo message ${message.id}`);
|
|
return null;
|
|
}
|
|
const boundSessionKey = isPluginOwnedSessionBindingRecord(threadBinding)
|
|
? ""
|
|
: threadBinding?.targetSessionKey?.trim();
|
|
const effectiveRoute = resolveDiscordEffectiveRoute({
|
|
route,
|
|
boundSessionKey,
|
|
configuredRoute,
|
|
matchedBy: "binding.channel",
|
|
});
|
|
const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined;
|
|
const isBoundThreadSession = Boolean(threadBinding && earlyThreadChannel);
|
|
const bypassMentionRequirement = isBoundThreadSession || Boolean(configuredBinding);
|
|
if (
|
|
isBoundThreadBotSystemMessage({
|
|
isBoundThreadSession,
|
|
isBotAuthor: Boolean(author.bot),
|
|
text: messageText,
|
|
})
|
|
) {
|
|
logVerbose(`discord: drop bound-thread bot system message ${message.id}`);
|
|
return null;
|
|
}
|
|
const mentionRegexes = buildMentionRegexes(params.cfg, effectiveRoute.agentId);
|
|
const explicitlyMentioned = Boolean(
|
|
botId && message.mentionedUsers?.some((user: User) => user.id === botId),
|
|
);
|
|
const hasAnyMention = Boolean(
|
|
!isDirectMessage &&
|
|
((message.mentionedUsers?.length ?? 0) > 0 ||
|
|
(message.mentionedRoles?.length ?? 0) > 0 ||
|
|
(message.mentionedEveryone && (!author.bot || sender.isPluralKit))),
|
|
);
|
|
const hasUserOrRoleMention = Boolean(
|
|
!isDirectMessage &&
|
|
((message.mentionedUsers?.length ?? 0) > 0 || (message.mentionedRoles?.length ?? 0) > 0),
|
|
);
|
|
|
|
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,
|
|
guildId: params.data.guild_id ?? undefined,
|
|
guildEntries: params.guildEntries,
|
|
})
|
|
: null;
|
|
logDebug(
|
|
`[discord-preflight] guild_id=${params.data.guild_id} guild_obj=${!!params.data.guild} guild_obj_id=${params.data.guild?.id} guildInfo=${!!guildInfo} guildEntries=${params.guildEntries ? Object.keys(params.guildEntries).join(",") : "none"}`,
|
|
);
|
|
if (
|
|
isGuildMessage &&
|
|
params.guildEntries &&
|
|
Object.keys(params.guildEntries).length > 0 &&
|
|
!guildInfo
|
|
) {
|
|
logDebug(
|
|
`[discord-preflight] guild blocked: guild_id=${params.data.guild_id} guildEntries keys=${Object.keys(params.guildEntries).join(",")}`,
|
|
);
|
|
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 = effectiveRoute.sessionKey;
|
|
const channelConfig = isGuildMessage
|
|
? resolveDiscordChannelConfigWithFallback({
|
|
guildInfo,
|
|
channelId: messageChannelId,
|
|
channelName,
|
|
channelSlug: threadChannelSlug,
|
|
parentId: threadParentId ?? undefined,
|
|
parentName: threadParentName ?? undefined,
|
|
parentSlug: threadParentSlug,
|
|
scope: threadChannel ? "thread" : "channel",
|
|
})
|
|
: null;
|
|
const channelMatchMeta = formatAllowlistMatchMeta(channelConfig);
|
|
if (shouldLogVerbose()) {
|
|
const channelConfigSummary = channelConfig
|
|
? `allowed=${channelConfig.allowed} enabled=${channelConfig.enabled ?? "unset"} requireMention=${channelConfig.requireMention ?? "unset"} ignoreOtherMentions=${channelConfig.ignoreOtherMentions ?? "unset"} matchKey=${channelConfig.matchKey ?? "none"} matchSource=${channelConfig.matchSource ?? "none"} users=${channelConfig.users?.length ?? 0} roles=${channelConfig.roles?.length ?? 0} skills=${channelConfig.skills?.length ?? 0}`
|
|
: "none";
|
|
logDebug(
|
|
`[discord-preflight] channelConfig=${channelConfigSummary} channelMatchMeta=${channelMatchMeta} channelId=${messageChannelId}`,
|
|
);
|
|
}
|
|
if (isGuildMessage && channelConfig?.enabled === false) {
|
|
logDebug(`[discord-preflight] drop: channel disabled`);
|
|
logVerbose(
|
|
`Blocked discord channel ${messageChannelId} (channel disabled, ${channelMatchMeta})`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const groupDmAllowed =
|
|
isGroupDm &&
|
|
resolveGroupDmAllow({
|
|
channels: params.groupDmChannels,
|
|
channelId: messageChannelId,
|
|
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") {
|
|
logDebug(`[discord-preflight] drop: groupPolicy disabled`);
|
|
logVerbose(`discord: drop guild message (groupPolicy: disabled, ${channelMatchMeta})`);
|
|
} else if (!channelAllowlistConfigured) {
|
|
logDebug(`[discord-preflight] drop: groupPolicy allowlist, no channel allowlist configured`);
|
|
logVerbose(
|
|
`discord: drop guild message (groupPolicy: allowlist, no channel allowlist, ${channelMatchMeta})`,
|
|
);
|
|
} else {
|
|
logDebug(
|
|
`[discord] Ignored message from channel ${messageChannelId} (not in guild allowlist). Add to guilds.<guildId>.channels to enable.`,
|
|
);
|
|
logVerbose(
|
|
`Blocked discord channel ${messageChannelId} not in guild channel allowlist (groupPolicy: allowlist, ${channelMatchMeta})`,
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (isGuildMessage && channelConfig?.allowed === false) {
|
|
logDebug(`[discord-preflight] drop: channelConfig.allowed===false`);
|
|
logVerbose(
|
|
`Blocked discord channel ${messageChannelId} not in guild channel allowlist (${channelMatchMeta})`,
|
|
);
|
|
return null;
|
|
}
|
|
if (isGuildMessage) {
|
|
logDebug(`[discord-preflight] pass: channel allowed`);
|
|
logVerbose(`discord: allow channel ${messageChannelId} (${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 shouldRequireMentionByConfig = resolveDiscordShouldRequireMention({
|
|
isGuildMessage,
|
|
isThread: Boolean(threadChannel),
|
|
botId,
|
|
threadOwnerId,
|
|
channelConfig,
|
|
guildInfo,
|
|
});
|
|
const shouldRequireMention = resolvePreflightMentionRequirement({
|
|
shouldRequireMention: shouldRequireMentionByConfig,
|
|
bypassMentionRequirement,
|
|
});
|
|
|
|
// Preflight audio transcription for mention detection in guilds.
|
|
// This allows voice notes to be checked for mentions before being dropped.
|
|
const { hasTypedText, transcript: preflightTranscript } =
|
|
await resolveDiscordPreflightAudioMentionContext({
|
|
message,
|
|
isDirectMessage,
|
|
shouldRequireMention,
|
|
mentionRegexes,
|
|
cfg: params.cfg,
|
|
abortSignal: params.abortSignal,
|
|
});
|
|
if (isPreflightAborted(params.abortSignal)) {
|
|
return null;
|
|
}
|
|
|
|
const mentionText = hasTypedText ? baseText : "";
|
|
const wasMentioned =
|
|
!isDirectMessage &&
|
|
matchesMentionWithExplicit({
|
|
text: mentionText,
|
|
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=${params.data.guild_id ?? "dm"} channel=${messageChannelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${messageText ? "yes" : "no"}`,
|
|
);
|
|
}
|
|
|
|
const allowTextCommands = shouldHandleTextCommands({
|
|
cfg: params.cfg,
|
|
surface: "discord",
|
|
});
|
|
const hasControlCommandInMessage = hasControlCommand(baseText, params.cfg);
|
|
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
|
|
channelConfig,
|
|
guildInfo,
|
|
memberRoleIds,
|
|
sender,
|
|
allowNameMatching,
|
|
});
|
|
|
|
if (!isDirectMessage) {
|
|
const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({
|
|
allowFrom: params.allowFrom,
|
|
sender: {
|
|
id: sender.id,
|
|
name: sender.name,
|
|
tag: sender.tag,
|
|
},
|
|
allowNameMatching,
|
|
});
|
|
const commandGate = resolveControlCommandGate({
|
|
useAccessGroups,
|
|
authorizers: [
|
|
{ configured: ownerAllowList != null, allowed: ownerOk },
|
|
{ configured: hasAccessRestrictions, allowed: memberAllowed },
|
|
],
|
|
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;
|
|
logDebug(
|
|
`[discord-preflight] shouldRequireMention=${shouldRequireMention} baseRequireMention=${shouldRequireMentionByConfig} boundThreadSession=${isBoundThreadSession} mentionGate.shouldSkip=${mentionGate.shouldSkip} wasMentioned=${wasMentioned}`,
|
|
);
|
|
if (isGuildMessage && shouldRequireMention) {
|
|
if (botId && mentionGate.shouldSkip) {
|
|
logDebug(`[discord-preflight] drop: no-mention`);
|
|
logVerbose(`discord: drop guild message (mention required, botId=${botId})`);
|
|
logger.info(
|
|
{
|
|
channelId: messageChannelId,
|
|
reason: "no-mention",
|
|
},
|
|
"discord: skipping guild message",
|
|
);
|
|
recordPendingHistoryEntryIfEnabled({
|
|
historyMap: params.guildHistories,
|
|
historyKey: messageChannelId,
|
|
limit: params.historyLimit,
|
|
entry: historyEntry ?? null,
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (author.bot && !sender.isPluralKit && allowBotsMode === "mentions") {
|
|
const botMentioned = isDirectMessage || wasMentioned || implicitMention;
|
|
if (!botMentioned) {
|
|
logDebug(`[discord-preflight] drop: bot message missing mention (allowBots=mentions)`);
|
|
logVerbose("discord: drop bot message (allowBots=mentions, missing mention)");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const ignoreOtherMentions =
|
|
channelConfig?.ignoreOtherMentions ?? guildInfo?.ignoreOtherMentions ?? false;
|
|
if (
|
|
isGuildMessage &&
|
|
ignoreOtherMentions &&
|
|
hasUserOrRoleMention &&
|
|
!wasMentioned &&
|
|
!implicitMention
|
|
) {
|
|
logDebug(`[discord-preflight] drop: other-mention`);
|
|
logVerbose(
|
|
`discord: drop guild message (another user/role mentioned, ignoreOtherMentions=true, botId=${botId})`,
|
|
);
|
|
recordPendingHistoryEntryIfEnabled({
|
|
historyMap: params.guildHistories,
|
|
historyKey: messageChannelId,
|
|
limit: params.historyLimit,
|
|
entry: historyEntry ?? null,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
if (isGuildMessage && hasAccessRestrictions && !memberAllowed) {
|
|
logDebug(`[discord-preflight] drop: member not allowed`);
|
|
logVerbose(`Blocked discord guild sender ${sender.id} (not in users/roles allowlist)`);
|
|
return null;
|
|
}
|
|
|
|
const systemLocation = resolveDiscordSystemLocation({
|
|
isDirectMessage,
|
|
isGroupDm,
|
|
guild: params.data.guild ?? undefined,
|
|
channelName: channelName ?? messageChannelId,
|
|
});
|
|
const systemText = resolveDiscordSystemEvent(message, systemLocation);
|
|
if (systemText) {
|
|
logDebug(`[discord-preflight] drop: system event`);
|
|
enqueueSystemEvent(systemText, {
|
|
sessionKey: effectiveRoute.sessionKey,
|
|
contextKey: `discord:system:${messageChannelId}:${message.id}`,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
if (!messageText) {
|
|
logDebug(`[discord-preflight] drop: empty content`);
|
|
logVerbose(`discord: drop message ${message.id} (empty content)`);
|
|
return null;
|
|
}
|
|
if (configuredBinding) {
|
|
const ensured = await ensureConfiguredBindingRouteReady({
|
|
cfg: freshCfg,
|
|
bindingResolution: configuredBinding,
|
|
});
|
|
if (!ensured.ok) {
|
|
logVerbose(
|
|
`discord: configured ACP binding unavailable for channel ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
logDebug(
|
|
`[discord-preflight] success: route=${effectiveRoute.agentId} sessionKey=${effectiveRoute.sessionKey}`,
|
|
);
|
|
return {
|
|
cfg: params.cfg,
|
|
discordConfig: params.discordConfig,
|
|
accountId: params.accountId,
|
|
token: params.token,
|
|
runtime: params.runtime,
|
|
botUserId: params.botUserId,
|
|
abortSignal: params.abortSignal,
|
|
guildHistories: params.guildHistories,
|
|
historyLimit: params.historyLimit,
|
|
mediaMaxBytes: params.mediaMaxBytes,
|
|
textLimit: params.textLimit,
|
|
replyToMode: params.replyToMode,
|
|
ackReactionScope: params.ackReactionScope,
|
|
groupPolicy: params.groupPolicy,
|
|
data,
|
|
client: params.client,
|
|
message,
|
|
messageChannelId,
|
|
author,
|
|
sender,
|
|
channelInfo,
|
|
channelName,
|
|
isGuildMessage,
|
|
isDirectMessage,
|
|
isGroupDm,
|
|
commandAuthorized,
|
|
baseText,
|
|
messageText,
|
|
wasMentioned,
|
|
route: effectiveRoute,
|
|
threadBinding,
|
|
boundSessionKey: boundSessionKey || undefined,
|
|
boundAgentId,
|
|
guildInfo,
|
|
guildSlug,
|
|
threadChannel,
|
|
threadParentId,
|
|
threadParentName,
|
|
threadParentType,
|
|
threadName,
|
|
configChannelName,
|
|
configChannelSlug,
|
|
displayChannelName,
|
|
displayChannelSlug,
|
|
baseSessionKey,
|
|
channelConfig,
|
|
channelAllowlistConfigured,
|
|
channelAllowed,
|
|
shouldRequireMention,
|
|
hasAnyMention,
|
|
allowTextCommands,
|
|
shouldBypassMention: mentionGate.shouldBypassMention,
|
|
effectiveWasMentioned,
|
|
canDetectMention,
|
|
historyEntry,
|
|
threadBindings: params.threadBindings,
|
|
discordRestFetch: params.discordRestFetch,
|
|
};
|
|
}
|