mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
1122 lines
38 KiB
TypeScript
1122 lines
38 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/allow-from";
|
|
import {
|
|
buildMentionRegexes,
|
|
implicitMentionKindWhen,
|
|
logInboundDrop,
|
|
matchesMentionWithExplicit,
|
|
resolveInboundMentionDecision,
|
|
} from "openclaw/plugin-sdk/channel-inbound";
|
|
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native";
|
|
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
|
import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-surface";
|
|
import { isDangerousNameMatchingEnabled, loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
|
import type { SessionBindingRecord } from "openclaw/plugin-sdk/conversation-binding-runtime";
|
|
import { enqueueSystemEvent, recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
|
|
import {
|
|
recordPendingHistoryEntryIfEnabled,
|
|
type HistoryEntry,
|
|
} from "openclaw/plugin-sdk/reply-history";
|
|
import { getChildLogger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
|
import { logDebug } from "openclaw/plugin-sdk/text-runtime";
|
|
import { resolveDefaultDiscordAccountId } from "../accounts.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 {
|
|
buildDiscordRoutePeer,
|
|
resolveDiscordConversationRoute,
|
|
resolveDiscordEffectiveRoute,
|
|
} from "./route-resolution.js";
|
|
import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js";
|
|
import { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.js";
|
|
|
|
export type {
|
|
DiscordMessagePreflightContext,
|
|
DiscordMessagePreflightParams,
|
|
} from "./message-handler.preflight.types.js";
|
|
|
|
const DISCORD_BOUND_THREAD_SYSTEM_PREFIXES = ["⚙️", "🤖", "🧰"];
|
|
|
|
let conversationRuntimePromise:
|
|
| Promise<typeof import("openclaw/plugin-sdk/conversation-binding-runtime")>
|
|
| undefined;
|
|
let pluralkitRuntimePromise: Promise<typeof import("../pluralkit.js")> | undefined;
|
|
let discordSendRuntimePromise: Promise<typeof import("../send.js")> | undefined;
|
|
let preflightAudioRuntimePromise: Promise<typeof import("./preflight-audio.js")> | undefined;
|
|
let systemEventsRuntimePromise: Promise<typeof import("./system-events.js")> | undefined;
|
|
let discordThreadingRuntimePromise: Promise<typeof import("./threading.js")> | undefined;
|
|
|
|
async function loadConversationRuntime() {
|
|
conversationRuntimePromise ??= import("openclaw/plugin-sdk/conversation-binding-runtime");
|
|
return await conversationRuntimePromise;
|
|
}
|
|
|
|
async function loadPluralKitRuntime() {
|
|
pluralkitRuntimePromise ??= import("../pluralkit.js");
|
|
return await pluralkitRuntimePromise;
|
|
}
|
|
|
|
async function loadDiscordSendRuntime() {
|
|
discordSendRuntimePromise ??= import("../send.js");
|
|
return await discordSendRuntimePromise;
|
|
}
|
|
|
|
async function loadPreflightAudioRuntime() {
|
|
preflightAudioRuntimePromise ??= import("./preflight-audio.js");
|
|
return await preflightAudioRuntimePromise;
|
|
}
|
|
|
|
async function loadSystemEventsRuntime() {
|
|
systemEventsRuntimePromise ??= import("./system-events.js");
|
|
return await systemEventsRuntimePromise;
|
|
}
|
|
|
|
async function loadDiscordThreadingRuntime() {
|
|
discordThreadingRuntimePromise ??= import("./threading.js");
|
|
return await discordThreadingRuntimePromise;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
type BoundThreadLookupRecordLike = {
|
|
webhookId?: string | null;
|
|
metadata?: {
|
|
webhookId?: string | null;
|
|
};
|
|
};
|
|
|
|
function isDiscordThreadChannelType(type: ChannelType | undefined): boolean {
|
|
return (
|
|
type === ChannelType.PublicThread ||
|
|
type === ChannelType.PrivateThread ||
|
|
type === ChannelType.AnnouncementThread
|
|
);
|
|
}
|
|
|
|
function isDiscordThreadChannelMessage(params: {
|
|
isGuildMessage: boolean;
|
|
message: Message;
|
|
channelInfo: import("./message-utils.js").DiscordChannelInfo | null;
|
|
}): boolean {
|
|
if (!params.isGuildMessage) {
|
|
return false;
|
|
}
|
|
const channel =
|
|
"channel" in params.message ? (params.message as { channel?: unknown }).channel : undefined;
|
|
return Boolean(
|
|
(channel &&
|
|
typeof channel === "object" &&
|
|
"isThread" in channel &&
|
|
typeof (channel as { isThread?: unknown }).isThread === "function" &&
|
|
(channel as { isThread: () => boolean }).isThread()) ||
|
|
isDiscordThreadChannelType(params.channelInfo?.type),
|
|
);
|
|
}
|
|
|
|
function resolveInjectedBoundThreadLookupRecord(params: {
|
|
threadBindings: DiscordMessagePreflightParams["threadBindings"];
|
|
threadId: string;
|
|
}): BoundThreadLookupRecordLike | undefined {
|
|
const getByThreadId = (params.threadBindings as { getByThreadId?: (threadId: string) => unknown })
|
|
.getByThreadId;
|
|
if (typeof getByThreadId !== "function") {
|
|
return undefined;
|
|
}
|
|
const binding = getByThreadId(params.threadId);
|
|
return binding && typeof binding === "object"
|
|
? (binding as BoundThreadLookupRecordLike)
|
|
: undefined;
|
|
}
|
|
|
|
function resolveDiscordMentionState(params: {
|
|
authorIsBot: boolean;
|
|
botId?: string;
|
|
hasAnyMention: boolean;
|
|
isDirectMessage: boolean;
|
|
isExplicitlyMentioned: boolean;
|
|
mentionRegexes: RegExp[];
|
|
mentionText: string;
|
|
mentionedEveryone: boolean;
|
|
referencedAuthorId?: string;
|
|
senderIsPluralKit: boolean;
|
|
transcript?: string;
|
|
}) {
|
|
if (params.isDirectMessage) {
|
|
return {
|
|
implicitMentionKinds: [],
|
|
wasMentioned: false,
|
|
};
|
|
}
|
|
|
|
const everyoneMentioned =
|
|
params.mentionedEveryone && (!params.authorIsBot || params.senderIsPluralKit);
|
|
const wasMentioned =
|
|
everyoneMentioned ||
|
|
matchesMentionWithExplicit({
|
|
text: params.mentionText,
|
|
mentionRegexes: params.mentionRegexes,
|
|
explicit: {
|
|
hasAnyMention: params.hasAnyMention,
|
|
isExplicitlyMentioned: params.isExplicitlyMentioned,
|
|
canResolveExplicit: Boolean(params.botId),
|
|
},
|
|
transcript: params.transcript,
|
|
});
|
|
const implicitMentionKinds = implicitMentionKindWhen(
|
|
"reply_to_bot",
|
|
Boolean(params.botId) &&
|
|
Boolean(params.referencedAuthorId) &&
|
|
params.referencedAuthorId === params.botId,
|
|
);
|
|
|
|
return {
|
|
implicitMentionKinds,
|
|
wasMentioned,
|
|
};
|
|
}
|
|
|
|
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?: BoundThreadLookupRecordLike;
|
|
}): boolean {
|
|
const webhookId = params.webhookId?.trim() || "";
|
|
if (!webhookId) {
|
|
return false;
|
|
}
|
|
const boundWebhookId =
|
|
typeof params.threadBinding?.webhookId === "string"
|
|
? params.threadBinding.webhookId.trim()
|
|
: 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 assignWithPrototype = <T extends object>(baseObject: T, ...sources: object[]): T =>
|
|
Object.assign(
|
|
Object.create(Object.getPrototypeOf(baseObject) ?? Object.prototype),
|
|
baseObject,
|
|
...sources,
|
|
) as T;
|
|
const referencedMessage = fetched.referenced_message
|
|
? assignWithPrototype(
|
|
((base as { referencedMessage?: Message }).referencedMessage ?? {}) as Message,
|
|
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 baseRawData = (base as { rawData?: Record<string, unknown> }).rawData;
|
|
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 ?? baseRawData?.sticker_items,
|
|
};
|
|
return assignWithPrototype(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 import("../pluralkit.js").fetchPluralKitMessageInfo>
|
|
> = null;
|
|
if (shouldCheckPluralKit) {
|
|
try {
|
|
const { fetchPluralKitMessageInfo } = await loadPluralKitRuntime();
|
|
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 messageText = resolveDiscordMessageText(message, {
|
|
includeForwarded: true,
|
|
});
|
|
const injectedBoundThreadBinding =
|
|
!isDirectMessage && !isGroupDm
|
|
? resolveInjectedBoundThreadLookupRecord({
|
|
threadBindings: params.threadBindings,
|
|
threadId: messageChannelId,
|
|
})
|
|
: undefined;
|
|
if (
|
|
shouldIgnoreBoundThreadWebhookMessage({
|
|
accountId: params.accountId,
|
|
threadId: messageChannelId,
|
|
webhookId,
|
|
threadBinding: injectedBoundThreadBinding,
|
|
})
|
|
) {
|
|
logVerbose(`discord: drop bound-thread webhook echo message ${message.id}`);
|
|
return null;
|
|
}
|
|
if (
|
|
isBoundThreadBotSystemMessage({
|
|
isBoundThreadSession:
|
|
Boolean(injectedBoundThreadBinding) &&
|
|
isDiscordThreadChannelMessage({
|
|
isGuildMessage,
|
|
message,
|
|
channelInfo,
|
|
}),
|
|
isBotAuthor: Boolean(author.bot),
|
|
text: messageText,
|
|
})
|
|
) {
|
|
logVerbose(`discord: drop bound-thread bot system message ${message.id}`);
|
|
return null;
|
|
}
|
|
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 ?? resolveDefaultDiscordAccountId(params.cfg);
|
|
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 {
|
|
const conversationRuntime = await loadConversationRuntime();
|
|
const { sendMessageDiscord } = await loadDiscordSendRuntime();
|
|
await sendMessageDiscord(
|
|
`user:${author.id}`,
|
|
conversationRuntime.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,
|
|
});
|
|
|
|
// 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 { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } =
|
|
await loadDiscordThreadingRuntime();
|
|
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;
|
|
}
|
|
|
|
// Use the active runtime snapshot for bindings lookup; routing inputs are
|
|
// still payload-derived, but this path should not reparse config from disk.
|
|
const memberRoleIds = Array.isArray(params.data.rawMember?.roles)
|
|
? params.data.rawMember.roles.map((roleId: string) => String(roleId))
|
|
: [];
|
|
const freshCfg = loadConfig();
|
|
const conversationRuntime = await loadConversationRuntime();
|
|
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 =
|
|
conversationRuntime.getSessionBindingService().resolveByConversation({
|
|
channel: "discord",
|
|
accountId: params.accountId,
|
|
conversationId: bindingConversationId,
|
|
parentConversationId: earlyThreadParentId,
|
|
}) ?? undefined;
|
|
const configuredRoute =
|
|
threadBinding == null
|
|
? conversationRuntime.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 = conversationRuntime.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;
|
|
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,
|
|
});
|
|
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
|
|
channelConfig,
|
|
guildInfo,
|
|
memberRoleIds,
|
|
sender,
|
|
allowNameMatching,
|
|
});
|
|
|
|
if (isGuildMessage && hasAccessRestrictions && !memberAllowed) {
|
|
logDebug(`[discord-preflight] drop: member not allowed`);
|
|
// Keep stable Discord user IDs out of routine deny-path logs.
|
|
logVerbose("Blocked discord guild sender (not in users/roles allowlist)");
|
|
return null;
|
|
}
|
|
|
|
// Only authorized guild senders should reach the expensive transcription path.
|
|
const { resolveDiscordPreflightAudioMentionContext } = await loadPreflightAudioRuntime();
|
|
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 { implicitMentionKinds, wasMentioned } = resolveDiscordMentionState({
|
|
authorIsBot: Boolean(author.bot),
|
|
botId,
|
|
hasAnyMention,
|
|
isDirectMessage,
|
|
isExplicitlyMentioned: explicitlyMentioned,
|
|
mentionRegexes,
|
|
mentionText,
|
|
mentionedEveryone: Boolean(message.mentionedEveryone),
|
|
referencedAuthorId: message.referencedMessage?.author?.id,
|
|
senderIsPluralKit: sender.isPluralKit,
|
|
transcript: preflightTranscript,
|
|
});
|
|
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);
|
|
|
|
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 mentionDecision = resolveInboundMentionDecision({
|
|
facts: {
|
|
canDetectMention,
|
|
wasMentioned,
|
|
hasAnyMention,
|
|
implicitMentionKinds,
|
|
},
|
|
policy: {
|
|
isGroup: isGuildMessage,
|
|
requireMention: Boolean(shouldRequireMention),
|
|
allowTextCommands,
|
|
hasControlCommand: hasControlCommandInMessage,
|
|
commandAuthorized,
|
|
},
|
|
});
|
|
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
|
|
logDebug(
|
|
`[discord-preflight] shouldRequireMention=${shouldRequireMention} baseRequireMention=${shouldRequireMentionByConfig} boundThreadSession=${isBoundThreadSession} mentionDecision.shouldSkip=${mentionDecision.shouldSkip} wasMentioned=${wasMentioned}`,
|
|
);
|
|
if (isGuildMessage && shouldRequireMention) {
|
|
if (botId && mentionDecision.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 || mentionDecision.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 &&
|
|
!mentionDecision.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;
|
|
}
|
|
|
|
const systemLocation = resolveDiscordSystemLocation({
|
|
isDirectMessage,
|
|
isGroupDm,
|
|
guild: params.data.guild ?? undefined,
|
|
channelName: channelName ?? messageChannelId,
|
|
});
|
|
const { resolveDiscordSystemEvent } = await loadSystemEventsRuntime();
|
|
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 conversationRuntime.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: mentionDecision.shouldBypassMention,
|
|
effectiveWasMentioned,
|
|
canDetectMention,
|
|
historyEntry,
|
|
threadBindings: params.threadBindings,
|
|
discordRestFetch: params.discordRestFetch,
|
|
};
|
|
}
|