Files
openclaw/src/discord/monitor/native-command.ts

936 lines
29 KiB
TypeScript
Raw Normal View History

2026-01-15 01:13:36 -06:00
import {
Button,
ChannelType,
Command,
Row,
type AutocompleteInteraction,
type ButtonInteraction,
type CommandInteraction,
type CommandOptions,
type ComponentData,
} from "@buape/carbon";
import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10";
import type {
ChatCommandDefinition,
CommandArgDefinition,
CommandArgValues,
CommandArgs,
NativeCommandSpec,
} from "../../auto-reply/commands-registry.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { OpenClawConfig, loadConfig } from "../../config/config.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 { resolveHumanDelayConfig } from "../../agents/identity.js";
2026-01-25 04:05:14 +00:00
import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js";
2026-01-15 01:13:36 -06:00
import {
buildCommandTextFromArgs,
findCommandByNativeName,
listChatCommands,
parseCommandArgs,
resolveCommandArgChoices,
resolveCommandArgMenu,
serializeCommandArgs,
} from "../../auto-reply/commands-registry.js";
2026-01-17 05:04:29 +00:00
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.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-14 01:08:15 +00:00
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
2026-01-14 01:08:15 +00:00
import { loadWebMedia } from "../../web/media.js";
2026-01-25 04:05:14 +00:00
import { chunkDiscordTextWithMode } from "../chunk.js";
2026-01-14 01:08:15 +00:00
import {
allowListMatches,
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug,
2026-01-17 15:26:59 -06:00
resolveDiscordChannelConfigWithFallback,
2026-01-14 01:08:15 +00:00
resolveDiscordGuildEntry,
resolveDiscordOwnerAllowFrom,
2026-01-14 01:08:15 +00:00
resolveDiscordUserAllowed,
} from "./allow-list.js";
2026-01-17 15:26:59 -06:00
import { resolveDiscordChannelInfo } from "./message-utils.js";
import { resolveDiscordSenderIdentity } from "./sender-identity.js";
2026-01-17 15:26:59 -06:00
import { resolveDiscordThreadParentInfo } from "./threading.js";
2026-01-14 01:08:15 +00:00
2026-01-30 03:15:10 +01:00
type DiscordConfig = NonNullable<OpenClawConfig["channels"]>["discord"];
2026-01-14 01:08:15 +00:00
2026-01-15 01:13:36 -06:00
function buildDiscordCommandOptions(params: {
command: ChatCommandDefinition;
cfg: ReturnType<typeof loadConfig>;
}): CommandOptions | undefined {
const { command, cfg } = params;
const args = command.args;
if (!args || args.length === 0) {
return undefined;
}
2026-01-15 01:13:36 -06:00
return args.map((arg) => {
const required = arg.required ?? false;
if (arg.type === "number") {
return {
name: arg.name,
description: arg.description,
type: ApplicationCommandOptionType.Number,
required,
};
}
if (arg.type === "boolean") {
return {
name: arg.name,
description: arg.description,
type: ApplicationCommandOptionType.Boolean,
required,
};
}
const resolvedChoices = resolveCommandArgChoices({ command, arg, cfg });
const shouldAutocomplete =
resolvedChoices.length > 0 &&
(typeof arg.choices === "function" || resolvedChoices.length > 25);
const autocomplete = shouldAutocomplete
? async (interaction: AutocompleteInteraction) => {
const focused = interaction.options.getFocused();
const focusValue =
typeof focused?.value === "string" ? focused.value.trim().toLowerCase() : "";
const choices = resolveCommandArgChoices({ command, arg, cfg });
const filtered = focusValue
fix(gateway): gracefully handle AbortError and transient network errors (#2451) * fix(tts): generate audio when block streaming drops final reply When block streaming succeeds, final replies are dropped but TTS was only applied to final replies. Fix by accumulating block text during streaming and generating TTS-only audio after streaming completes. Also: - Change truncate vs skip behavior when summary OFF (now truncates) - Align TTS limits with Telegram max (4096 chars) - Improve /tts command help messages with examples - Add newline separator between accumulated blocks * fix(tts): add error handling for accumulated block TTS * feat(tts): add descriptive inline menu with action descriptions - Add value/label support for command arg choices - TTS menu now shows descriptive title listing each action - Capitalize button labels (On, Off, Status, etc.) - Update Telegram, Discord, and Slack handlers to use labels Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(gateway): gracefully handle AbortError and transient network errors Addresses issues #1851, #1997, and #2034. During config reload (SIGUSR1), in-flight requests are aborted, causing AbortError exceptions. Similarly, transient network errors (fetch failed, ECONNRESET, ETIMEDOUT, etc.) can crash the gateway unnecessarily. This change: - Adds isAbortError() to detect intentional cancellations - Adds isTransientNetworkError() to detect temporary connectivity issues - Logs these errors appropriately instead of crashing - Handles nested cause chains and AggregateError AbortError is logged as a warning (expected during shutdown). Network errors are logged as non-fatal errors (will resolve on their own). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(test): update commands-registry test expectations Update test expectations to match new ResolvedCommandArgChoice format (choices now return {label, value} objects instead of plain strings). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Shadow <hi@shadowing.dev>
2026-01-26 21:51:53 -04:00
? choices.filter((choice) => choice.label.toLowerCase().includes(focusValue))
2026-01-15 01:13:36 -06:00
: choices;
await interaction.respond(
fix(gateway): gracefully handle AbortError and transient network errors (#2451) * fix(tts): generate audio when block streaming drops final reply When block streaming succeeds, final replies are dropped but TTS was only applied to final replies. Fix by accumulating block text during streaming and generating TTS-only audio after streaming completes. Also: - Change truncate vs skip behavior when summary OFF (now truncates) - Align TTS limits with Telegram max (4096 chars) - Improve /tts command help messages with examples - Add newline separator between accumulated blocks * fix(tts): add error handling for accumulated block TTS * feat(tts): add descriptive inline menu with action descriptions - Add value/label support for command arg choices - TTS menu now shows descriptive title listing each action - Capitalize button labels (On, Off, Status, etc.) - Update Telegram, Discord, and Slack handlers to use labels Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(gateway): gracefully handle AbortError and transient network errors Addresses issues #1851, #1997, and #2034. During config reload (SIGUSR1), in-flight requests are aborted, causing AbortError exceptions. Similarly, transient network errors (fetch failed, ECONNRESET, ETIMEDOUT, etc.) can crash the gateway unnecessarily. This change: - Adds isAbortError() to detect intentional cancellations - Adds isTransientNetworkError() to detect temporary connectivity issues - Logs these errors appropriately instead of crashing - Handles nested cause chains and AggregateError AbortError is logged as a warning (expected during shutdown). Network errors are logged as non-fatal errors (will resolve on their own). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(test): update commands-registry test expectations Update test expectations to match new ResolvedCommandArgChoice format (choices now return {label, value} objects instead of plain strings). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Shadow <hi@shadowing.dev>
2026-01-26 21:51:53 -04:00
filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })),
2026-01-15 01:13:36 -06:00
);
}
: undefined;
const choices =
resolvedChoices.length > 0 && !autocomplete
fix(gateway): gracefully handle AbortError and transient network errors (#2451) * fix(tts): generate audio when block streaming drops final reply When block streaming succeeds, final replies are dropped but TTS was only applied to final replies. Fix by accumulating block text during streaming and generating TTS-only audio after streaming completes. Also: - Change truncate vs skip behavior when summary OFF (now truncates) - Align TTS limits with Telegram max (4096 chars) - Improve /tts command help messages with examples - Add newline separator between accumulated blocks * fix(tts): add error handling for accumulated block TTS * feat(tts): add descriptive inline menu with action descriptions - Add value/label support for command arg choices - TTS menu now shows descriptive title listing each action - Capitalize button labels (On, Off, Status, etc.) - Update Telegram, Discord, and Slack handlers to use labels Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(gateway): gracefully handle AbortError and transient network errors Addresses issues #1851, #1997, and #2034. During config reload (SIGUSR1), in-flight requests are aborted, causing AbortError exceptions. Similarly, transient network errors (fetch failed, ECONNRESET, ETIMEDOUT, etc.) can crash the gateway unnecessarily. This change: - Adds isAbortError() to detect intentional cancellations - Adds isTransientNetworkError() to detect temporary connectivity issues - Logs these errors appropriately instead of crashing - Handles nested cause chains and AggregateError AbortError is logged as a warning (expected during shutdown). Network errors are logged as non-fatal errors (will resolve on their own). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(test): update commands-registry test expectations Update test expectations to match new ResolvedCommandArgChoice format (choices now return {label, value} objects instead of plain strings). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Shadow <hi@shadowing.dev>
2026-01-26 21:51:53 -04:00
? resolvedChoices
.slice(0, 25)
.map((choice) => ({ name: choice.label, value: choice.value }))
2026-01-15 01:13:36 -06:00
: undefined;
return {
name: arg.name,
description: arg.description,
type: ApplicationCommandOptionType.String,
required,
choices,
autocomplete,
};
}) satisfies CommandOptions;
}
function readDiscordCommandArgs(
interaction: CommandInteraction,
definitions?: CommandArgDefinition[],
): CommandArgs | undefined {
if (!definitions || definitions.length === 0) {
return undefined;
}
2026-01-15 01:13:36 -06:00
const values: CommandArgValues = {};
for (const definition of definitions) {
2026-01-15 18:37:02 +00:00
let value: string | number | boolean | null | undefined;
2026-01-15 01:13:36 -06:00
if (definition.type === "number") {
value = interaction.options.getNumber(definition.name) ?? null;
2026-01-15 01:13:36 -06:00
} else if (definition.type === "boolean") {
value = interaction.options.getBoolean(definition.name) ?? null;
2026-01-15 01:13:36 -06:00
} else {
value = interaction.options.getString(definition.name) ?? null;
2026-01-15 01:13:36 -06:00
}
if (value != null) {
values[definition.name] = value;
}
}
return Object.keys(values).length > 0 ? { values } : undefined;
}
function chunkItems<T>(items: T[], size: number): T[][] {
if (size <= 0) {
return [items];
}
2026-01-15 01:13:36 -06:00
const rows: T[][] = [];
for (let i = 0; i < items.length; i += size) {
rows.push(items.slice(i, i + size));
}
return rows;
}
const DISCORD_COMMAND_ARG_CUSTOM_ID_KEY = "cmdarg";
function createCommandArgsWithValue(params: { argName: string; value: string }): CommandArgs {
const values: CommandArgValues = { [params.argName]: params.value };
return { values };
}
function encodeDiscordCommandArgValue(value: string): string {
return encodeURIComponent(value);
}
function decodeDiscordCommandArgValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function isDiscordUnknownInteraction(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
const err = error as {
discordCode?: number;
status?: number;
message?: string;
rawBody?: { code?: number; message?: string };
};
if (err.discordCode === 10062 || err.rawBody?.code === 10062) {
return true;
}
if (err.status === 404 && /Unknown interaction/i.test(err.message ?? "")) {
return true;
}
if (/Unknown interaction/i.test(err.rawBody?.message ?? "")) {
return true;
}
return false;
}
async function safeDiscordInteractionCall<T>(
label: string,
fn: () => Promise<T>,
): Promise<T | null> {
try {
return await fn();
} catch (error) {
if (isDiscordUnknownInteraction(error)) {
console.warn(`discord: ${label} skipped (interaction expired)`);
return null;
}
throw error;
}
}
2026-01-15 01:13:36 -06:00
function buildDiscordCommandArgCustomId(params: {
command: string;
arg: string;
value: string;
userId: string;
}): string {
return [
`${DISCORD_COMMAND_ARG_CUSTOM_ID_KEY}:command=${encodeDiscordCommandArgValue(params.command)}`,
`arg=${encodeDiscordCommandArgValue(params.arg)}`,
`value=${encodeDiscordCommandArgValue(params.value)}`,
`user=${encodeDiscordCommandArgValue(params.userId)}`,
].join(";");
}
function parseDiscordCommandArgData(
data: ComponentData,
): { command: string; arg: string; value: string; userId: string } | null {
if (!data || typeof data !== "object") {
return null;
}
2026-01-15 01:13:36 -06:00
const coerce = (value: unknown) =>
typeof value === "string" || typeof value === "number" ? String(value) : "";
const rawCommand = coerce(data.command);
const rawArg = coerce(data.arg);
const rawValue = coerce(data.value);
const rawUser = coerce(data.user);
if (!rawCommand || !rawArg || !rawValue || !rawUser) {
return null;
}
2026-01-15 01:13:36 -06:00
return {
command: decodeDiscordCommandArgValue(rawCommand),
arg: decodeDiscordCommandArgValue(rawArg),
value: decodeDiscordCommandArgValue(rawValue),
userId: decodeDiscordCommandArgValue(rawUser),
2026-01-14 01:08:15 +00:00
};
2026-01-15 01:13:36 -06:00
}
type DiscordCommandArgContext = {
cfg: ReturnType<typeof loadConfig>;
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
};
async function handleDiscordCommandArgInteraction(
interaction: ButtonInteraction,
data: ComponentData,
ctx: DiscordCommandArgContext,
) {
const parsed = parseDiscordCommandArgData(data);
if (!parsed) {
await safeDiscordInteractionCall("command arg update", () =>
interaction.update({
content: "Sorry, that selection is no longer available.",
components: [],
}),
);
return;
}
if (interaction.user?.id && interaction.user.id !== parsed.userId) {
await safeDiscordInteractionCall("command arg ack", () => interaction.acknowledge());
return;
}
const commandDefinition =
2026-01-24 09:58:06 +00:00
findCommandByNativeName(parsed.command, "discord") ??
listChatCommands().find((entry) => entry.key === parsed.command);
if (!commandDefinition) {
await safeDiscordInteractionCall("command arg update", () =>
interaction.update({
content: "Sorry, that command is no longer available.",
components: [],
}),
);
return;
}
const updated = await safeDiscordInteractionCall("command arg update", () =>
interaction.update({
content: `✅ Selected ${parsed.value}.`,
components: [],
}),
);
if (!updated) {
return;
}
const commandArgs = createCommandArgsWithValue({
argName: parsed.arg,
value: parsed.value,
});
const commandArgsWithRaw: CommandArgs = {
...commandArgs,
raw: serializeCommandArgs(commandDefinition, commandArgs),
};
const prompt = buildCommandTextFromArgs(commandDefinition, commandArgsWithRaw);
await dispatchDiscordCommandInteraction({
interaction,
prompt,
command: commandDefinition,
commandArgs: commandArgsWithRaw,
cfg: ctx.cfg,
discordConfig: ctx.discordConfig,
accountId: ctx.accountId,
sessionPrefix: ctx.sessionPrefix,
preferFollowUp: true,
});
}
2026-01-15 01:13:36 -06:00
class DiscordCommandArgButton extends Button {
label: string;
customId: string;
style = ButtonStyle.Secondary;
private cfg: ReturnType<typeof loadConfig>;
private discordConfig: DiscordConfig;
private accountId: string;
private sessionPrefix: string;
constructor(params: {
label: string;
customId: string;
cfg: ReturnType<typeof loadConfig>;
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
}) {
super();
this.label = params.label;
this.customId = params.customId;
this.cfg = params.cfg;
this.discordConfig = params.discordConfig;
this.accountId = params.accountId;
this.sessionPrefix = params.sessionPrefix;
}
async run(interaction: ButtonInteraction, data: ComponentData) {
await handleDiscordCommandArgInteraction(interaction, data, {
2026-01-15 01:13:36 -06:00
cfg: this.cfg,
discordConfig: this.discordConfig,
accountId: this.accountId,
sessionPrefix: this.sessionPrefix,
});
}
}
class DiscordCommandArgFallbackButton extends Button {
label = "cmdarg";
customId = "cmdarg:seed=1";
private ctx: DiscordCommandArgContext;
constructor(ctx: DiscordCommandArgContext) {
super();
this.ctx = ctx;
}
async run(interaction: ButtonInteraction, data: ComponentData) {
await handleDiscordCommandArgInteraction(interaction, data, this.ctx);
}
}
export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgContext): Button {
return new DiscordCommandArgFallbackButton(params);
}
2026-01-15 01:13:36 -06:00
function buildDiscordCommandArgMenu(params: {
command: ChatCommandDefinition;
fix(gateway): gracefully handle AbortError and transient network errors (#2451) * fix(tts): generate audio when block streaming drops final reply When block streaming succeeds, final replies are dropped but TTS was only applied to final replies. Fix by accumulating block text during streaming and generating TTS-only audio after streaming completes. Also: - Change truncate vs skip behavior when summary OFF (now truncates) - Align TTS limits with Telegram max (4096 chars) - Improve /tts command help messages with examples - Add newline separator between accumulated blocks * fix(tts): add error handling for accumulated block TTS * feat(tts): add descriptive inline menu with action descriptions - Add value/label support for command arg choices - TTS menu now shows descriptive title listing each action - Capitalize button labels (On, Off, Status, etc.) - Update Telegram, Discord, and Slack handlers to use labels Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(gateway): gracefully handle AbortError and transient network errors Addresses issues #1851, #1997, and #2034. During config reload (SIGUSR1), in-flight requests are aborted, causing AbortError exceptions. Similarly, transient network errors (fetch failed, ECONNRESET, ETIMEDOUT, etc.) can crash the gateway unnecessarily. This change: - Adds isAbortError() to detect intentional cancellations - Adds isTransientNetworkError() to detect temporary connectivity issues - Logs these errors appropriately instead of crashing - Handles nested cause chains and AggregateError AbortError is logged as a warning (expected during shutdown). Network errors are logged as non-fatal errors (will resolve on their own). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(test): update commands-registry test expectations Update test expectations to match new ResolvedCommandArgChoice format (choices now return {label, value} objects instead of plain strings). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Shadow <hi@shadowing.dev>
2026-01-26 21:51:53 -04:00
menu: {
arg: CommandArgDefinition;
choices: Array<{ value: string; label: string }>;
title?: string;
};
2026-01-15 01:13:36 -06:00
interaction: CommandInteraction;
cfg: ReturnType<typeof loadConfig>;
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
}): { content: string; components: Row<Button>[] } {
const { command, menu, interaction } = params;
const commandLabel = command.nativeName ?? command.key;
const userId = interaction.user?.id ?? "";
const rows = chunkItems(menu.choices, 4).map((choices) => {
const buttons = choices.map(
(choice) =>
new DiscordCommandArgButton({
fix(gateway): gracefully handle AbortError and transient network errors (#2451) * fix(tts): generate audio when block streaming drops final reply When block streaming succeeds, final replies are dropped but TTS was only applied to final replies. Fix by accumulating block text during streaming and generating TTS-only audio after streaming completes. Also: - Change truncate vs skip behavior when summary OFF (now truncates) - Align TTS limits with Telegram max (4096 chars) - Improve /tts command help messages with examples - Add newline separator between accumulated blocks * fix(tts): add error handling for accumulated block TTS * feat(tts): add descriptive inline menu with action descriptions - Add value/label support for command arg choices - TTS menu now shows descriptive title listing each action - Capitalize button labels (On, Off, Status, etc.) - Update Telegram, Discord, and Slack handlers to use labels Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(gateway): gracefully handle AbortError and transient network errors Addresses issues #1851, #1997, and #2034. During config reload (SIGUSR1), in-flight requests are aborted, causing AbortError exceptions. Similarly, transient network errors (fetch failed, ECONNRESET, ETIMEDOUT, etc.) can crash the gateway unnecessarily. This change: - Adds isAbortError() to detect intentional cancellations - Adds isTransientNetworkError() to detect temporary connectivity issues - Logs these errors appropriately instead of crashing - Handles nested cause chains and AggregateError AbortError is logged as a warning (expected during shutdown). Network errors are logged as non-fatal errors (will resolve on their own). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(test): update commands-registry test expectations Update test expectations to match new ResolvedCommandArgChoice format (choices now return {label, value} objects instead of plain strings). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Shadow <hi@shadowing.dev>
2026-01-26 21:51:53 -04:00
label: choice.label,
2026-01-15 01:13:36 -06:00
customId: buildDiscordCommandArgCustomId({
command: commandLabel,
arg: menu.arg.name,
fix(gateway): gracefully handle AbortError and transient network errors (#2451) * fix(tts): generate audio when block streaming drops final reply When block streaming succeeds, final replies are dropped but TTS was only applied to final replies. Fix by accumulating block text during streaming and generating TTS-only audio after streaming completes. Also: - Change truncate vs skip behavior when summary OFF (now truncates) - Align TTS limits with Telegram max (4096 chars) - Improve /tts command help messages with examples - Add newline separator between accumulated blocks * fix(tts): add error handling for accumulated block TTS * feat(tts): add descriptive inline menu with action descriptions - Add value/label support for command arg choices - TTS menu now shows descriptive title listing each action - Capitalize button labels (On, Off, Status, etc.) - Update Telegram, Discord, and Slack handlers to use labels Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(gateway): gracefully handle AbortError and transient network errors Addresses issues #1851, #1997, and #2034. During config reload (SIGUSR1), in-flight requests are aborted, causing AbortError exceptions. Similarly, transient network errors (fetch failed, ECONNRESET, ETIMEDOUT, etc.) can crash the gateway unnecessarily. This change: - Adds isAbortError() to detect intentional cancellations - Adds isTransientNetworkError() to detect temporary connectivity issues - Logs these errors appropriately instead of crashing - Handles nested cause chains and AggregateError AbortError is logged as a warning (expected during shutdown). Network errors are logged as non-fatal errors (will resolve on their own). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(test): update commands-registry test expectations Update test expectations to match new ResolvedCommandArgChoice format (choices now return {label, value} objects instead of plain strings). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Shadow <hi@shadowing.dev>
2026-01-26 21:51:53 -04:00
value: choice.value,
2026-01-15 01:13:36 -06:00
userId,
}),
cfg: params.cfg,
discordConfig: params.discordConfig,
accountId: params.accountId,
sessionPrefix: params.sessionPrefix,
}),
);
return new Row(buttons);
});
const content =
menu.title ?? `Choose ${menu.arg.description || menu.arg.name} for /${commandLabel}.`;
return { content, components: rows };
}
export function createDiscordNativeCommand(params: {
command: NativeCommandSpec;
2026-01-14 01:08:15 +00:00
cfg: ReturnType<typeof loadConfig>;
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
ephemeralDefault: boolean;
}): Command {
const { command, cfg, discordConfig, accountId, sessionPrefix, ephemeralDefault } = params;
2026-01-15 01:13:36 -06:00
const commandDefinition =
2026-01-24 09:58:06 +00:00
findCommandByNativeName(command.name, "discord") ??
2026-01-15 01:13:36 -06:00
({
key: command.name,
nativeName: command.name,
description: command.description,
textAliases: [],
acceptsArgs: command.acceptsArgs,
args: command.args,
argsParsing: "none",
scope: "native",
} satisfies ChatCommandDefinition);
const argDefinitions = commandDefinition.args ?? command.args;
const commandOptions = buildDiscordCommandOptions({
command: commandDefinition,
cfg,
});
const options = commandOptions
? (commandOptions satisfies CommandOptions)
: command.acceptsArgs
2026-01-14 01:08:15 +00:00
? ([
{
name: "input",
description: "Command input",
type: ApplicationCommandOptionType.String,
required: false,
},
] satisfies CommandOptions)
: undefined;
2026-01-15 01:13:36 -06:00
return new (class extends Command {
name = command.name;
description = command.description;
defer = true;
ephemeral = ephemeralDefault;
options = options;
2026-01-14 01:08:15 +00:00
async run(interaction: CommandInteraction) {
2026-01-15 01:13:36 -06:00
const commandArgs = argDefinitions?.length
? readDiscordCommandArgs(interaction, argDefinitions)
: command.acceptsArgs
? parseCommandArgs(commandDefinition, interaction.options.getString("input") ?? "")
: undefined;
const commandArgsWithRaw = commandArgs
? ({
...commandArgs,
raw: serializeCommandArgs(commandDefinition, commandArgs) ?? commandArgs.raw,
} satisfies CommandArgs)
: undefined;
const prompt = buildCommandTextFromArgs(commandDefinition, commandArgsWithRaw);
await dispatchDiscordCommandInteraction({
interaction,
prompt,
command: commandDefinition,
commandArgs: commandArgsWithRaw,
cfg,
discordConfig,
accountId,
sessionPrefix,
preferFollowUp: false,
2026-01-14 01:08:15 +00:00
});
2026-01-15 01:13:36 -06:00
}
})();
}
async function dispatchDiscordCommandInteraction(params: {
interaction: CommandInteraction | ButtonInteraction;
prompt: string;
command: ChatCommandDefinition;
commandArgs?: CommandArgs;
cfg: ReturnType<typeof loadConfig>;
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
preferFollowUp: boolean;
}) {
const {
interaction,
prompt,
command,
commandArgs,
cfg,
discordConfig,
accountId,
sessionPrefix,
preferFollowUp,
} = params;
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
const payload = {
content,
...(options?.ephemeral !== undefined ? { ephemeral: options.ephemeral } : {}),
};
await safeDiscordInteractionCall("interaction reply", async () => {
if (preferFollowUp) {
await interaction.followUp(payload);
return;
}
await interaction.reply(payload);
});
2026-01-15 01:13:36 -06:00
};
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const user = interaction.user;
if (!user) {
return;
}
const sender = resolveDiscordSenderIdentity({ author: user, pluralkitInfo: null });
2026-01-15 01:13:36 -06:00
const channel = interaction.channel;
const channelType = channel?.type;
const isDirectMessage = channelType === ChannelType.DM;
const isGroupDm = channelType === ChannelType.GroupDM;
2026-01-17 15:26:59 -06:00
const isThreadChannel =
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread;
2026-01-15 01:13:36 -06:00
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
2026-01-17 15:26:59 -06:00
const rawChannelId = channel?.id ?? "";
const ownerAllowList = normalizeDiscordAllowList(discordConfig?.dm?.allowFrom ?? [], [
"discord:",
"user:",
"pk:",
]);
const ownerOk =
ownerAllowList && user
? allowListMatches(ownerAllowList, {
id: sender.id,
name: sender.name,
tag: sender.tag,
})
: false;
2026-01-15 01:13:36 -06:00
const guildInfo = resolveDiscordGuildEntry({
guild: interaction.guild ?? undefined,
guildEntries: discordConfig?.guilds,
});
2026-01-17 15:26:59 -06:00
let threadParentId: string | undefined;
let threadParentName: string | undefined;
let threadParentSlug = "";
if (interaction.guild && channel && isThreadChannel && rawChannelId) {
// Threads inherit parent channel config unless explicitly overridden.
const channelInfo = await resolveDiscordChannelInfo(interaction.client, rawChannelId);
const parentInfo = await resolveDiscordThreadParentInfo({
client: interaction.client,
threadChannel: {
id: rawChannelId,
name: channelName,
parentId: "parentId" in channel ? (channel.parentId ?? undefined) : undefined,
2026-01-17 15:26:59 -06:00
parent: undefined,
},
channelInfo,
});
threadParentId = parentInfo.id;
threadParentName = parentInfo.name;
threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : "";
}
2026-01-15 01:13:36 -06:00
const channelConfig = interaction.guild
2026-01-17 15:26:59 -06:00
? resolveDiscordChannelConfigWithFallback({
2026-01-15 01:13:36 -06:00
guildInfo,
2026-01-17 15:26:59 -06:00
channelId: rawChannelId,
2026-01-15 01:13:36 -06:00
channelName,
channelSlug,
2026-01-17 15:26:59 -06:00
parentId: threadParentId,
parentName: threadParentName,
parentSlug: threadParentSlug,
scope: isThreadChannel ? "thread" : "channel",
2026-01-15 01:13:36 -06:00
})
: null;
if (channelConfig?.enabled === false) {
await respond("This channel is disabled.");
return;
}
if (interaction.guild && channelConfig?.allowed === false) {
await respond("This channel is not allowed.");
return;
}
if (useAccessGroups && interaction.guild) {
const channelAllowlistConfigured =
Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false;
const allowByPolicy = isDiscordGroupAllowedByPolicy({
groupPolicy: discordConfig?.groupPolicy ?? "open",
guildAllowlisted: Boolean(guildInfo),
2026-01-15 01:13:36 -06:00
channelAllowlistConfigured,
channelAllowed,
});
if (!allowByPolicy) {
await respond("This channel is not allowed.");
return;
}
}
const dmEnabled = discordConfig?.dm?.enabled ?? true;
const dmPolicy = discordConfig?.dm?.policy ?? "pairing";
let commandAuthorized = true;
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") {
await respond("Discord DMs are disabled.");
return;
}
if (dmPolicy !== "open") {
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
const effectiveAllowFrom = [...(discordConfig?.dm?.allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
2026-01-15 01:13:36 -06:00
const permitted = allowList
? allowListMatches(allowList, {
id: sender.id,
name: sender.name,
tag: sender.tag,
2026-01-14 01:08:15 +00:00
})
2026-01-15 01:13:36 -06:00
: false;
if (!permitted) {
commandAuthorized = false;
if (dmPolicy === "pairing") {
const { code, created } = await upsertChannelPairingRequest({
channel: "discord",
id: user.id,
meta: {
tag: sender.tag,
name: sender.name,
2026-01-15 01:13:36 -06:00
},
2026-01-14 01:08:15 +00:00
});
2026-01-15 01:13:36 -06:00
if (created) {
await respond(
buildPairingReply({
2026-01-14 01:08:15 +00:00
channel: "discord",
2026-01-15 01:13:36 -06:00
idLine: `Your Discord user id: ${user.id}`,
code,
}),
{ ephemeral: true },
);
2026-01-14 01:08:15 +00:00
}
2026-01-15 01:13:36 -06:00
} else {
await respond("You are not authorized to use this command.", { ephemeral: true });
2026-01-14 01:08:15 +00:00
}
return;
}
2026-01-15 01:13:36 -06:00
commandAuthorized = true;
}
}
if (!isDirectMessage) {
const channelUsers = channelConfig?.users ?? guildInfo?.users;
const hasUserAllowlist = Array.isArray(channelUsers) && channelUsers.length > 0;
const userOk = hasUserAllowlist
? resolveDiscordUserAllowed({
allowList: channelUsers,
userId: sender.id,
userName: sender.name,
userTag: sender.tag,
})
: false;
const authorizers = useAccessGroups
? [
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: hasUserAllowlist, allowed: userOk },
]
: [{ configured: hasUserAllowlist, allowed: userOk }];
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers,
modeWhenAccessGroupsOff: "configured",
});
if (!commandAuthorized) {
await respond("You are not authorized to use this command.", { ephemeral: true });
return;
2026-01-15 01:13:36 -06:00
}
}
if (isGroupDm && discordConfig?.dm?.groupEnabled === false) {
await respond("Discord group DMs are disabled.");
return;
}
2026-01-14 01:08:15 +00:00
2026-01-15 01:13:36 -06:00
const menu = resolveCommandArgMenu({
command,
args: commandArgs,
cfg,
});
if (menu) {
const menuPayload = buildDiscordCommandArgMenu({
command,
menu,
interaction: interaction as CommandInteraction,
cfg,
discordConfig,
accountId,
sessionPrefix,
});
if (preferFollowUp) {
await safeDiscordInteractionCall("interaction follow-up", () =>
interaction.followUp({
content: menuPayload.content,
components: menuPayload.components,
ephemeral: true,
}),
);
return;
}
await safeDiscordInteractionCall("interaction reply", () =>
interaction.reply({
2026-01-15 01:13:36 -06:00
content: menuPayload.content,
components: menuPayload.components,
ephemeral: true,
}),
);
2026-01-15 01:13:36 -06:00
return;
}
const isGuild = Boolean(interaction.guild);
2026-01-17 15:26:59 -06:00
const channelId = rawChannelId || "unknown";
2026-01-15 01:13:36 -06:00
const interactionId = interaction.rawData.id;
const route = resolveAgentRoute({
cfg,
channel: "discord",
accountId,
guildId: interaction.guild?.id ?? undefined,
peer: {
refactor: unify peer kind to ChatType, rename dm to direct (#11881) * fix: use .js extension for ESM imports of RoutePeerKind The imports incorrectly used .ts extension which doesn't resolve with moduleResolution: NodeNext. Changed to .js and added 'type' import modifier. * fix tsconfig * refactor: unify peer kind to ChatType, rename dm to direct - Replace RoutePeerKind with ChatType throughout codebase - Change 'dm' literal values to 'direct' in routing/session keys - Keep backward compat: normalizeChatType accepts 'dm' -> 'direct' - Add ChatType export to plugin-sdk, deprecate RoutePeerKind - Update session key parsing to accept both 'dm' and 'direct' markers - Update all channel monitors and extensions to use ChatType BREAKING CHANGE: Session keys now use 'direct' instead of 'dm'. Existing 'dm' keys still work via backward compat layer. * fix tests * test: update session key expectations for dmdirect migration - Fix test expectations to expect :direct: in generated output - Add explicit backward compat test for normalizeChatType('dm') - Keep input test data with :dm: keys to verify backward compat * fix: accept legacy 'dm' in session key parsing for backward compat getDmHistoryLimitFromSessionKey now accepts both :dm: and :direct: to ensure old session keys continue to work correctly. * test: add explicit backward compat tests for dmdirect migration - session-key.test.ts: verify both :dm: and :direct: keys are valid - getDmHistoryLimitFromSessionKey: verify both formats work * feat: backward compat for resetByType.dm config key * test: skip unix-path Nix tests on Windows
2026-02-08 16:20:52 -08:00
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
2026-01-15 01:13:36 -06:00
id: isDirectMessage ? user.id : channelId,
},
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
2026-01-15 01:13:36 -06:00
});
2026-01-17 04:04:05 +00:00
const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId;
const ownerAllowFrom = resolveDiscordOwnerAllowFrom({
channelConfig,
guildInfo,
sender: { id: sender.id, name: sender.name, tag: sender.tag },
});
2026-01-17 05:04:29 +00:00
const ctxPayload = finalizeInboundContext({
2026-01-15 01:13:36 -06:00
Body: prompt,
2026-01-17 05:04:29 +00:00
RawBody: prompt,
2026-01-15 01:13:36 -06:00
CommandBody: prompt,
CommandArgs: commandArgs,
2026-01-17 08:46:19 +00:00
From: isDirectMessage
? `discord:${user.id}`
: isGroupDm
? `discord:group:${channelId}`
: `discord:channel:${channelId}`,
2026-01-15 01:13:36 -06:00
To: `slash:${user.id}`,
SessionKey: `agent:${route.agentId}:${sessionPrefix}:${user.id}`,
CommandTargetSessionKey: route.sessionKey,
AccountId: route.accountId,
2026-01-17 04:04:05 +00:00
ChatType: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
ConversationLabel: conversationLabel,
2026-01-15 01:13:36 -06:00
GroupSubject: isGuild ? interaction.guild?.name : undefined,
GroupSystemPrompt: isGuild
? (() => {
const systemPromptParts = [channelConfig?.systemPrompt?.trim() || null].filter(
(entry): entry is string => Boolean(entry),
);
return systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
})()
: undefined,
UntrustedContext: isGuild
2026-01-15 01:13:36 -06:00
? (() => {
const channelTopic =
channel && "topic" in channel ? (channel.topic ?? undefined) : undefined;
const untrustedChannelMetadata = buildUntrustedChannelMetadata({
source: "discord",
label: "Discord channel topic",
entries: [channelTopic],
});
return untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined;
2026-01-15 01:13:36 -06:00
})()
: undefined,
OwnerAllowFrom: ownerAllowFrom,
2026-01-15 01:13:36 -06:00
SenderName: user.globalName ?? user.username,
SenderId: user.id,
SenderUsername: user.username,
SenderTag: sender.tag,
2026-01-15 01:13:36 -06:00
Provider: "discord" as const,
Surface: "discord" as const,
WasMentioned: true,
MessageSid: interactionId,
Timestamp: Date.now(),
CommandAuthorized: commandAuthorized,
CommandSource: "native" as const,
2026-01-17 05:04:29 +00:00
});
2026-01-15 01:13:36 -06: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-15 01:13:36 -06:00
let didReply = false;
await dispatchReplyWithDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
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-15 01:13:36 -06:00
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
try {
await deliverDiscordInteractionReply({
interaction,
payload,
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
fallbackLimit: 2000,
}),
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
preferFollowUp: preferFollowUp || didReply,
2026-01-25 04:05:14 +00:00
chunkMode: resolveChunkMode(cfg, "discord", accountId),
});
} catch (error) {
if (isDiscordUnknownInteraction(error)) {
console.warn("discord: interaction reply skipped (interaction expired)");
return;
}
throw error;
}
2026-01-15 01:13:36 -06:00
didReply = true;
},
onError: (err, info) => {
console.error(`discord slash ${info.kind} reply failed`, err);
},
},
replyOptions: {
skillFilter: channelConfig?.skills,
disableBlockStreaming:
typeof discordConfig?.blockStreaming === "boolean"
? !discordConfig.blockStreaming
: undefined,
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
onModelSelected,
2026-01-15 01:13:36 -06:00
},
});
2026-01-14 01:08:15 +00:00
}
async function deliverDiscordInteractionReply(params: {
2026-01-15 01:13:36 -06:00
interaction: CommandInteraction | ButtonInteraction;
2026-01-14 01:08:15 +00:00
payload: ReplyPayload;
textLimit: number;
maxLinesPerMessage?: number;
preferFollowUp: boolean;
2026-01-25 04:05:14 +00:00
chunkMode: "length" | "newline";
2026-01-14 01:08:15 +00:00
}) {
2026-01-25 04:05:14 +00:00
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
2026-01-14 01:08:15 +00:00
const text = payload.text ?? "";
let hasReplied = false;
const sendMessage = async (content: string, files?: { name: string; data: Buffer }[]) => {
2026-01-14 01:08:15 +00:00
const payload =
files && files.length > 0
? {
content,
files: files.map((file) => {
if (file.data instanceof Blob) {
return { name: file.name, data: file.data };
}
const arrayBuffer = Uint8Array.from(file.data).buffer;
return { name: file.name, data: new Blob([arrayBuffer]) };
}),
}
: { content };
await safeDiscordInteractionCall("interaction send", async () => {
if (!preferFollowUp && !hasReplied) {
await interaction.reply(payload);
hasReplied = true;
return;
}
await interaction.followUp(payload);
2026-01-14 01:08:15 +00:00
hasReplied = true;
});
2026-01-14 01:08:15 +00:00
};
if (mediaList.length > 0) {
const media = await Promise.all(
mediaList.map(async (url) => {
const loaded = await loadWebMedia(url);
return {
name: loaded.fileName ?? "upload",
data: loaded.buffer,
};
}),
);
2026-01-25 04:05:14 +00:00
const chunks = chunkDiscordTextWithMode(text, {
2026-01-14 01:08:15 +00:00
maxChars: textLimit,
maxLines: maxLinesPerMessage,
2026-01-25 04:05:14 +00:00
chunkMode,
2026-01-14 01:08:15 +00:00
});
if (!chunks.length && text) {
chunks.push(text);
}
2026-01-14 01:08:15 +00:00
const caption = chunks[0] ?? "";
await sendMessage(caption, media);
for (const chunk of chunks.slice(1)) {
if (!chunk.trim()) {
continue;
}
2026-01-14 01:08:15 +00:00
await interaction.followUp({ content: chunk });
}
return;
}
if (!text.trim()) {
return;
}
2026-01-25 04:05:14 +00:00
const chunks = chunkDiscordTextWithMode(text, {
2026-01-14 01:08:15 +00:00
maxChars: textLimit,
maxLines: maxLinesPerMessage,
2026-01-25 04:05:14 +00:00
chunkMode,
2026-01-14 01:08:15 +00:00
});
if (!chunks.length && text) {
chunks.push(text);
}
2026-01-14 01:08:15 +00:00
for (const chunk of chunks) {
if (!chunk.trim()) {
continue;
}
2026-01-14 01:08:15 +00:00
await sendMessage(chunk);
}
}