Files
openclaw/src/discord/monitor/message-handler.process.ts

706 lines
22 KiB
TypeScript
Raw Normal View History

2026-01-24 21:57:48 -06:00
import { ChannelType } from "@buape/carbon";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
2026-01-23 23:04:09 +00:00
import { resolveAckReaction, resolveHumanDelayConfig } from "../../agents/identity.js";
import { resolveChunkMode } from "../../auto-reply/chunk.js";
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
} from "../../auto-reply/reply/history.js";
2026-01-17 05:04:29 +00:00
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
2026-01-14 01:08:15 +00:00
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import { shouldAckReaction as shouldAckReactionGate } from "../../channels/ack-reactions.js";
import { logTypingFailure, logAckFailure } from "../../channels/logging.js";
feat: per-channel responsePrefix override (#9001) * feat: per-channel responsePrefix override Add responsePrefix field to all channel config types and Zod schemas, enabling per-channel and per-account outbound response prefix overrides. Resolution cascade (most specific wins): L1: channels.<ch>.accounts.<id>.responsePrefix L2: channels.<ch>.responsePrefix L3: (reserved for channels.defaults) L4: messages.responsePrefix (existing global) Semantics: - undefined -> inherit from parent level - empty string -> explicitly no prefix (stops cascade) - "auto" -> derive [identity.name] from routed agent Changes: - Core logic: resolveResponsePrefix() in identity.ts accepts optional channel/accountId and walks the cascade - resolveEffectiveMessagesConfig() passes channel context through - Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs - Zod schemas: responsePrefix added for config validation - All channel handlers wired: telegram, discord, slack, signal, imessage, line, heartbeat runner, route-reply, native commands - 23 new tests covering backward compat, channel/account levels, full cascade, auto keyword, empty string stops, unknown fallthrough Fully backward compatible - no existing config is affected. Fixes #8857 * fix: address CI lint + review feedback - Replace Record<string, any> with proper typed helpers (no-explicit-any) - Add curly braces to single-line if returns (eslint curly) - Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types - Extract getChannelConfig() helper for type-safe dynamic key access * fix: finish responsePrefix overrides (#9001) (thanks @mudrii) * fix: normalize prefix wiring and types (#9001) (thanks @mudrii) --------- Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
import { recordInboundSession } from "../../channels/session.js";
import { createTypingCallbacks } from "../../channels/typing.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
2026-01-14 01:08:15 +00:00
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../../routing/session-key.js";
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
2026-01-14 01:08:15 +00:00
import { truncateUtf16Safe } from "../../utils.js";
import { reactMessageDiscord, removeReactionDiscord } from "../send.js";
import { normalizeDiscordSlug, resolveDiscordOwnerAllowFrom } from "./allow-list.js";
import { resolveTimestampMs } from "./format.js";
2026-01-14 01:08:15 +00:00
import {
buildDiscordMediaPayload,
resolveDiscordMessageText,
resolveForwardedMediaList,
2026-01-14 01:08:15 +00:00
resolveMediaList,
} from "./message-utils.js";
import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply-context.js";
2026-01-14 01:08:15 +00:00
import { deliverDiscordReply } from "./reply-delivery.js";
2026-01-14 17:10:16 -08:00
import { resolveDiscordAutoThreadReplyPlan, resolveDiscordThreadStarter } from "./threading.js";
2026-01-14 01:08:15 +00:00
import { sendTyping } from "./typing.js";
const DISCORD_STATUS_THINKING_EMOJI = "🧠";
const DISCORD_STATUS_TOOL_EMOJI = "🛠️";
const DISCORD_STATUS_CODING_EMOJI = "💻";
const DISCORD_STATUS_WEB_EMOJI = "🌐";
const DISCORD_STATUS_DONE_EMOJI = "✅";
const DISCORD_STATUS_ERROR_EMOJI = "❌";
const DISCORD_STATUS_STALL_SOFT_EMOJI = "⏳";
const DISCORD_STATUS_STALL_HARD_EMOJI = "⚠️";
const DISCORD_STATUS_DONE_HOLD_MS = 1500;
const DISCORD_STATUS_ERROR_HOLD_MS = 2500;
const DISCORD_STATUS_DEBOUNCE_MS = 700;
const DISCORD_STATUS_STALL_SOFT_MS = 10_000;
const DISCORD_STATUS_STALL_HARD_MS = 30_000;
const CODING_STATUS_TOOL_TOKENS = [
"exec",
"process",
"read",
"write",
"edit",
"session_status",
"bash",
];
const WEB_STATUS_TOOL_TOKENS = ["web_search", "web-search", "web_fetch", "web-fetch", "browser"];
function resolveToolStatusEmoji(toolName?: string): string {
const normalized = toolName?.trim().toLowerCase() ?? "";
if (!normalized) {
return DISCORD_STATUS_TOOL_EMOJI;
}
if (WEB_STATUS_TOOL_TOKENS.some((token) => normalized.includes(token))) {
return DISCORD_STATUS_WEB_EMOJI;
}
if (CODING_STATUS_TOOL_TOKENS.some((token) => normalized.includes(token))) {
return DISCORD_STATUS_CODING_EMOJI;
}
return DISCORD_STATUS_TOOL_EMOJI;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function createDiscordStatusReactionController(params: {
enabled: boolean;
channelId: string;
messageId: string;
initialEmoji: string;
rest: unknown;
}) {
let activeEmoji: string | null = null;
let chain: Promise<void> = Promise.resolve();
let pendingEmoji: string | null = null;
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
let finished = false;
let softStallTimer: ReturnType<typeof setTimeout> | null = null;
let hardStallTimer: ReturnType<typeof setTimeout> | null = null;
const enqueue = (work: () => Promise<void>) => {
chain = chain.then(work).catch((err) => {
logAckFailure({
log: logVerbose,
channel: "discord",
target: `${params.channelId}/${params.messageId}`,
error: err,
});
});
return chain;
};
const clearStallTimers = () => {
if (softStallTimer) {
clearTimeout(softStallTimer);
softStallTimer = null;
}
if (hardStallTimer) {
clearTimeout(hardStallTimer);
hardStallTimer = null;
}
};
const clearPendingDebounce = () => {
if (pendingTimer) {
clearTimeout(pendingTimer);
pendingTimer = null;
}
pendingEmoji = null;
};
const applyEmoji = (emoji: string) =>
enqueue(async () => {
if (!params.enabled || !emoji || activeEmoji === emoji) {
return;
}
const previousEmoji = activeEmoji;
await reactMessageDiscord(params.channelId, params.messageId, emoji, {
rest: params.rest as never,
});
activeEmoji = emoji;
if (previousEmoji && previousEmoji !== emoji) {
await removeReactionDiscord(params.channelId, params.messageId, previousEmoji, {
rest: params.rest as never,
});
}
});
const requestEmoji = (emoji: string, options?: { immediate?: boolean }) => {
if (!params.enabled || !emoji) {
return Promise.resolve();
}
if (options?.immediate) {
clearPendingDebounce();
return applyEmoji(emoji);
}
pendingEmoji = emoji;
if (pendingTimer) {
clearTimeout(pendingTimer);
}
pendingTimer = setTimeout(() => {
pendingTimer = null;
const emojiToApply = pendingEmoji;
pendingEmoji = null;
if (!emojiToApply || emojiToApply === activeEmoji) {
return;
}
void applyEmoji(emojiToApply);
}, DISCORD_STATUS_DEBOUNCE_MS);
return Promise.resolve();
};
const scheduleStallTimers = () => {
if (!params.enabled || finished) {
return;
}
clearStallTimers();
softStallTimer = setTimeout(() => {
if (finished) {
return;
}
void requestEmoji(DISCORD_STATUS_STALL_SOFT_EMOJI, { immediate: true });
}, DISCORD_STATUS_STALL_SOFT_MS);
hardStallTimer = setTimeout(() => {
if (finished) {
return;
}
void requestEmoji(DISCORD_STATUS_STALL_HARD_EMOJI, { immediate: true });
}, DISCORD_STATUS_STALL_HARD_MS);
};
const setPhase = (emoji: string) => {
if (!params.enabled || finished) {
return Promise.resolve();
}
scheduleStallTimers();
return requestEmoji(emoji);
};
const setTerminal = async (emoji: string) => {
if (!params.enabled) {
return;
}
finished = true;
clearStallTimers();
await requestEmoji(emoji, { immediate: true });
};
const clear = async () => {
if (!params.enabled) {
return;
}
finished = true;
clearStallTimers();
clearPendingDebounce();
await enqueue(async () => {
const cleanupCandidates = new Set<string>([
params.initialEmoji,
activeEmoji ?? "",
DISCORD_STATUS_THINKING_EMOJI,
DISCORD_STATUS_TOOL_EMOJI,
DISCORD_STATUS_CODING_EMOJI,
DISCORD_STATUS_WEB_EMOJI,
DISCORD_STATUS_DONE_EMOJI,
DISCORD_STATUS_ERROR_EMOJI,
DISCORD_STATUS_STALL_SOFT_EMOJI,
DISCORD_STATUS_STALL_HARD_EMOJI,
]);
activeEmoji = null;
for (const emoji of cleanupCandidates) {
if (!emoji) {
continue;
}
try {
await removeReactionDiscord(params.channelId, params.messageId, emoji, {
rest: params.rest as never,
});
} catch (err) {
logAckFailure({
log: logVerbose,
channel: "discord",
target: `${params.channelId}/${params.messageId}`,
error: err,
});
}
}
});
};
const restoreInitial = async () => {
if (!params.enabled) {
return;
}
finished = true;
clearStallTimers();
clearPendingDebounce();
await requestEmoji(params.initialEmoji, { immediate: true });
};
return {
setQueued: () => {
scheduleStallTimers();
return requestEmoji(params.initialEmoji, { immediate: true });
},
setThinking: () => setPhase(DISCORD_STATUS_THINKING_EMOJI),
setTool: (toolName?: string) => setPhase(resolveToolStatusEmoji(toolName)),
setDone: () => setTerminal(DISCORD_STATUS_DONE_EMOJI),
setError: () => setTerminal(DISCORD_STATUS_ERROR_EMOJI),
clear,
restoreInitial,
};
}
export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) {
2026-01-14 01:08:15 +00:00
const {
cfg,
discordConfig,
accountId,
token,
runtime,
guildHistories,
historyLimit,
mediaMaxBytes,
textLimit,
replyToMode,
ackReactionScope,
message,
author,
sender,
2026-01-14 01:08:15 +00:00
data,
client,
channelInfo,
channelName,
messageChannelId,
2026-01-14 01:08:15 +00:00
isGuildMessage,
isDirectMessage,
isGroupDm,
baseText,
messageText,
shouldRequireMention,
canDetectMention,
effectiveWasMentioned,
shouldBypassMention,
2026-01-14 01:08:15 +00:00
threadChannel,
threadParentId,
threadParentName,
threadParentType,
threadName,
displayChannelSlug,
guildInfo,
guildSlug,
channelConfig,
baseSessionKey,
route,
commandAuthorized,
} = ctx;
const mediaList = await resolveMediaList(message, mediaMaxBytes);
const forwardedMediaList = await resolveForwardedMediaList(message, mediaMaxBytes);
mediaList.push(...forwardedMediaList);
2026-01-14 01:08:15 +00:00
const text = messageText;
if (!text) {
logVerbose(`discord: drop message ${message.id} (empty content)`);
return;
}
const ackReaction = resolveAckReaction(cfg, route.agentId, {
channel: "discord",
accountId,
});
2026-01-14 01:08:15 +00:00
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const shouldAckReaction = () =>
Boolean(
ackReaction &&
shouldAckReactionGate({
scope: ackReactionScope,
isDirect: isDirectMessage,
isGroup: isGuildMessage || isGroupDm,
isMentionableGroup: isGuildMessage,
requireMention: Boolean(shouldRequireMention),
canDetectMention,
effectiveWasMentioned,
shouldBypassMention,
}),
);
const statusReactionsEnabled = shouldAckReaction();
const statusReactions = createDiscordStatusReactionController({
enabled: statusReactionsEnabled,
channelId: messageChannelId,
messageId: message.id,
initialEmoji: ackReaction,
rest: client.rest,
});
if (statusReactionsEnabled) {
void statusReactions.setQueued();
}
2026-01-14 01:08:15 +00:00
const fromLabel = isDirectMessage
? buildDirectLabel(author)
: buildGuildLabel({
guild: data.guild ?? undefined,
channelName: channelName ?? messageChannelId,
channelId: messageChannelId,
2026-01-14 01:08:15 +00:00
});
const senderLabel = sender.label;
2026-01-24 21:57:48 -06:00
const isForumParent =
threadParentType === ChannelType.GuildForum || threadParentType === ChannelType.GuildMedia;
const forumParentSlug =
isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : "";
2026-01-25 04:11:16 +00:00
const threadChannelId = threadChannel?.id;
const isForumStarter =
Boolean(threadChannelId && isForumParent && forumParentSlug) && message.id === threadChannelId;
2026-01-24 21:57:48 -06:00
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
2026-01-17 07:41:01 +00:00
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
const groupSubject = isDirectMessage ? undefined : groupChannel;
const untrustedChannelMetadata = isGuildMessage
? buildUntrustedChannelMetadata({
source: "discord",
label: "Discord channel topic",
entries: [channelInfo?.topic],
})
: undefined;
const senderName = sender.isPluralKit
? (sender.name ?? author.username)
: (data.member?.nickname ?? author.globalName ?? author.username);
const senderUsername = sender.isPluralKit
? (sender.tag ?? sender.name ?? author.username)
: author.username;
const senderTag = sender.tag;
const systemPromptParts = [channelConfig?.systemPrompt?.trim() || null].filter(
(entry): entry is string => Boolean(entry),
);
2026-01-14 01:08:15 +00:00
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
const ownerAllowFrom = resolveDiscordOwnerAllowFrom({
channelConfig,
guildInfo,
sender: { id: sender.id, name: sender.name, tag: sender.tag },
});
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
2026-01-17 05:21:02 +00:00
let combinedBody = formatInboundEnvelope({
2026-01-14 01:08:15 +00:00
channel: "Discord",
from: fromLabel,
timestamp: resolveTimestampMs(message.timestamp),
body: text,
2026-01-17 05:21:02 +00:00
chatType: isDirectMessage ? "direct" : "channel",
senderLabel,
previousTimestamp,
envelope: envelopeOptions,
2026-01-14 01:08:15 +00:00
});
const shouldIncludeChannelHistory =
!isDirectMessage && !(isGuildMessage && channelConfig?.autoThread && !threadChannel);
if (shouldIncludeChannelHistory) {
combinedBody = buildPendingHistoryContextFromMap({
2026-01-14 01:08:15 +00:00
historyMap: guildHistories,
historyKey: messageChannelId,
2026-01-14 01:08:15 +00:00
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
2026-01-17 05:21:02 +00:00
formatInboundEnvelope({
2026-01-14 01:08:15 +00:00
channel: "Discord",
from: fromLabel,
timestamp: entry.timestamp,
body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${messageChannelId}]`,
2026-01-17 05:21:02 +00:00
chatType: "channel",
senderLabel: entry.sender,
envelope: envelopeOptions,
2026-01-14 01:08:15 +00:00
}),
});
}
const replyContext = resolveReplyContext(message, resolveDiscordMessageText);
2026-01-24 21:57:48 -06:00
if (forumContextLine) {
combinedBody = `${combinedBody}\n${forumContextLine}`;
}
2026-01-14 01:08:15 +00:00
let threadStarterBody: string | undefined;
let threadLabel: string | undefined;
let parentSessionKey: string | undefined;
if (threadChannel) {
const includeThreadStarter = channelConfig?.includeThreadStarter !== false;
if (includeThreadStarter) {
const starter = await resolveDiscordThreadStarter({
channel: threadChannel,
client,
parentId: threadParentId,
parentType: threadParentType,
resolveTimestampMs,
2026-01-14 01:08:15 +00:00
});
if (starter?.text) {
// Keep thread starter as raw text; metadata is provided out-of-band in the system prompt.
threadStarterBody = starter.text;
}
2026-01-14 01:08:15 +00:00
}
const parentName = threadParentName ?? "parent";
threadLabel = threadName
? `Discord thread #${normalizeDiscordSlug(parentName)} ${threadName}`
: `Discord thread #${normalizeDiscordSlug(parentName)}`;
if (threadParentId) {
parentSessionKey = buildAgentSessionKey({
agentId: route.agentId,
channel: route.channel,
peer: { kind: "channel", id: threadParentId },
});
}
2026-01-14 17:10:16 -08:00
}
const mediaPayload = buildDiscordMediaPayload(mediaList);
const threadKeys = resolveThreadSessionKeys({
baseSessionKey,
threadId: threadChannel ? messageChannelId : undefined,
2026-01-14 17:10:16 -08:00
parentSessionKey,
useSuffix: false,
});
const replyPlan = await resolveDiscordAutoThreadReplyPlan({
client,
message,
messageChannelId,
2026-01-14 17:10:16 -08:00
isGuildMessage,
channelConfig,
threadChannel,
baseText: baseText ?? "",
combinedBody,
replyToMode,
agentId: route.agentId,
channel: route.channel,
});
const deliverTarget = replyPlan.deliverTarget;
const replyTarget = replyPlan.replyTarget;
const replyReference = replyPlan.replyReference;
const autoThreadContext = replyPlan.autoThreadContext;
2026-01-14 17:10:16 -08:00
const effectiveFrom = isDirectMessage
? `discord:${author.id}`
: (autoThreadContext?.From ?? `discord:channel:${messageChannelId}`);
2026-01-14 17:10:16 -08:00
const effectiveTo = autoThreadContext?.To ?? replyTarget;
if (!effectiveTo) {
runtime.error?.(danger("discord: missing reply target"));
return;
}
const inboundHistory =
shouldIncludeChannelHistory && historyLimit > 0
? (guildHistories.get(messageChannelId) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
2026-01-17 05:04:29 +00:00
const ctxPayload = finalizeInboundContext({
2026-01-14 17:10:16 -08:00
Body: combinedBody,
BodyForAgent: baseText ?? text,
InboundHistory: inboundHistory,
2026-01-14 17:10:16 -08:00
RawBody: baseText,
CommandBody: baseText,
From: effectiveFrom,
To: effectiveTo,
SessionKey: autoThreadContext?.SessionKey ?? threadKeys.sessionKey,
AccountId: route.accountId,
2026-01-17 04:04:05 +00:00
ChatType: isDirectMessage ? "direct" : "channel",
ConversationLabel: fromLabel,
SenderName: senderName,
SenderId: sender.id,
SenderUsername: senderUsername,
SenderTag: senderTag,
2026-01-14 17:10:16 -08:00
GroupSubject: groupSubject,
2026-01-17 07:41:01 +00:00
GroupChannel: groupChannel,
UntrustedContext: untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined,
2026-01-14 17:10:16 -08:00
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,
OwnerAllowFrom: ownerAllowFrom,
2026-01-14 17:10:16 -08:00
Provider: "discord" as const,
Surface: "discord" as const,
WasMentioned: effectiveWasMentioned,
MessageSid: message.id,
ReplyToId: replyContext?.id,
ReplyToBody: replyContext?.body,
ReplyToSender: replyContext?.sender,
2026-01-14 17:10:16 -08:00
ParentSessionKey: autoThreadContext?.ParentSessionKey ?? threadKeys.parentSessionKey,
ThreadStarterBody: threadStarterBody,
ThreadLabel: threadLabel,
Timestamp: resolveTimestampMs(message.timestamp),
...mediaPayload,
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
// Originating channel for reply routing.
OriginatingChannel: "discord" as const,
OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget,
2026-01-17 05:04:29 +00:00
});
await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
updateLastRoute: isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "discord",
to: `user:${author.id}`,
accountId: route.accountId,
}
: undefined,
onRecordError: (err) => {
logVerbose(`discord: failed updating session meta: ${String(err)}`);
},
});
2026-01-14 17:10:16 -08:00
if (shouldLogVerbose()) {
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
logVerbose(
`discord inbound: channel=${messageChannelId} deliver=${deliverTarget} from=${ctxPayload.From} preview="${preview}"`,
2026-01-14 17:10:16 -08:00
);
}
2026-01-14 01:08:15 +00:00
2026-01-14 17:10:16 -08:00
const typingChannelId = deliverTarget.startsWith("channel:")
? deliverTarget.slice("channel:".length)
: messageChannelId;
feat: per-channel responsePrefix override (#9001) * feat: per-channel responsePrefix override Add responsePrefix field to all channel config types and Zod schemas, enabling per-channel and per-account outbound response prefix overrides. Resolution cascade (most specific wins): L1: channels.<ch>.accounts.<id>.responsePrefix L2: channels.<ch>.responsePrefix L3: (reserved for channels.defaults) L4: messages.responsePrefix (existing global) Semantics: - undefined -> inherit from parent level - empty string -> explicitly no prefix (stops cascade) - "auto" -> derive [identity.name] from routed agent Changes: - Core logic: resolveResponsePrefix() in identity.ts accepts optional channel/accountId and walks the cascade - resolveEffectiveMessagesConfig() passes channel context through - Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs - Zod schemas: responsePrefix added for config validation - All channel handlers wired: telegram, discord, slack, signal, imessage, line, heartbeat runner, route-reply, native commands - 23 new tests covering backward compat, channel/account levels, full cascade, auto keyword, empty string stops, unknown fallthrough Fully backward compatible - no existing config is affected. Fixes #8857 * fix: address CI lint + review feedback - Replace Record<string, any> with proper typed helpers (no-explicit-any) - Add curly braces to single-line if returns (eslint curly) - Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types - Extract getChannelConfig() helper for type-safe dynamic key access * fix: finish responsePrefix overrides (#9001) (thanks @mudrii) * fix: normalize prefix wiring and types (#9001) (thanks @mudrii) --------- Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
agentId: route.agentId,
channel: "discord",
accountId: route.accountId,
});
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "discord",
accountId,
});
const typingCallbacks = createTypingCallbacks({
start: () => sendTyping({ client, channelId: typingChannelId }),
onStartError: (err) => {
logTypingFailure({
log: logVerbose,
channel: "discord",
target: typingChannelId,
error: err,
});
},
});
2026-01-14 17:10:16 -08:00
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
feat: per-channel responsePrefix override (#9001) * feat: per-channel responsePrefix override Add responsePrefix field to all channel config types and Zod schemas, enabling per-channel and per-account outbound response prefix overrides. Resolution cascade (most specific wins): L1: channels.<ch>.accounts.<id>.responsePrefix L2: channels.<ch>.responsePrefix L3: (reserved for channels.defaults) L4: messages.responsePrefix (existing global) Semantics: - undefined -> inherit from parent level - empty string -> explicitly no prefix (stops cascade) - "auto" -> derive [identity.name] from routed agent Changes: - Core logic: resolveResponsePrefix() in identity.ts accepts optional channel/accountId and walks the cascade - resolveEffectiveMessagesConfig() passes channel context through - Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs - Zod schemas: responsePrefix added for config validation - All channel handlers wired: telegram, discord, slack, signal, imessage, line, heartbeat runner, route-reply, native commands - 23 new tests covering backward compat, channel/account levels, full cascade, auto keyword, empty string stops, unknown fallthrough Fully backward compatible - no existing config is affected. Fixes #8857 * fix: address CI lint + review feedback - Replace Record<string, any> with proper typed helpers (no-explicit-any) - Add curly braces to single-line if returns (eslint curly) - Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types - Extract getChannelConfig() helper for type-safe dynamic key access * fix: finish responsePrefix overrides (#9001) (thanks @mudrii) * fix: normalize prefix wiring and types (#9001) (thanks @mudrii) --------- Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
...prefixOptions,
2026-01-14 17:10:16 -08:00
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
const replyToId = replyReference.use();
await deliverDiscordReply({
replies: [payload],
target: deliverTarget,
token,
accountId,
rest: client.rest,
runtime,
replyToId,
textLimit,
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
tableMode,
2026-01-25 04:05:14 +00:00
chunkMode: resolveChunkMode(cfg, "discord", accountId),
});
replyReference.markSent();
},
2026-01-14 17:10:16 -08:00
onError: (err, info) => {
runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`));
},
onReplyStart: async () => {
await typingCallbacks.onReplyStart();
await statusReactions.setThinking();
2026-01-14 01:08:15 +00:00
},
});
let dispatchResult: Awaited<ReturnType<typeof dispatchInboundMessage>> | null = null;
let dispatchError = false;
try {
dispatchResult = await dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
skillFilter: channelConfig?.skills,
disableBlockStreaming:
typeof discordConfig?.blockStreaming === "boolean"
? !discordConfig.blockStreaming
: undefined,
onModelSelected,
onReasoningStream: async () => {
await statusReactions.setThinking();
},
onToolStart: async (payload) => {
await statusReactions.setTool(payload.name);
},
},
});
} catch (err) {
dispatchError = true;
throw err;
} finally {
markDispatchIdle();
if (statusReactionsEnabled) {
if (dispatchError) {
await statusReactions.setError();
} else {
await statusReactions.setDone();
}
if (removeAckAfterReply) {
void (async () => {
await sleep(dispatchError ? DISCORD_STATUS_ERROR_HOLD_MS : DISCORD_STATUS_DONE_HOLD_MS);
await statusReactions.clear();
})();
} else {
void statusReactions.restoreInitial();
}
}
}
if (!dispatchResult?.queuedFinal) {
if (isGuildMessage) {
clearHistoryEntriesIfEnabled({
2026-01-14 01:08:15 +00:00
historyMap: guildHistories,
historyKey: messageChannelId,
limit: historyLimit,
2026-01-14 01:08:15 +00:00
});
}
return;
}
if (shouldLogVerbose()) {
const finalCount = dispatchResult.counts.final;
2026-01-14 01:08:15 +00:00
logVerbose(
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
);
}
if (isGuildMessage) {
clearHistoryEntriesIfEnabled({
2026-01-14 01:08:15 +00:00
historyMap: guildHistories,
historyKey: messageChannelId,
limit: historyLimit,
2026-01-14 01:08:15 +00:00
});
}
}