Files
openclaw/src/discord/monitor/message-handler.preflight.ts
Onur 8178ea472d feat: thread-bound subagents on Discord (#21805)
* docs: thread-bound subagents plan

* docs: add exact thread-bound subagent implementation touchpoints

* Docs: prioritize auto thread-bound subagent flow

* Docs: add ACP harness thread-binding extensions

* Discord: add thread-bound session routing and auto-bind spawn flow

* Subagents: add focus commands and ACP/session binding lifecycle hooks

* Tests: cover thread bindings, focus commands, and ACP unbind hooks

* Docs: add plugin-hook appendix for thread-bound subagents

* Plugins: add subagent lifecycle hook events

* Core: emit subagent lifecycle hooks and decouple Discord bindings

* Discord: handle subagent bind lifecycle via plugin hooks

* Subagents: unify completion finalizer and split registry modules

* Add subagent lifecycle events module

* Hooks: fix subagent ended context key

* Discord: share thread bindings across ESM and Jiti

* Subagents: add persistent sessions_spawn mode for thread-bound sessions

* Subagents: clarify thread intro and persistent completion copy

* test(subagents): stabilize sessions_spawn lifecycle cleanup assertions

* Discord: add thread-bound session TTL with auto-unfocus

* Subagents: fail session spawns when thread bind fails

* Subagents: cover thread session failure cleanup paths

* Session: add thread binding TTL config and /session ttl controls

* Tests: align discord reaction expectations

* Agent: persist sessionFile for keyed subagent sessions

* Discord: normalize imports after conflict resolution

* Sessions: centralize sessionFile resolve/persist helper

* Discord: harden thread-bound subagent session routing

* Rebase: resolve upstream/main conflicts

* Subagents: move thread binding into hooks and split bindings modules

* Docs: add channel-agnostic subagent routing hook plan

* Agents: decouple subagent routing from Discord

* Discord: refactor thread-bound subagent flows

* Subagents: prevent duplicate end hooks and orphaned failed sessions

* Refactor: split subagent command and provider phases

* Subagents: honor hook delivery target overrides

* Discord: add thread binding kill switches and refresh plan doc

* Discord: fix thread bind channel resolution

* Routing: centralize account id normalization

* Discord: clean up thread bindings on startup failures

* Discord: add startup cleanup regression tests

* Docs: add long-term thread-bound subagent architecture

* Docs: split session binding plan and dedupe thread-bound doc

* Subagents: add channel-agnostic session binding routing

* Subagents: stabilize announce completion routing tests

* Subagents: cover multi-bound completion routing

* Subagents: suppress lifecycle hooks on failed thread bind

* tests: fix discord provider mock typing regressions

* docs/protocol: sync slash command aliases and delete param models

* fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc)

---------

Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00

730 lines
24 KiB
TypeScript

import { ChannelType, MessageType, type User } from "@buape/carbon";
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";
import { logVerbose, shouldLogVerbose } from "../../globals.js";
import { recordChannelActivity } from "../../infra/channel-activity.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { logDebug } from "../../logger.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 { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
import { sendMessageDiscord } from "../send.js";
import {
allowListMatches,
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordAllowListMatch,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordMemberAccessState,
resolveDiscordShouldRequireMention,
resolveGroupDmAllow,
} from "./allow-list.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 { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js";
import { resolveDiscordSystemEvent } from "./system-events.js";
import {
isRecentlyUnboundThreadWebhookMessage,
type ThreadBindingRecord,
} from "./thread-bindings.js";
import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js";
export type {
DiscordMessagePreflightContext,
DiscordMessagePreflightParams,
} from "./message-handler.preflight.types.js";
export function resolvePreflightMentionRequirement(params: {
shouldRequireMention: boolean;
isBoundThreadSession: boolean;
}): boolean {
if (!params.shouldRequireMention) {
return false;
}
return !params.isBoundThreadSession;
}
export function shouldIgnoreBoundThreadWebhookMessage(params: {
accountId?: string;
threadId?: string;
webhookId?: string | null;
threadBinding?: ThreadBindingRecord;
}): boolean {
const webhookId = params.webhookId?.trim() || "";
if (!webhookId) {
return false;
}
const boundWebhookId = params.threadBinding?.webhookId?.trim() || "";
if (!boundWebhookId) {
const threadId = params.threadId?.trim() || "";
if (!threadId) {
return false;
}
return isRecentlyUnboundThreadWebhookMessage({
accountId: params.accountId,
threadId,
webhookId,
});
}
return webhookId === boundWebhookId;
}
export async function preflightDiscordMessage(
params: DiscordMessagePreflightParams,
): Promise<DiscordMessagePreflightContext | null> {
const logger = getChildLogger({ module: "discord-auto-reply" });
const message = params.data.message;
const author = params.data.author;
if (!author) {
return null;
}
const messageChannelId = resolveDiscordMessageChannelId({
message,
eventChannelId: params.data.channel_id,
});
if (!messageChannelId) {
logVerbose(`discord: drop message ${message.id} (missing channel id)`);
return null;
}
const allowBots = params.discordConfig?.allowBots ?? false;
if (params.botUserId && author.id === params.botUserId) {
// Always ignore own messages to prevent self-reply loops
return null;
}
const pluralkitConfig = params.discordConfig?.pluralkit;
const webhookId = resolveDiscordWebhookId(message);
const shouldCheckPluralKit = Boolean(pluralkitConfig?.enabled) && !webhookId;
let pluralkitInfo: Awaited<ReturnType<typeof fetchPluralKitMessageInfo>> = null;
if (shouldCheckPluralKit) {
try {
pluralkitInfo = await fetchPluralKitMessageInfo({
messageId: message.id,
config: pluralkitConfig,
});
} catch (err) {
logVerbose(`discord: pluralkit lookup failed for ${message.id}: ${String(err)}`);
}
}
const sender = resolveDiscordSenderIdentity({
author,
member: params.data.member,
pluralkitInfo,
});
if (author.bot) {
if (!allowBots && !sender.isPluralKit) {
logVerbose("discord: drop bot message (allowBots=false)");
return null;
}
}
const isGuildMessage = Boolean(params.data.guild_id);
const channelInfo = await resolveDiscordChannelInfo(params.client, messageChannelId);
const isDirectMessage = channelInfo?.type === ChannelType.DM;
const isGroupDm = channelInfo?.type === ChannelType.GroupDM;
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";
let commandAuthorized = true;
if (isDirectMessage) {
if (dmPolicy === "disabled") {
logVerbose("discord: drop dm (dmPolicy: disabled)");
return null;
}
if (dmPolicy !== "open") {
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
const effectiveAllowFrom = [...(params.allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
const allowMatch = allowList
? resolveDiscordAllowListMatch({
allowList,
candidate: {
id: sender.id,
name: sender.name,
tag: sender.tag,
},
})
: { allowed: false };
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
const permitted = allowMatch.allowed;
if (!permitted) {
commandAuthorized = false;
if (dmPolicy === "pairing") {
const { code, created } = await upsertChannelPairingRequest({
channel: "discord",
id: author.id,
meta: {
tag: formatDiscordUserTag(author),
name: author.username ?? undefined,
},
});
if (created) {
logVerbose(
`discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)} (${allowMatchMeta})`,
);
try {
await sendMessageDiscord(
`user:${author.id}`,
buildPairingReply({
channel: "discord",
idLine: `Your Discord user id: ${author.id}`,
code,
}),
{
token: params.token,
rest: params.client.rest,
accountId: params.accountId,
},
);
} catch (err) {
logVerbose(`discord pairing reply failed for ${author.id}: ${String(err)}`);
}
}
} else {
logVerbose(
`Blocked unauthorized discord sender ${sender.id} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
}
return null;
}
commandAuthorized = true;
}
}
const botId = params.botUserId;
const baseText = resolveDiscordMessageText(message, {
includeForwarded: false,
});
const messageText = resolveDiscordMessageText(message, {
includeForwarded: true,
});
recordChannelActivity({
channel: "discord",
accountId: params.accountId,
direction: "inbound",
});
// Resolve thread parent early for binding inheritance
const channelName =
channelInfo?.name ??
((isGuildMessage || isGroupDm) && message.channel && "name" in message.channel
? message.channel.name
: undefined);
const earlyThreadChannel = resolveDiscordThreadChannel({
isGuildMessage,
message,
channelInfo,
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,
});
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 route = resolveAgentRoute({
cfg: loadConfig(),
channel: "discord",
accountId: params.accountId,
guildId: params.data.guild_id ?? undefined,
memberRoleIds,
peer: {
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
id: isDirectMessage ? author.id : messageChannelId,
},
// Pass parent peer for thread binding inheritance
parentPeer: earlyThreadParentId ? { kind: "channel", id: earlyThreadParentId } : undefined,
});
const threadBinding = earlyThreadChannel
? params.threadBindings.getByThreadId(messageChannelId)
: undefined;
if (
shouldIgnoreBoundThreadWebhookMessage({
accountId: params.accountId,
threadId: messageChannelId,
webhookId,
threadBinding,
})
) {
logVerbose(`discord: drop bound-thread webhook echo message ${message.id}`);
return null;
}
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
const effectiveRoute = boundSessionKey
? {
...route,
sessionKey: boundSessionKey,
agentId: boundAgentId ?? route.agentId,
}
: route;
const mentionRegexes = buildMentionRegexes(params.cfg, effectiveRoute.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),
);
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;
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"} 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") {
logVerbose(`discord: drop guild message (groupPolicy: disabled, ${channelMatchMeta})`);
} else if (!channelAllowlistConfigured) {
logVerbose(
`discord: drop guild message (groupPolicy: allowlist, no channel allowlist, ${channelMatchMeta})`,
);
} else {
logVerbose(
`Blocked discord channel ${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 isBoundThreadSession = Boolean(boundSessionKey && threadChannel);
const shouldRequireMention = resolvePreflightMentionRequirement({
shouldRequireMention: shouldRequireMentionByConfig,
isBoundThreadSession,
});
// 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=${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,
});
if (!isDirectMessage) {
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, [
"discord:",
"user:",
"pk:",
]);
const ownerOk = ownerAllowList
? allowListMatches(ownerAllowList, {
id: sender.id,
name: sender.name,
tag: sender.tag,
})
: false;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
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 (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;
}
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,
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,
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,
};
}