2026-01-24 21:57:48 -06:00
|
|
|
|
import { ChannelType } from "@buape/carbon";
|
2026-01-23 23:04:09 +00:00
|
|
|
|
import { resolveAckReaction, resolveHumanDelayConfig } from "../../agents/identity.js";
|
2026-02-20 12:37:15 -06:00
|
|
|
|
import { EmbeddedBlockChunker } from "../../agents/pi-embedded-block-chunker.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
|
import { resolveChunkMode } from "../../auto-reply/chunk.js";
|
|
|
|
|
|
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
2026-02-10 00:35:56 -06:00
|
|
|
|
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js";
|
2026-01-16 23:52:14 +00:00
|
|
|
|
import {
|
|
|
|
|
|
buildPendingHistoryContextFromMap,
|
2026-01-23 22:36:43 +00:00
|
|
|
|
clearHistoryEntriesIfEnabled,
|
2026-01-16 23:52:14 +00:00
|
|
|
|
} 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";
|
2026-02-18 01:34:35 +00:00
|
|
|
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
2026-02-17 04:38:39 +09:00
|
|
|
|
import { shouldAckReaction as shouldAckReactionGate } from "../../channels/ack-reactions.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
|
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";
|
2026-01-23 22:48:03 +00:00
|
|
|
|
import { recordInboundSession } from "../../channels/session.js";
|
2026-02-20 15:27:42 -06:00
|
|
|
|
import {
|
|
|
|
|
|
createStatusReactionController,
|
|
|
|
|
|
DEFAULT_TIMING,
|
|
|
|
|
|
type StatusReactionAdapter,
|
|
|
|
|
|
} from "../../channels/status-reactions.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
|
import { createTypingCallbacks } from "../../channels/typing.js";
|
2026-02-24 01:32:23 +00:00
|
|
|
|
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
|
2026-02-21 19:53:23 +01:00
|
|
|
|
import { resolveDiscordPreviewStreamMode } from "../../config/discord-preview-streaming.js";
|
2026-01-23 17:56:50 +00:00
|
|
|
|
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
|
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
|
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
|
2026-02-20 12:37:15 -06:00
|
|
|
|
import { convertMarkdownTables } from "../../markdown/tables.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
|
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
|
|
|
|
|
|
import { resolveThreadSessionKeys } from "../../routing/session-key.js";
|
2026-02-03 23:02:28 -08:00
|
|
|
|
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
|
2026-02-23 17:30:59 +00:00
|
|
|
|
import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
|
import { truncateUtf16Safe } from "../../utils.js";
|
2026-02-20 12:37:15 -06:00
|
|
|
|
import { chunkDiscordTextWithMode } from "../chunk.js";
|
|
|
|
|
|
import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js";
|
|
|
|
|
|
import { createDiscordDraftStream } from "../draft-stream.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
|
import { reactMessageDiscord, removeReactionDiscord } from "../send.js";
|
2026-02-20 12:37:15 -06:00
|
|
|
|
import { editMessageDiscord } from "../send.messages.js";
|
2026-02-04 23:34:08 -08:00
|
|
|
|
import { normalizeDiscordSlug, resolveDiscordOwnerAllowFrom } from "./allow-list.js";
|
2026-01-31 20:20:17 -06:00
|
|
|
|
import { resolveTimestampMs } from "./format.js";
|
2026-02-18 01:34:35 +00:00
|
|
|
|
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
2026-01-14 01:08:15 +00:00
|
|
|
|
import {
|
|
|
|
|
|
buildDiscordMediaPayload,
|
|
|
|
|
|
resolveDiscordMessageText,
|
2026-02-16 22:23:40 +01:00
|
|
|
|
resolveForwardedMediaList,
|
2026-01-14 01:08:15 +00:00
|
|
|
|
resolveMediaList,
|
|
|
|
|
|
} from "./message-utils.js";
|
2026-01-14 14:31:43 +00:00
|
|
|
|
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";
|
|
|
|
|
|
|
2026-02-17 04:38:39 +09:00
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
setTimeout(resolve, ms);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 14:31:43 +00:00
|
|
|
|
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,
|
2026-01-31 19:50:06 -06:00
|
|
|
|
sender,
|
2026-01-14 01:08:15 +00:00
|
|
|
|
data,
|
|
|
|
|
|
client,
|
|
|
|
|
|
channelInfo,
|
|
|
|
|
|
channelName,
|
2026-02-16 02:30:17 +00:00
|
|
|
|
messageChannelId,
|
2026-01-14 01:08:15 +00:00
|
|
|
|
isGuildMessage,
|
|
|
|
|
|
isDirectMessage,
|
|
|
|
|
|
isGroupDm,
|
|
|
|
|
|
baseText,
|
|
|
|
|
|
messageText,
|
|
|
|
|
|
shouldRequireMention,
|
|
|
|
|
|
canDetectMention,
|
|
|
|
|
|
effectiveWasMentioned,
|
2026-01-23 22:17:14 +00:00
|
|
|
|
shouldBypassMention,
|
2026-01-14 01:08:15 +00:00
|
|
|
|
threadChannel,
|
|
|
|
|
|
threadParentId,
|
|
|
|
|
|
threadParentName,
|
|
|
|
|
|
threadParentType,
|
|
|
|
|
|
threadName,
|
|
|
|
|
|
displayChannelSlug,
|
|
|
|
|
|
guildInfo,
|
|
|
|
|
|
guildSlug,
|
|
|
|
|
|
channelConfig,
|
|
|
|
|
|
baseSessionKey,
|
2026-02-21 16:14:55 +01:00
|
|
|
|
boundSessionKey,
|
|
|
|
|
|
threadBindings,
|
2026-01-14 01:08:15 +00:00
|
|
|
|
route,
|
|
|
|
|
|
commandAuthorized,
|
|
|
|
|
|
} = ctx;
|
|
|
|
|
|
|
|
|
|
|
|
const mediaList = await resolveMediaList(message, mediaMaxBytes);
|
2026-02-16 22:23:40 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-15 11:29:51 -06:00
|
|
|
|
const ackReaction = resolveAckReaction(cfg, route.agentId, {
|
|
|
|
|
|
channel: "discord",
|
|
|
|
|
|
accountId,
|
|
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
|
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
2026-01-23 22:17:14 +00:00
|
|
|
|
const shouldAckReaction = () =>
|
|
|
|
|
|
Boolean(
|
|
|
|
|
|
ackReaction &&
|
|
|
|
|
|
shouldAckReactionGate({
|
|
|
|
|
|
scope: ackReactionScope,
|
|
|
|
|
|
isDirect: isDirectMessage,
|
|
|
|
|
|
isGroup: isGuildMessage || isGroupDm,
|
|
|
|
|
|
isMentionableGroup: isGuildMessage,
|
|
|
|
|
|
requireMention: Boolean(shouldRequireMention),
|
|
|
|
|
|
canDetectMention,
|
|
|
|
|
|
effectiveWasMentioned,
|
|
|
|
|
|
shouldBypassMention,
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
2026-02-17 04:38:39 +09:00
|
|
|
|
const statusReactionsEnabled = shouldAckReaction();
|
2026-02-20 15:27:42 -06:00
|
|
|
|
const discordAdapter: StatusReactionAdapter = {
|
|
|
|
|
|
setReaction: async (emoji) => {
|
|
|
|
|
|
await reactMessageDiscord(messageChannelId, message.id, emoji, {
|
|
|
|
|
|
rest: client.rest as never,
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
removeReaction: async (emoji) => {
|
|
|
|
|
|
await removeReactionDiscord(messageChannelId, message.id, emoji, {
|
|
|
|
|
|
rest: client.rest as never,
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
const statusReactions = createStatusReactionController({
|
2026-02-17 04:38:39 +09:00
|
|
|
|
enabled: statusReactionsEnabled,
|
2026-02-20 15:27:42 -06:00
|
|
|
|
adapter: discordAdapter,
|
2026-02-17 04:38:39 +09:00
|
|
|
|
initialEmoji: ackReaction,
|
2026-02-20 15:27:42 -06:00
|
|
|
|
onError: (err) => {
|
|
|
|
|
|
logAckFailure({
|
|
|
|
|
|
log: logVerbose,
|
|
|
|
|
|
channel: "discord",
|
|
|
|
|
|
target: `${messageChannelId}/${message.id}`,
|
|
|
|
|
|
error: err,
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-02-17 04:38:39 +09:00
|
|
|
|
});
|
|
|
|
|
|
if (statusReactionsEnabled) {
|
|
|
|
|
|
void statusReactions.setQueued();
|
|
|
|
|
|
}
|
2026-01-14 01:08:15 +00:00
|
|
|
|
|
|
|
|
|
|
const fromLabel = isDirectMessage
|
|
|
|
|
|
? buildDirectLabel(author)
|
|
|
|
|
|
: buildGuildLabel({
|
|
|
|
|
|
guild: data.guild ?? undefined,
|
2026-02-16 02:30:17 +00:00
|
|
|
|
channelName: channelName ?? messageChannelId,
|
|
|
|
|
|
channelId: messageChannelId,
|
2026-01-14 01:08:15 +00:00
|
|
|
|
});
|
2026-01-31 19:50:06 -06: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;
|
2026-01-24 23:35:20 -05:00
|
|
|
|
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;
|
2026-02-03 23:02:28 -08:00
|
|
|
|
const untrustedChannelMetadata = isGuildMessage
|
|
|
|
|
|
? buildUntrustedChannelMetadata({
|
|
|
|
|
|
source: "discord",
|
|
|
|
|
|
label: "Discord channel topic",
|
|
|
|
|
|
entries: [channelInfo?.topic],
|
|
|
|
|
|
})
|
|
|
|
|
|
: undefined;
|
2026-01-31 20:20:17 -06:00
|
|
|
|
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;
|
2026-02-03 23:02:28 -08:00
|
|
|
|
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;
|
2026-02-04 23:34:08 -08:00
|
|
|
|
const ownerAllowFrom = resolveDiscordOwnerAllowFrom({
|
|
|
|
|
|
channelConfig,
|
|
|
|
|
|
guildInfo,
|
|
|
|
|
|
sender: { id: sender.id, name: sender.name, tag: sender.tag },
|
2026-02-24 01:32:23 +00:00
|
|
|
|
allowNameMatching: isDangerousNameMatchingEnabled(discordConfig),
|
2026-02-04 23:34:08 -08:00
|
|
|
|
});
|
2026-01-18 18:42:34 +00:00
|
|
|
|
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,
|
2026-01-18 18:42:34 +00:00
|
|
|
|
previousTimestamp,
|
|
|
|
|
|
envelope: envelopeOptions,
|
2026-01-14 01:08:15 +00:00
|
|
|
|
});
|
2026-01-14 12:25:20 -06:00
|
|
|
|
const shouldIncludeChannelHistory =
|
2026-01-14 19:57:42 +00:00
|
|
|
|
!isDirectMessage && !(isGuildMessage && channelConfig?.autoThread && !threadChannel);
|
2026-01-14 12:25:20 -06:00
|
|
|
|
if (shouldIncludeChannelHistory) {
|
2026-01-16 23:52:14 +00:00
|
|
|
|
combinedBody = buildPendingHistoryContextFromMap({
|
2026-01-14 01:08:15 +00:00
|
|
|
|
historyMap: guildHistories,
|
2026-02-16 02:30:17 +00:00
|
|
|
|
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,
|
2026-02-16 02:30:17 +00:00
|
|
|
|
body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${messageChannelId}]`,
|
2026-01-17 05:21:02 +00:00
|
|
|
|
chatType: "channel",
|
|
|
|
|
|
senderLabel: entry.sender,
|
2026-01-18 18:42:34 +00:00
|
|
|
|
envelope: envelopeOptions,
|
2026-01-14 01:08:15 +00:00
|
|
|
|
}),
|
|
|
|
|
|
});
|
2026-01-14 12:25:20 -06:00
|
|
|
|
}
|
2026-02-10 00:35:56 -06: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) {
|
2026-02-04 13:25:28 -08:00
|
|
|
|
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
|
|
|
|
});
|
2026-02-04 13:25:28 -08:00
|
|
|
|
if (starter?.text) {
|
2026-02-10 00:35:56 -06:00
|
|
|
|
// Keep thread starter as raw text; metadata is provided out-of-band in the system prompt.
|
|
|
|
|
|
threadStarterBody = starter.text;
|
2026-02-04 13:25:28 -08:00
|
|
|
|
}
|
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,
|
2026-02-16 02:30:17 +00:00
|
|
|
|
threadId: threadChannel ? messageChannelId : undefined,
|
2026-01-14 17:10:16 -08:00
|
|
|
|
parentSessionKey,
|
|
|
|
|
|
useSuffix: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
const replyPlan = await resolveDiscordAutoThreadReplyPlan({
|
|
|
|
|
|
client,
|
|
|
|
|
|
message,
|
2026-02-16 02:30:17 +00:00
|
|
|
|
messageChannelId,
|
2026-01-14 17:10:16 -08:00
|
|
|
|
isGuildMessage,
|
|
|
|
|
|
channelConfig,
|
|
|
|
|
|
threadChannel,
|
2026-02-16 21:00:30 +08:00
|
|
|
|
channelType: channelInfo?.type,
|
2026-01-14 17:10:16 -08:00
|
|
|
|
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 12:25:20 -06:00
|
|
|
|
|
2026-01-14 17:10:16 -08:00
|
|
|
|
const effectiveFrom = isDirectMessage
|
|
|
|
|
|
? `discord:${author.id}`
|
2026-02-16 02:30:17 +00:00
|
|
|
|
: (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;
|
|
|
|
|
|
}
|
2026-02-17 13:47:16 +00:00
|
|
|
|
// Keep DM routes user-addressed so follow-up sends resolve direct session keys.
|
|
|
|
|
|
const lastRouteTo = isDirectMessage ? `user:${author.id}` : effectiveTo;
|
2026-01-14 20:04:07 +00:00
|
|
|
|
|
2026-02-10 00:35:56 -06:00
|
|
|
|
const inboundHistory =
|
|
|
|
|
|
shouldIncludeChannelHistory && historyLimit > 0
|
2026-02-16 02:30:17 +00:00
|
|
|
|
? (guildHistories.get(messageChannelId) ?? []).map((entry) => ({
|
2026-02-10 00:35:56 -06:00
|
|
|
|
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,
|
2026-02-10 00:35:56 -06:00
|
|
|
|
BodyForAgent: baseText ?? text,
|
|
|
|
|
|
InboundHistory: inboundHistory,
|
2026-01-14 17:10:16 -08:00
|
|
|
|
RawBody: baseText,
|
|
|
|
|
|
CommandBody: baseText,
|
|
|
|
|
|
From: effectiveFrom,
|
|
|
|
|
|
To: effectiveTo,
|
2026-02-21 16:14:55 +01:00
|
|
|
|
SessionKey: boundSessionKey ?? autoThreadContext?.SessionKey ?? threadKeys.sessionKey,
|
2026-01-14 17:10:16 -08:00
|
|
|
|
AccountId: route.accountId,
|
2026-01-17 04:04:05 +00:00
|
|
|
|
ChatType: isDirectMessage ? "direct" : "channel",
|
|
|
|
|
|
ConversationLabel: fromLabel,
|
2026-01-31 20:20:17 -06:00
|
|
|
|
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,
|
2026-02-03 23:02:28 -08:00
|
|
|
|
UntrustedContext: untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined,
|
2026-01-14 17:10:16 -08:00
|
|
|
|
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
|
|
|
|
|
|
GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,
|
2026-02-04 23:34:08 -08:00
|
|
|
|
OwnerAllowFrom: ownerAllowFrom,
|
2026-01-14 17:10:16 -08:00
|
|
|
|
Provider: "discord" as const,
|
|
|
|
|
|
Surface: "discord" as const,
|
|
|
|
|
|
WasMentioned: effectiveWasMentioned,
|
|
|
|
|
|
MessageSid: message.id,
|
2026-02-10 00:35:56 -06:00
|
|
|
|
ReplyToId: replyContext?.id,
|
|
|
|
|
|
ReplyToBody: replyContext?.body,
|
|
|
|
|
|
ReplyToSender: replyContext?.sender,
|
2026-01-14 17:10:16 -08:00
|
|
|
|
ParentSessionKey: autoThreadContext?.ParentSessionKey ?? threadKeys.parentSessionKey,
|
2026-02-21 16:14:55 +01:00
|
|
|
|
MessageThreadId: threadChannel?.id ?? autoThreadContext?.createdThreadId ?? undefined,
|
2026-01-14 17:10:16 -08:00
|
|
|
|
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
|
|
|
|
});
|
2026-02-17 13:47:16 +00:00
|
|
|
|
const persistedSessionKey = ctxPayload.SessionKey ?? route.sessionKey;
|
2026-01-14 12:25:20 -06:00
|
|
|
|
|
2026-01-23 22:48:03 +00:00
|
|
|
|
await recordInboundSession({
|
2026-01-18 02:41:06 +00:00
|
|
|
|
storePath,
|
2026-02-17 13:47:16 +00:00
|
|
|
|
sessionKey: persistedSessionKey,
|
2026-01-18 02:41:06 +00:00
|
|
|
|
ctx: ctxPayload,
|
2026-02-16 20:58:30 +08:00
|
|
|
|
updateLastRoute: {
|
2026-02-17 13:47:16 +00:00
|
|
|
|
sessionKey: persistedSessionKey,
|
2026-02-16 20:58:30 +08:00
|
|
|
|
channel: "discord",
|
2026-02-17 13:47:16 +00:00
|
|
|
|
to: lastRouteTo,
|
2026-02-16 20:58:30 +08:00
|
|
|
|
accountId: route.accountId,
|
|
|
|
|
|
},
|
2026-01-23 22:48:03 +00:00
|
|
|
|
onRecordError: (err) => {
|
|
|
|
|
|
logVerbose(`discord: failed updating session meta: ${String(err)}`);
|
|
|
|
|
|
},
|
2026-01-18 02:41:06 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-14 17:10:16 -08:00
|
|
|
|
if (shouldLogVerbose()) {
|
|
|
|
|
|
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
|
|
|
|
|
|
logVerbose(
|
2026-02-16 02:30:17 +00:00
|
|
|
|
`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)
|
2026-02-16 02:30:17 +00:00
|
|
|
|
: messageChannelId;
|
feat: add dynamic template variables to messages.responsePrefix (#923)
Adds support for template variables in `messages.responsePrefix` that
resolve dynamically at runtime with the actual model used (including
after fallback).
Supported variables (case-insensitive):
- {model} - short model name (e.g., "claude-opus-4-5", "gpt-4o")
- {modelFull} - full model identifier (e.g., "anthropic/claude-opus-4-5")
- {provider} - provider name (e.g., "anthropic", "openai")
- {thinkingLevel} or {think} - thinking level ("high", "low", "off")
- {identity.name} or {identityName} - agent identity name
Example: "[{model} | think:{thinkingLevel}]" → "[claude-opus-4-5 | think:high]"
Variables show the actual model used after fallback, not the intended
model. Unresolved variables remain as literal text.
Implementation:
- New module: src/auto-reply/reply/response-prefix-template.ts
- Template interpolation in normalize-reply.ts via context provider
- onModelSelected callback in agent-runner-execution.ts
- Updated all 6 provider message handlers (web, signal, discord,
telegram, slack, imessage)
- 27 unit tests covering all variables and edge cases
- Documentation in docs/gateway/configuration.md and JSDoc
Fixes #923
2026-01-14 23:05:08 -05:00
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-01-23 17:56:50 +00:00
|
|
|
|
const tableMode = resolveMarkdownTableMode({
|
|
|
|
|
|
cfg,
|
|
|
|
|
|
channel: "discord",
|
|
|
|
|
|
accountId,
|
|
|
|
|
|
});
|
2026-02-20 12:37:15 -06:00
|
|
|
|
const chunkMode = resolveChunkMode(cfg, "discord", accountId);
|
feat: add dynamic template variables to messages.responsePrefix (#923)
Adds support for template variables in `messages.responsePrefix` that
resolve dynamically at runtime with the actual model used (including
after fallback).
Supported variables (case-insensitive):
- {model} - short model name (e.g., "claude-opus-4-5", "gpt-4o")
- {modelFull} - full model identifier (e.g., "anthropic/claude-opus-4-5")
- {provider} - provider name (e.g., "anthropic", "openai")
- {thinkingLevel} or {think} - thinking level ("high", "low", "off")
- {identity.name} or {identityName} - agent identity name
Example: "[{model} | think:{thinkingLevel}]" → "[claude-opus-4-5 | think:high]"
Variables show the actual model used after fallback, not the intended
model. Unresolved variables remain as literal text.
Implementation:
- New module: src/auto-reply/reply/response-prefix-template.ts
- Template interpolation in normalize-reply.ts via context provider
- onModelSelected callback in agent-runner-execution.ts
- Updated all 6 provider message handlers (web, signal, discord,
telegram, slack, imessage)
- 27 unit tests covering all variables and edge cases
- Documentation in docs/gateway/configuration.md and JSDoc
Fixes #923
2026-01-14 23:05:08 -05:00
|
|
|
|
|
2026-02-17 04:38:39 +09:00
|
|
|
|
const typingCallbacks = createTypingCallbacks({
|
|
|
|
|
|
start: () => sendTyping({ client, channelId: typingChannelId }),
|
|
|
|
|
|
onStartError: (err) => {
|
|
|
|
|
|
logTypingFailure({
|
|
|
|
|
|
log: logVerbose,
|
|
|
|
|
|
channel: "discord",
|
|
|
|
|
|
target: typingChannelId,
|
|
|
|
|
|
error: err,
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-20 12:37:15 -06:00
|
|
|
|
// --- Discord draft stream (edit-based preview streaming) ---
|
2026-02-21 19:53:23 +01:00
|
|
|
|
const discordStreamMode = resolveDiscordPreviewStreamMode(discordConfig);
|
2026-02-20 12:37:15 -06:00
|
|
|
|
const draftMaxChars = Math.min(textLimit, 2000);
|
|
|
|
|
|
const accountBlockStreamingEnabled =
|
|
|
|
|
|
typeof discordConfig?.blockStreaming === "boolean"
|
|
|
|
|
|
? discordConfig.blockStreaming
|
|
|
|
|
|
: cfg.agents?.defaults?.blockStreamingDefault === "on";
|
|
|
|
|
|
const canStreamDraft = discordStreamMode !== "off" && !accountBlockStreamingEnabled;
|
|
|
|
|
|
const draftReplyToMessageId = () => replyReference.use();
|
|
|
|
|
|
const deliverChannelId = deliverTarget.startsWith("channel:")
|
|
|
|
|
|
? deliverTarget.slice("channel:".length)
|
|
|
|
|
|
: messageChannelId;
|
|
|
|
|
|
const draftStream = canStreamDraft
|
|
|
|
|
|
? createDiscordDraftStream({
|
|
|
|
|
|
rest: client.rest,
|
|
|
|
|
|
channelId: deliverChannelId,
|
|
|
|
|
|
maxChars: draftMaxChars,
|
|
|
|
|
|
replyToMessageId: draftReplyToMessageId,
|
|
|
|
|
|
minInitialChars: 30,
|
|
|
|
|
|
throttleMs: 1200,
|
|
|
|
|
|
log: logVerbose,
|
|
|
|
|
|
warn: logVerbose,
|
|
|
|
|
|
})
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const draftChunking =
|
|
|
|
|
|
draftStream && discordStreamMode === "block"
|
|
|
|
|
|
? resolveDiscordDraftStreamingChunking(cfg, accountId)
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const shouldSplitPreviewMessages = discordStreamMode === "block";
|
|
|
|
|
|
const draftChunker = draftChunking ? new EmbeddedBlockChunker(draftChunking) : undefined;
|
|
|
|
|
|
let lastPartialText = "";
|
|
|
|
|
|
let draftText = "";
|
|
|
|
|
|
let hasStreamedMessage = false;
|
|
|
|
|
|
let finalizedViaPreviewMessage = false;
|
|
|
|
|
|
|
|
|
|
|
|
const resolvePreviewFinalText = (text?: string) => {
|
|
|
|
|
|
if (typeof text !== "string") {
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
const formatted = convertMarkdownTables(text, tableMode);
|
|
|
|
|
|
const chunks = chunkDiscordTextWithMode(formatted, {
|
|
|
|
|
|
maxChars: draftMaxChars,
|
|
|
|
|
|
maxLines: discordConfig?.maxLinesPerMessage,
|
|
|
|
|
|
chunkMode,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!chunks.length && formatted) {
|
|
|
|
|
|
chunks.push(formatted);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (chunks.length !== 1) {
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
const trimmed = chunks[0].trim();
|
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
const currentPreviewText = discordStreamMode === "block" ? draftText : lastPartialText;
|
|
|
|
|
|
if (
|
|
|
|
|
|
currentPreviewText &&
|
|
|
|
|
|
currentPreviewText.startsWith(trimmed) &&
|
|
|
|
|
|
trimmed.length < currentPreviewText.length
|
|
|
|
|
|
) {
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
return trimmed;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const updateDraftFromPartial = (text?: string) => {
|
|
|
|
|
|
if (!draftStream || !text) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-23 17:30:59 +00:00
|
|
|
|
// Strip reasoning/thinking tags that may leak through the stream.
|
|
|
|
|
|
const cleaned = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" });
|
|
|
|
|
|
// Skip pure-reasoning messages (e.g. "Reasoning:\n…") that contain no answer text.
|
|
|
|
|
|
if (!cleaned || cleaned.startsWith("Reasoning:\n")) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cleaned === lastPartialText) {
|
2026-02-20 12:37:15 -06:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
hasStreamedMessage = true;
|
|
|
|
|
|
if (discordStreamMode === "partial") {
|
|
|
|
|
|
// Keep the longer preview to avoid visible punctuation flicker.
|
|
|
|
|
|
if (
|
|
|
|
|
|
lastPartialText &&
|
2026-02-23 17:30:59 +00:00
|
|
|
|
lastPartialText.startsWith(cleaned) &&
|
|
|
|
|
|
cleaned.length < lastPartialText.length
|
2026-02-20 12:37:15 -06:00
|
|
|
|
) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-23 17:30:59 +00:00
|
|
|
|
lastPartialText = cleaned;
|
|
|
|
|
|
draftStream.update(cleaned);
|
2026-02-20 12:37:15 -06:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-23 17:30:59 +00:00
|
|
|
|
let delta = cleaned;
|
|
|
|
|
|
if (cleaned.startsWith(lastPartialText)) {
|
|
|
|
|
|
delta = cleaned.slice(lastPartialText.length);
|
2026-02-20 12:37:15 -06:00
|
|
|
|
} else {
|
|
|
|
|
|
// Streaming buffer reset (or non-monotonic stream). Start fresh.
|
|
|
|
|
|
draftChunker?.reset();
|
|
|
|
|
|
draftText = "";
|
|
|
|
|
|
}
|
2026-02-23 17:30:59 +00:00
|
|
|
|
lastPartialText = cleaned;
|
2026-02-20 12:37:15 -06:00
|
|
|
|
if (!delta) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!draftChunker) {
|
2026-02-23 17:30:59 +00:00
|
|
|
|
draftText = cleaned;
|
2026-02-20 12:37:15 -06:00
|
|
|
|
draftStream.update(draftText);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
draftChunker.append(delta);
|
|
|
|
|
|
draftChunker.drain({
|
|
|
|
|
|
force: false,
|
|
|
|
|
|
emit: (chunk) => {
|
|
|
|
|
|
draftText += chunk;
|
|
|
|
|
|
draftStream.update(draftText);
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const flushDraft = async () => {
|
|
|
|
|
|
if (!draftStream) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (draftChunker?.hasBuffered()) {
|
|
|
|
|
|
draftChunker.drain({
|
|
|
|
|
|
force: true,
|
|
|
|
|
|
emit: (chunk) => {
|
|
|
|
|
|
draftText += chunk;
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
draftChunker.reset();
|
|
|
|
|
|
if (draftText) {
|
|
|
|
|
|
draftStream.update(draftText);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
await draftStream.flush();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// When draft streaming is active, suppress block streaming to avoid double-streaming.
|
|
|
|
|
|
const disableBlockStreamingForDraft = draftStream ? true : undefined;
|
|
|
|
|
|
|
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),
|
2026-02-20 12:37:15 -06:00
|
|
|
|
deliver: async (payload: ReplyPayload, info) => {
|
|
|
|
|
|
const isFinal = info.kind === "final";
|
2026-02-24 23:27:12 +00:00
|
|
|
|
if (payload.isReasoning) {
|
|
|
|
|
|
// Reasoning/thinking payloads should not be delivered to Discord.
|
2026-02-24 11:33:40 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-20 12:37:15 -06:00
|
|
|
|
if (draftStream && isFinal) {
|
|
|
|
|
|
await flushDraft();
|
|
|
|
|
|
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
|
|
|
|
|
const finalText = payload.text;
|
|
|
|
|
|
const previewFinalText = resolvePreviewFinalText(finalText);
|
|
|
|
|
|
const previewMessageId = draftStream.messageId();
|
|
|
|
|
|
|
|
|
|
|
|
// Try to finalize via preview edit (text-only, fits in 2000 chars, not an error)
|
|
|
|
|
|
const canFinalizeViaPreviewEdit =
|
|
|
|
|
|
!finalizedViaPreviewMessage &&
|
|
|
|
|
|
!hasMedia &&
|
|
|
|
|
|
typeof previewFinalText === "string" &&
|
|
|
|
|
|
typeof previewMessageId === "string" &&
|
|
|
|
|
|
!payload.isError;
|
|
|
|
|
|
|
|
|
|
|
|
if (canFinalizeViaPreviewEdit) {
|
|
|
|
|
|
await draftStream.stop();
|
|
|
|
|
|
try {
|
|
|
|
|
|
await editMessageDiscord(
|
|
|
|
|
|
deliverChannelId,
|
|
|
|
|
|
previewMessageId,
|
|
|
|
|
|
{ content: previewFinalText },
|
|
|
|
|
|
{ rest: client.rest },
|
|
|
|
|
|
);
|
|
|
|
|
|
finalizedViaPreviewMessage = true;
|
|
|
|
|
|
replyReference.markSent();
|
|
|
|
|
|
return;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
logVerbose(
|
|
|
|
|
|
`discord: preview final edit failed; falling back to standard send (${String(err)})`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if stop() flushed a message we can edit
|
|
|
|
|
|
if (!finalizedViaPreviewMessage) {
|
|
|
|
|
|
await draftStream.stop();
|
|
|
|
|
|
const messageIdAfterStop = draftStream.messageId();
|
|
|
|
|
|
if (
|
|
|
|
|
|
typeof messageIdAfterStop === "string" &&
|
|
|
|
|
|
typeof previewFinalText === "string" &&
|
|
|
|
|
|
!hasMedia &&
|
|
|
|
|
|
!payload.isError
|
|
|
|
|
|
) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await editMessageDiscord(
|
|
|
|
|
|
deliverChannelId,
|
|
|
|
|
|
messageIdAfterStop,
|
|
|
|
|
|
{ content: previewFinalText },
|
|
|
|
|
|
{ rest: client.rest },
|
|
|
|
|
|
);
|
|
|
|
|
|
finalizedViaPreviewMessage = true;
|
|
|
|
|
|
replyReference.markSent();
|
|
|
|
|
|
return;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
logVerbose(
|
|
|
|
|
|
`discord: post-stop preview edit failed; falling back to standard send (${String(err)})`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Clear the preview and fall through to standard delivery
|
|
|
|
|
|
if (!finalizedViaPreviewMessage) {
|
|
|
|
|
|
await draftStream.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 14:31:43 +00:00
|
|
|
|
const replyToId = replyReference.use();
|
|
|
|
|
|
await deliverDiscordReply({
|
|
|
|
|
|
replies: [payload],
|
|
|
|
|
|
target: deliverTarget,
|
|
|
|
|
|
token,
|
|
|
|
|
|
accountId,
|
|
|
|
|
|
rest: client.rest,
|
|
|
|
|
|
runtime,
|
|
|
|
|
|
replyToId,
|
2026-02-20 16:37:06 -06:00
|
|
|
|
replyToMode,
|
2026-01-14 14:31:43 +00:00
|
|
|
|
textLimit,
|
|
|
|
|
|
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
2026-01-23 17:56:50 +00:00
|
|
|
|
tableMode,
|
2026-02-20 12:37:15 -06:00
|
|
|
|
chunkMode,
|
2026-02-21 16:14:55 +01:00
|
|
|
|
sessionKey: ctxPayload.SessionKey,
|
|
|
|
|
|
threadBindings,
|
2026-01-14 14:31:43 +00:00
|
|
|
|
});
|
|
|
|
|
|
replyReference.markSent();
|
|
|
|
|
|
},
|
2026-01-14 17:10:16 -08:00
|
|
|
|
onError: (err, info) => {
|
|
|
|
|
|
runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`));
|
|
|
|
|
|
},
|
2026-02-17 04:38:39 +09:00
|
|
|
|
onReplyStart: async () => {
|
|
|
|
|
|
await typingCallbacks.onReplyStart();
|
|
|
|
|
|
await statusReactions.setThinking();
|
2026-01-14 01:08:15 +00:00
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-02-17 04:38:39 +09: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:
|
2026-02-20 12:37:15 -06:00
|
|
|
|
disableBlockStreamingForDraft ??
|
|
|
|
|
|
(typeof discordConfig?.blockStreaming === "boolean"
|
2026-02-17 04:38:39 +09:00
|
|
|
|
? !discordConfig.blockStreaming
|
2026-02-20 12:37:15 -06:00
|
|
|
|
: undefined),
|
|
|
|
|
|
onPartialReply: draftStream ? (payload) => updateDraftFromPartial(payload.text) : undefined,
|
|
|
|
|
|
onAssistantMessageStart: draftStream
|
|
|
|
|
|
? () => {
|
|
|
|
|
|
if (shouldSplitPreviewMessages && hasStreamedMessage) {
|
|
|
|
|
|
logVerbose("discord: calling forceNewMessage() for draft stream");
|
|
|
|
|
|
draftStream.forceNewMessage();
|
|
|
|
|
|
}
|
|
|
|
|
|
lastPartialText = "";
|
|
|
|
|
|
draftText = "";
|
|
|
|
|
|
draftChunker?.reset();
|
|
|
|
|
|
}
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
onReasoningEnd: draftStream
|
|
|
|
|
|
? () => {
|
|
|
|
|
|
if (shouldSplitPreviewMessages && hasStreamedMessage) {
|
|
|
|
|
|
logVerbose("discord: calling forceNewMessage() for draft stream");
|
|
|
|
|
|
draftStream.forceNewMessage();
|
|
|
|
|
|
}
|
|
|
|
|
|
lastPartialText = "";
|
|
|
|
|
|
draftText = "";
|
|
|
|
|
|
draftChunker?.reset();
|
|
|
|
|
|
}
|
|
|
|
|
|
: undefined,
|
2026-02-17 04:38:39 +09:00
|
|
|
|
onModelSelected,
|
|
|
|
|
|
onReasoningStream: async () => {
|
|
|
|
|
|
await statusReactions.setThinking();
|
|
|
|
|
|
},
|
|
|
|
|
|
onToolStart: async (payload) => {
|
|
|
|
|
|
await statusReactions.setTool(payload.name);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
dispatchError = true;
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
} finally {
|
2026-02-20 12:37:15 -06:00
|
|
|
|
// Must stop() first to flush debounced content before clear() wipes state
|
|
|
|
|
|
await draftStream?.stop();
|
|
|
|
|
|
if (!finalizedViaPreviewMessage) {
|
|
|
|
|
|
await draftStream?.clear();
|
|
|
|
|
|
}
|
2026-02-17 04:38:39 +09:00
|
|
|
|
markDispatchIdle();
|
|
|
|
|
|
if (statusReactionsEnabled) {
|
|
|
|
|
|
if (dispatchError) {
|
|
|
|
|
|
await statusReactions.setError();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await statusReactions.setDone();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (removeAckAfterReply) {
|
|
|
|
|
|
void (async () => {
|
2026-02-20 15:27:42 -06:00
|
|
|
|
await sleep(dispatchError ? DEFAULT_TIMING.errorHoldMs : DEFAULT_TIMING.doneHoldMs);
|
2026-02-17 04:38:39 +09:00
|
|
|
|
await statusReactions.clear();
|
|
|
|
|
|
})();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
void statusReactions.restoreInitial();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!dispatchResult?.queuedFinal) {
|
2026-01-23 22:36:43 +00:00
|
|
|
|
if (isGuildMessage) {
|
|
|
|
|
|
clearHistoryEntriesIfEnabled({
|
2026-01-14 01:08:15 +00:00
|
|
|
|
historyMap: guildHistories,
|
2026-02-16 02:30:17 +00:00
|
|
|
|
historyKey: messageChannelId,
|
2026-01-23 22:36:43 +00:00
|
|
|
|
limit: historyLimit,
|
2026-01-14 01:08:15 +00:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (shouldLogVerbose()) {
|
2026-02-17 04:38:39 +09:00
|
|
|
|
const finalCount = dispatchResult.counts.final;
|
2026-01-14 01:08:15 +00:00
|
|
|
|
logVerbose(
|
|
|
|
|
|
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-01-23 22:36:43 +00:00
|
|
|
|
if (isGuildMessage) {
|
|
|
|
|
|
clearHistoryEntriesIfEnabled({
|
2026-01-14 01:08:15 +00:00
|
|
|
|
historyMap: guildHistories,
|
2026-02-16 02:30:17 +00:00
|
|
|
|
historyKey: messageChannelId,
|
2026-01-23 22:36:43 +00:00
|
|
|
|
limit: historyLimit,
|
2026-01-14 01:08:15 +00:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|