2026-01-15 01:13:36 -06:00
|
|
|
|
import {
|
|
|
|
|
|
Button,
|
|
|
|
|
|
ChannelType,
|
|
|
|
|
|
Command,
|
2026-02-20 21:03:19 -06:00
|
|
|
|
Container,
|
2026-01-15 01:13:36 -06:00
|
|
|
|
Row,
|
2026-02-20 21:03:19 -06:00
|
|
|
|
StringSelectMenu,
|
|
|
|
|
|
TextDisplay,
|
2026-01-15 01:13:36 -06:00
|
|
|
|
type AutocompleteInteraction,
|
|
|
|
|
|
type ButtonInteraction,
|
|
|
|
|
|
type CommandInteraction,
|
|
|
|
|
|
type CommandOptions,
|
|
|
|
|
|
type ComponentData,
|
2026-02-20 21:03:19 -06:00
|
|
|
|
type StringSelectMenuInteraction,
|
2026-01-15 01:13:36 -06:00
|
|
|
|
} from "@buape/carbon";
|
|
|
|
|
|
import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10";
|
2026-02-18 01:34:35 +00:00
|
|
|
|
import { resolveHumanDelayConfig } from "../../agents/identity.js";
|
|
|
|
|
|
import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
|
import type {
|
|
|
|
|
|
ChatCommandDefinition,
|
|
|
|
|
|
CommandArgDefinition,
|
|
|
|
|
|
CommandArgValues,
|
|
|
|
|
|
CommandArgs,
|
|
|
|
|
|
NativeCommandSpec,
|
|
|
|
|
|
} from "../../auto-reply/commands-registry.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";
|
2026-02-20 21:03:19 -06:00
|
|
|
|
import { resolveStoredModelOverride } from "../../auto-reply/reply/model-selection.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
|
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
2026-02-18 01:34:35 +00:00
|
|
|
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
|
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-02-18 01:34:35 +00:00
|
|
|
|
import type { OpenClawConfig, loadConfig } from "../../config/config.js";
|
2026-02-20 21:03:19 -06:00
|
|
|
|
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
|
|
|
|
|
import { logVerbose } from "../../globals.js";
|
2026-02-21 17:44:00 -05:00
|
|
|
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
2026-02-15 10:53:45 -05:00
|
|
|
|
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.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";
|
2026-02-21 16:14:55 +01:00
|
|
|
|
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
2026-02-03 23:02:28 -08:00
|
|
|
|
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
|
2026-02-16 01:52:30 +00:00
|
|
|
|
import { chunkItems } from "../../utils/chunk-items.js";
|
2026-02-20 21:03:19 -06:00
|
|
|
|
import { withTimeout } from "../../utils/with-timeout.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,
|
2026-02-16 01:55:31 +00:00
|
|
|
|
resolveDiscordMemberAccessState,
|
2026-02-04 23:34:08 -08:00
|
|
|
|
resolveDiscordOwnerAllowFrom,
|
2026-01-14 01:08:15 +00:00
|
|
|
|
} from "./allow-list.js";
|
2026-01-17 15:26:59 -06:00
|
|
|
|
import { resolveDiscordChannelInfo } from "./message-utils.js";
|
2026-02-20 21:03:19 -06:00
|
|
|
|
import {
|
|
|
|
|
|
readDiscordModelPickerRecentModels,
|
|
|
|
|
|
recordDiscordModelPickerRecentModel,
|
|
|
|
|
|
type DiscordModelPickerPreferenceScope,
|
|
|
|
|
|
} from "./model-picker-preferences.js";
|
|
|
|
|
|
import {
|
|
|
|
|
|
DISCORD_MODEL_PICKER_CUSTOM_ID_KEY,
|
|
|
|
|
|
loadDiscordModelPickerData,
|
|
|
|
|
|
parseDiscordModelPickerData,
|
|
|
|
|
|
renderDiscordModelPickerModelsView,
|
|
|
|
|
|
renderDiscordModelPickerProvidersView,
|
|
|
|
|
|
renderDiscordModelPickerRecentsView,
|
|
|
|
|
|
toDiscordModelPickerMessagePayload,
|
|
|
|
|
|
type DiscordModelPickerCommandContext,
|
|
|
|
|
|
} from "./model-picker.js";
|
2026-02-01 10:41:31 +09:00
|
|
|
|
import { resolveDiscordSenderIdentity } from "./sender-identity.js";
|
2026-02-21 16:14:55 +01:00
|
|
|
|
import type { ThreadBindingManager } from "./thread-bindings.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-02-21 17:44:00 -05:00
|
|
|
|
const log = createSubsystemLogger("discord/native-command");
|
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;
|
2026-01-31 16:19:20 +09:00
|
|
|
|
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 {
|
2026-01-31 16:19:20 +09:00
|
|
|
|
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") {
|
2026-01-16 01:52:34 +00:00
|
|
|
|
value = interaction.options.getNumber(definition.name) ?? null;
|
2026-01-15 01:13:36 -06:00
|
|
|
|
} else if (definition.type === "boolean") {
|
2026-01-16 01:52:34 +00:00
|
|
|
|
value = interaction.options.getBoolean(definition.name) ?? null;
|
2026-01-15 01:13:36 -06:00
|
|
|
|
} else {
|
2026-01-16 01:52:34 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 17:23:30 +00:00
|
|
|
|
function isDiscordUnknownInteraction(error: unknown): boolean {
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!error || typeof error !== "object") {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-01-17 17:23:30 +00:00
|
|
|
|
const err = error as {
|
|
|
|
|
|
discordCode?: number;
|
|
|
|
|
|
status?: number;
|
|
|
|
|
|
message?: string;
|
|
|
|
|
|
rawBody?: { code?: number; message?: string };
|
|
|
|
|
|
};
|
2026-01-31 16:19:20 +09:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-01-17 17:23:30 +00:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function safeDiscordInteractionCall<T>(
|
|
|
|
|
|
label: string,
|
|
|
|
|
|
fn: () => Promise<T>,
|
|
|
|
|
|
): Promise<T | null> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await fn();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (isDiscordUnknownInteraction(error)) {
|
2026-02-20 21:03:19 -06:00
|
|
|
|
logVerbose(`discord: ${label} skipped (interaction expired)`);
|
2026-01-17 17:23:30 +00:00
|
|
|
|
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 {
|
2026-01-31 16:19:20 +09:00
|
|
|
|
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);
|
2026-01-31 16:19:20 +09:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 17:23:30 +00:00
|
|
|
|
type DiscordCommandArgContext = {
|
|
|
|
|
|
cfg: ReturnType<typeof loadConfig>;
|
|
|
|
|
|
discordConfig: DiscordConfig;
|
|
|
|
|
|
accountId: string;
|
|
|
|
|
|
sessionPrefix: string;
|
2026-02-21 16:14:55 +01:00
|
|
|
|
threadBindings: ThreadBindingManager;
|
2026-01-17 17:23:30 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-20 21:03:19 -06:00
|
|
|
|
type DiscordModelPickerContext = DiscordCommandArgContext;
|
|
|
|
|
|
|
|
|
|
|
|
function resolveDiscordModelPickerCommandContext(
|
|
|
|
|
|
command: ChatCommandDefinition,
|
|
|
|
|
|
): DiscordModelPickerCommandContext | null {
|
|
|
|
|
|
const normalized = (command.nativeName ?? command.key).trim().toLowerCase();
|
|
|
|
|
|
if (normalized === "model" || normalized === "models") {
|
|
|
|
|
|
return normalized;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveCommandArgStringValue(args: CommandArgs | undefined, key: string): string {
|
|
|
|
|
|
const value = args?.values?.[key];
|
|
|
|
|
|
if (typeof value !== "string") {
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
return value.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function shouldOpenDiscordModelPickerFromCommand(params: {
|
|
|
|
|
|
command: ChatCommandDefinition;
|
|
|
|
|
|
commandArgs?: CommandArgs;
|
|
|
|
|
|
}): DiscordModelPickerCommandContext | null {
|
|
|
|
|
|
const context = resolveDiscordModelPickerCommandContext(params.command);
|
|
|
|
|
|
if (!context) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const serializedArgs = serializeCommandArgs(params.command, params.commandArgs)?.trim() ?? "";
|
|
|
|
|
|
if (context === "model") {
|
|
|
|
|
|
const modelValue = resolveCommandArgStringValue(params.commandArgs, "model");
|
|
|
|
|
|
return !modelValue && !serializedArgs ? context : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return serializedArgs ? null : context;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildDiscordModelPickerCurrentModel(
|
|
|
|
|
|
defaultProvider: string,
|
|
|
|
|
|
defaultModel: string,
|
|
|
|
|
|
): string {
|
|
|
|
|
|
return `${defaultProvider}/${defaultModel}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildDiscordModelPickerAllowedModelRefs(
|
|
|
|
|
|
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>,
|
|
|
|
|
|
): Set<string> {
|
|
|
|
|
|
const out = new Set<string>();
|
|
|
|
|
|
for (const provider of data.providers) {
|
|
|
|
|
|
const models = data.byProvider.get(provider);
|
|
|
|
|
|
if (!models) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const model of models) {
|
|
|
|
|
|
out.add(`${provider}/${model}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveDiscordModelPickerPreferenceScope(params: {
|
|
|
|
|
|
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
|
|
|
|
|
accountId: string;
|
|
|
|
|
|
userId: string;
|
|
|
|
|
|
}): DiscordModelPickerPreferenceScope {
|
|
|
|
|
|
return {
|
|
|
|
|
|
accountId: params.accountId,
|
|
|
|
|
|
guildId: params.interaction.guild?.id ?? undefined,
|
|
|
|
|
|
userId: params.userId,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildDiscordModelPickerNoticePayload(message: string): { components: Container[] } {
|
|
|
|
|
|
return {
|
|
|
|
|
|
components: [new Container([new TextDisplay(message)])],
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function resolveDiscordModelPickerRoute(params: {
|
|
|
|
|
|
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
|
|
|
|
|
cfg: ReturnType<typeof loadConfig>;
|
|
|
|
|
|
accountId: string;
|
2026-02-21 16:14:55 +01:00
|
|
|
|
threadBindings: ThreadBindingManager;
|
2026-02-20 21:03:19 -06:00
|
|
|
|
}) {
|
|
|
|
|
|
const { interaction, cfg, accountId } = params;
|
|
|
|
|
|
const channel = interaction.channel;
|
|
|
|
|
|
const channelType = channel?.type;
|
|
|
|
|
|
const isDirectMessage = channelType === ChannelType.DM;
|
|
|
|
|
|
const isGroupDm = channelType === ChannelType.GroupDM;
|
|
|
|
|
|
const isThreadChannel =
|
|
|
|
|
|
channelType === ChannelType.PublicThread ||
|
|
|
|
|
|
channelType === ChannelType.PrivateThread ||
|
|
|
|
|
|
channelType === ChannelType.AnnouncementThread;
|
|
|
|
|
|
const rawChannelId = channel?.id ?? "unknown";
|
|
|
|
|
|
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
|
|
|
|
|
|
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
|
|
|
|
|
|
: [];
|
|
|
|
|
|
let threadParentId: string | undefined;
|
|
|
|
|
|
if (interaction.guild && channel && isThreadChannel && rawChannelId) {
|
|
|
|
|
|
const channelInfo = await resolveDiscordChannelInfo(interaction.client, rawChannelId);
|
|
|
|
|
|
const parentInfo = await resolveDiscordThreadParentInfo({
|
|
|
|
|
|
client: interaction.client,
|
|
|
|
|
|
threadChannel: {
|
|
|
|
|
|
id: rawChannelId,
|
|
|
|
|
|
name: "name" in channel ? (channel.name as string | undefined) : undefined,
|
|
|
|
|
|
parentId: "parentId" in channel ? (channel.parentId ?? undefined) : undefined,
|
|
|
|
|
|
parent: undefined,
|
|
|
|
|
|
},
|
|
|
|
|
|
channelInfo,
|
|
|
|
|
|
});
|
|
|
|
|
|
threadParentId = parentInfo.id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-21 16:14:55 +01:00
|
|
|
|
const route = resolveAgentRoute({
|
2026-02-20 21:03:19 -06:00
|
|
|
|
cfg,
|
|
|
|
|
|
channel: "discord",
|
|
|
|
|
|
accountId,
|
|
|
|
|
|
guildId: interaction.guild?.id ?? undefined,
|
|
|
|
|
|
memberRoleIds,
|
|
|
|
|
|
peer: {
|
|
|
|
|
|
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
|
|
|
|
|
|
id: isDirectMessage ? (interaction.user?.id ?? rawChannelId) : rawChannelId,
|
|
|
|
|
|
},
|
|
|
|
|
|
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
|
|
|
|
|
|
});
|
2026-02-21 16:14:55 +01:00
|
|
|
|
|
|
|
|
|
|
const threadBinding = isThreadChannel
|
|
|
|
|
|
? params.threadBindings.getByThreadId(rawChannelId)
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
|
|
|
|
|
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
|
|
|
|
|
|
return boundSessionKey
|
|
|
|
|
|
? {
|
|
|
|
|
|
...route,
|
|
|
|
|
|
sessionKey: boundSessionKey,
|
|
|
|
|
|
agentId: boundAgentId ?? route.agentId,
|
|
|
|
|
|
}
|
|
|
|
|
|
: route;
|
2026-02-20 21:03:19 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveDiscordModelPickerCurrentModel(params: {
|
|
|
|
|
|
cfg: ReturnType<typeof loadConfig>;
|
|
|
|
|
|
route: ReturnType<typeof resolveAgentRoute>;
|
|
|
|
|
|
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>;
|
|
|
|
|
|
}): string {
|
|
|
|
|
|
const fallback = buildDiscordModelPickerCurrentModel(
|
|
|
|
|
|
params.data.resolvedDefault.provider,
|
|
|
|
|
|
params.data.resolvedDefault.model,
|
|
|
|
|
|
);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const storePath = resolveStorePath(params.cfg.session?.store, {
|
|
|
|
|
|
agentId: params.route.agentId,
|
|
|
|
|
|
});
|
|
|
|
|
|
const sessionStore = loadSessionStore(storePath, { skipCache: true });
|
|
|
|
|
|
const sessionEntry = sessionStore[params.route.sessionKey];
|
|
|
|
|
|
const override = resolveStoredModelOverride({
|
|
|
|
|
|
sessionEntry,
|
|
|
|
|
|
sessionStore,
|
|
|
|
|
|
sessionKey: params.route.sessionKey,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!override?.model) {
|
|
|
|
|
|
return fallback;
|
|
|
|
|
|
}
|
|
|
|
|
|
const provider = (override.provider || params.data.resolvedDefault.provider).trim();
|
|
|
|
|
|
if (!provider) {
|
|
|
|
|
|
return fallback;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${provider}/${override.model}`;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return fallback;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function replyWithDiscordModelPickerProviders(params: {
|
|
|
|
|
|
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
|
|
|
|
|
cfg: ReturnType<typeof loadConfig>;
|
|
|
|
|
|
command: DiscordModelPickerCommandContext;
|
|
|
|
|
|
userId: string;
|
|
|
|
|
|
accountId: string;
|
2026-02-21 16:14:55 +01:00
|
|
|
|
threadBindings: ThreadBindingManager;
|
2026-02-20 21:03:19 -06:00
|
|
|
|
preferFollowUp: boolean;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const data = await loadDiscordModelPickerData(params.cfg);
|
|
|
|
|
|
const route = await resolveDiscordModelPickerRoute({
|
|
|
|
|
|
interaction: params.interaction,
|
|
|
|
|
|
cfg: params.cfg,
|
|
|
|
|
|
accountId: params.accountId,
|
2026-02-21 16:14:55 +01:00
|
|
|
|
threadBindings: params.threadBindings,
|
2026-02-20 21:03:19 -06:00
|
|
|
|
});
|
|
|
|
|
|
const currentModel = resolveDiscordModelPickerCurrentModel({
|
|
|
|
|
|
cfg: params.cfg,
|
|
|
|
|
|
route,
|
|
|
|
|
|
data,
|
|
|
|
|
|
});
|
|
|
|
|
|
const quickModels = await readDiscordModelPickerRecentModels({
|
|
|
|
|
|
scope: resolveDiscordModelPickerPreferenceScope({
|
|
|
|
|
|
interaction: params.interaction,
|
|
|
|
|
|
accountId: params.accountId,
|
|
|
|
|
|
userId: params.userId,
|
|
|
|
|
|
}),
|
|
|
|
|
|
allowedModelRefs: buildDiscordModelPickerAllowedModelRefs(data),
|
|
|
|
|
|
limit: 5,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const rendered = renderDiscordModelPickerModelsView({
|
|
|
|
|
|
command: params.command,
|
|
|
|
|
|
userId: params.userId,
|
|
|
|
|
|
data,
|
|
|
|
|
|
provider: splitDiscordModelRef(currentModel ?? "")?.provider ?? data.resolvedDefault.provider,
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
providerPage: 1,
|
|
|
|
|
|
currentModel,
|
|
|
|
|
|
quickModels,
|
|
|
|
|
|
});
|
|
|
|
|
|
const payload = {
|
|
|
|
|
|
...toDiscordModelPickerMessagePayload(rendered),
|
|
|
|
|
|
ephemeral: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker reply", async () => {
|
|
|
|
|
|
if (params.preferFollowUp) {
|
|
|
|
|
|
await params.interaction.followUp(payload);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await params.interaction.reply(payload);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveModelPickerSelectionValue(
|
|
|
|
|
|
interaction: ButtonInteraction | StringSelectMenuInteraction,
|
|
|
|
|
|
): string | null {
|
|
|
|
|
|
const rawValues = (interaction as { values?: string[] }).values;
|
|
|
|
|
|
if (!Array.isArray(rawValues) || rawValues.length === 0) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const first = rawValues[0];
|
|
|
|
|
|
if (typeof first !== "string") {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const trimmed = first.trim();
|
|
|
|
|
|
return trimmed || null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildDiscordModelPickerSelectionCommand(params: {
|
|
|
|
|
|
modelRef: string;
|
|
|
|
|
|
}): { command: ChatCommandDefinition; args: CommandArgs; prompt: string } | null {
|
|
|
|
|
|
const commandDefinition =
|
|
|
|
|
|
findCommandByNativeName("model", "discord") ??
|
|
|
|
|
|
listChatCommands().find((entry) => entry.key === "model");
|
|
|
|
|
|
if (!commandDefinition) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const commandArgs: CommandArgs = {
|
|
|
|
|
|
values: {
|
|
|
|
|
|
model: params.modelRef,
|
|
|
|
|
|
},
|
|
|
|
|
|
raw: params.modelRef,
|
|
|
|
|
|
};
|
|
|
|
|
|
return {
|
|
|
|
|
|
command: commandDefinition,
|
|
|
|
|
|
args: commandArgs,
|
|
|
|
|
|
prompt: buildCommandTextFromArgs(commandDefinition, commandArgs),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function listDiscordModelPickerProviderModels(
|
|
|
|
|
|
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>,
|
|
|
|
|
|
provider: string,
|
|
|
|
|
|
): string[] {
|
|
|
|
|
|
const modelSet = data.byProvider.get(provider);
|
|
|
|
|
|
if (!modelSet) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
return [...modelSet].toSorted();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveDiscordModelPickerModelIndex(params: {
|
|
|
|
|
|
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>;
|
|
|
|
|
|
provider: string;
|
|
|
|
|
|
model: string;
|
|
|
|
|
|
}): number | null {
|
|
|
|
|
|
const models = listDiscordModelPickerProviderModels(params.data, params.provider);
|
|
|
|
|
|
if (!models.length) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const index = models.indexOf(params.model);
|
|
|
|
|
|
if (index < 0) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return index + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveDiscordModelPickerModelByIndex(params: {
|
|
|
|
|
|
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>;
|
|
|
|
|
|
provider: string;
|
|
|
|
|
|
modelIndex?: number;
|
|
|
|
|
|
}): string | null {
|
|
|
|
|
|
if (!params.modelIndex || params.modelIndex < 1) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const models = listDiscordModelPickerProviderModels(params.data, params.provider);
|
|
|
|
|
|
if (!models.length) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return models[params.modelIndex - 1] ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function splitDiscordModelRef(modelRef: string): { provider: string; model: string } | null {
|
|
|
|
|
|
const trimmed = modelRef.trim();
|
|
|
|
|
|
const slashIndex = trimmed.indexOf("/");
|
|
|
|
|
|
if (slashIndex <= 0 || slashIndex >= trimmed.length - 1) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const provider = trimmed.slice(0, slashIndex).trim();
|
|
|
|
|
|
const model = trimmed.slice(slashIndex + 1).trim();
|
|
|
|
|
|
if (!provider || !model) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return { provider, model };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleDiscordModelPickerInteraction(
|
|
|
|
|
|
interaction: ButtonInteraction | StringSelectMenuInteraction,
|
|
|
|
|
|
data: ComponentData,
|
|
|
|
|
|
ctx: DiscordModelPickerContext,
|
|
|
|
|
|
) {
|
|
|
|
|
|
const parsed = parseDiscordModelPickerData(data);
|
|
|
|
|
|
if (!parsed) {
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker update", () =>
|
|
|
|
|
|
interaction.update(
|
|
|
|
|
|
buildDiscordModelPickerNoticePayload(
|
|
|
|
|
|
"Sorry, that model picker interaction is no longer available.",
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (interaction.user?.id && interaction.user.id !== parsed.userId) {
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker ack", () => interaction.acknowledge());
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const pickerData = await loadDiscordModelPickerData(ctx.cfg);
|
|
|
|
|
|
const route = await resolveDiscordModelPickerRoute({
|
|
|
|
|
|
interaction,
|
|
|
|
|
|
cfg: ctx.cfg,
|
|
|
|
|
|
accountId: ctx.accountId,
|
2026-02-21 16:14:55 +01:00
|
|
|
|
threadBindings: ctx.threadBindings,
|
2026-02-20 21:03:19 -06:00
|
|
|
|
});
|
|
|
|
|
|
const currentModelRef = resolveDiscordModelPickerCurrentModel({
|
|
|
|
|
|
cfg: ctx.cfg,
|
|
|
|
|
|
route,
|
|
|
|
|
|
data: pickerData,
|
|
|
|
|
|
});
|
|
|
|
|
|
const allowedModelRefs = buildDiscordModelPickerAllowedModelRefs(pickerData);
|
|
|
|
|
|
const preferenceScope = resolveDiscordModelPickerPreferenceScope({
|
|
|
|
|
|
interaction,
|
|
|
|
|
|
accountId: ctx.accountId,
|
|
|
|
|
|
userId: parsed.userId,
|
|
|
|
|
|
});
|
|
|
|
|
|
const quickModels = await readDiscordModelPickerRecentModels({
|
|
|
|
|
|
scope: preferenceScope,
|
|
|
|
|
|
allowedModelRefs,
|
|
|
|
|
|
limit: 5,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (parsed.action === "recents") {
|
|
|
|
|
|
const rendered = renderDiscordModelPickerRecentsView({
|
|
|
|
|
|
command: parsed.command,
|
|
|
|
|
|
userId: parsed.userId,
|
|
|
|
|
|
data: pickerData,
|
|
|
|
|
|
quickModels,
|
|
|
|
|
|
currentModel: currentModelRef,
|
|
|
|
|
|
provider: parsed.provider,
|
|
|
|
|
|
page: parsed.page,
|
|
|
|
|
|
providerPage: parsed.providerPage,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker update", () =>
|
|
|
|
|
|
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (parsed.action === "back" && parsed.view === "providers") {
|
|
|
|
|
|
const rendered = renderDiscordModelPickerProvidersView({
|
|
|
|
|
|
command: parsed.command,
|
|
|
|
|
|
userId: parsed.userId,
|
|
|
|
|
|
data: pickerData,
|
|
|
|
|
|
page: parsed.page,
|
|
|
|
|
|
currentModel: currentModelRef,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker update", () =>
|
|
|
|
|
|
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (parsed.action === "back" && parsed.view === "models") {
|
|
|
|
|
|
const provider =
|
|
|
|
|
|
parsed.provider ??
|
|
|
|
|
|
splitDiscordModelRef(currentModelRef ?? "")?.provider ??
|
|
|
|
|
|
pickerData.resolvedDefault.provider;
|
|
|
|
|
|
|
|
|
|
|
|
const rendered = renderDiscordModelPickerModelsView({
|
|
|
|
|
|
command: parsed.command,
|
|
|
|
|
|
userId: parsed.userId,
|
|
|
|
|
|
data: pickerData,
|
|
|
|
|
|
provider,
|
|
|
|
|
|
page: parsed.page ?? 1,
|
|
|
|
|
|
providerPage: parsed.providerPage ?? 1,
|
|
|
|
|
|
currentModel: currentModelRef,
|
|
|
|
|
|
quickModels,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker update", () =>
|
|
|
|
|
|
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (parsed.action === "provider") {
|
|
|
|
|
|
const selectedProvider = resolveModelPickerSelectionValue(interaction) ?? parsed.provider;
|
|
|
|
|
|
if (!selectedProvider || !pickerData.byProvider.has(selectedProvider)) {
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker update", () =>
|
|
|
|
|
|
interaction.update(
|
|
|
|
|
|
buildDiscordModelPickerNoticePayload("Sorry, that provider isn't available anymore."),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const rendered = renderDiscordModelPickerModelsView({
|
|
|
|
|
|
command: parsed.command,
|
|
|
|
|
|
userId: parsed.userId,
|
|
|
|
|
|
data: pickerData,
|
|
|
|
|
|
provider: selectedProvider,
|
|
|
|
|
|
page: 1,
|
|
|
|
|
|
providerPage: parsed.providerPage ?? parsed.page,
|
|
|
|
|
|
currentModel: currentModelRef,
|
|
|
|
|
|
quickModels,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker update", () =>
|
|
|
|
|
|
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (parsed.action === "model") {
|
|
|
|
|
|
const selectedModel = resolveModelPickerSelectionValue(interaction);
|
|
|
|
|
|
const provider = parsed.provider;
|
|
|
|
|
|
if (!provider || !selectedModel) {
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker update", () =>
|
|
|
|
|
|
interaction.update(
|
|
|
|
|
|
buildDiscordModelPickerNoticePayload("Sorry, I couldn't read that model selection."),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const modelIndex = resolveDiscordModelPickerModelIndex({
|
|
|
|
|
|
data: pickerData,
|
|
|
|
|
|
provider,
|
|
|
|
|
|
model: selectedModel,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!modelIndex) {
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker update", () =>
|
|
|
|
|
|
interaction.update(
|
|
|
|
|
|
buildDiscordModelPickerNoticePayload("Sorry, that model isn't available anymore."),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const modelRef = `${provider}/${selectedModel}`;
|
|
|
|
|
|
const rendered = renderDiscordModelPickerModelsView({
|
|
|
|
|
|
command: parsed.command,
|
|
|
|
|
|
userId: parsed.userId,
|
|
|
|
|
|
data: pickerData,
|
|
|
|
|
|
provider,
|
|
|
|
|
|
page: parsed.page,
|
|
|
|
|
|
providerPage: parsed.providerPage ?? 1,
|
|
|
|
|
|
currentModel: currentModelRef,
|
|
|
|
|
|
pendingModel: modelRef,
|
|
|
|
|
|
pendingModelIndex: modelIndex,
|
|
|
|
|
|
quickModels,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker update", () =>
|
|
|
|
|
|
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (parsed.action === "submit" || parsed.action === "reset" || parsed.action === "quick") {
|
|
|
|
|
|
let modelRef: string | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
if (parsed.action === "reset") {
|
|
|
|
|
|
modelRef = `${pickerData.resolvedDefault.provider}/${pickerData.resolvedDefault.model}`;
|
|
|
|
|
|
} else if (parsed.action === "quick") {
|
|
|
|
|
|
const slot = parsed.recentSlot ?? 0;
|
|
|
|
|
|
modelRef = slot >= 1 ? (quickModels[slot - 1] ?? null) : null;
|
|
|
|
|
|
} else if (parsed.view === "recents") {
|
|
|
|
|
|
const defaultModelRef = `${pickerData.resolvedDefault.provider}/${pickerData.resolvedDefault.model}`;
|
|
|
|
|
|
const dedupedRecents = quickModels.filter((ref) => ref !== defaultModelRef);
|
|
|
|
|
|
const slot = parsed.recentSlot ?? 0;
|
|
|
|
|
|
if (slot === 1) {
|
|
|
|
|
|
modelRef = defaultModelRef;
|
|
|
|
|
|
} else if (slot >= 2) {
|
|
|
|
|
|
modelRef = dedupedRecents[slot - 2] ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const provider = parsed.provider;
|
|
|
|
|
|
const selectedModel = resolveDiscordModelPickerModelByIndex({
|
|
|
|
|
|
data: pickerData,
|
|
|
|
|
|
provider: provider ?? "",
|
|
|
|
|
|
modelIndex: parsed.modelIndex,
|
|
|
|
|
|
});
|
|
|
|
|
|
modelRef = provider && selectedModel ? `${provider}/${selectedModel}` : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const parsedModelRef = modelRef ? splitDiscordModelRef(modelRef) : null;
|
|
|
|
|
|
if (
|
|
|
|
|
|
!parsedModelRef ||
|
|
|
|
|
|
!pickerData.byProvider.get(parsedModelRef.provider)?.has(parsedModelRef.model)
|
|
|
|
|
|
) {
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker update", () =>
|
|
|
|
|
|
interaction.update(
|
|
|
|
|
|
buildDiscordModelPickerNoticePayload(
|
|
|
|
|
|
"That selection expired. Please choose a model again.",
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const resolvedModelRef = `${parsedModelRef.provider}/${parsedModelRef.model}`;
|
|
|
|
|
|
|
|
|
|
|
|
const selectionCommand = buildDiscordModelPickerSelectionCommand({
|
|
|
|
|
|
modelRef: resolvedModelRef,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!selectionCommand) {
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker update", () =>
|
|
|
|
|
|
interaction.update(
|
|
|
|
|
|
buildDiscordModelPickerNoticePayload("Sorry, /model is unavailable right now."),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateResult = await safeDiscordInteractionCall("model picker update", () =>
|
|
|
|
|
|
interaction.update(
|
|
|
|
|
|
buildDiscordModelPickerNoticePayload(`Applying model change to ${resolvedModelRef}...`),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (updateResult === null) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await withTimeout(
|
|
|
|
|
|
dispatchDiscordCommandInteraction({
|
|
|
|
|
|
interaction,
|
|
|
|
|
|
prompt: selectionCommand.prompt,
|
|
|
|
|
|
command: selectionCommand.command,
|
|
|
|
|
|
commandArgs: selectionCommand.args,
|
|
|
|
|
|
cfg: ctx.cfg,
|
|
|
|
|
|
discordConfig: ctx.discordConfig,
|
|
|
|
|
|
accountId: ctx.accountId,
|
|
|
|
|
|
sessionPrefix: ctx.sessionPrefix,
|
|
|
|
|
|
preferFollowUp: true,
|
2026-02-21 16:14:55 +01:00
|
|
|
|
threadBindings: ctx.threadBindings,
|
2026-02-20 21:03:19 -06:00
|
|
|
|
suppressReplies: true,
|
|
|
|
|
|
}),
|
|
|
|
|
|
12000,
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (error instanceof Error && error.message === "timeout") {
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker follow-up", () =>
|
|
|
|
|
|
interaction.followUp({
|
|
|
|
|
|
...buildDiscordModelPickerNoticePayload(
|
|
|
|
|
|
`⏳ Model change to ${resolvedModelRef} is still processing. Check /status in a few seconds.`,
|
|
|
|
|
|
),
|
|
|
|
|
|
ephemeral: true,
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker follow-up", () =>
|
|
|
|
|
|
interaction.followUp({
|
|
|
|
|
|
...buildDiscordModelPickerNoticePayload(
|
|
|
|
|
|
`❌ Failed to apply ${resolvedModelRef}. Try /model ${resolvedModelRef} directly.`,
|
|
|
|
|
|
),
|
|
|
|
|
|
ephemeral: true,
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const effectiveModelRef = resolveDiscordModelPickerCurrentModel({
|
|
|
|
|
|
cfg: ctx.cfg,
|
|
|
|
|
|
route,
|
|
|
|
|
|
data: pickerData,
|
|
|
|
|
|
});
|
|
|
|
|
|
const persisted = effectiveModelRef === resolvedModelRef;
|
|
|
|
|
|
|
|
|
|
|
|
if (!persisted) {
|
|
|
|
|
|
logVerbose(
|
|
|
|
|
|
`discord: model picker override mismatch — expected ${resolvedModelRef} but read ${effectiveModelRef} from session key ${route.sessionKey}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (persisted) {
|
|
|
|
|
|
await recordDiscordModelPickerRecentModel({
|
|
|
|
|
|
scope: preferenceScope,
|
|
|
|
|
|
modelRef: resolvedModelRef,
|
|
|
|
|
|
limit: 5,
|
|
|
|
|
|
}).catch(() => undefined);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker follow-up", () =>
|
|
|
|
|
|
interaction.followUp({
|
|
|
|
|
|
...buildDiscordModelPickerNoticePayload(
|
|
|
|
|
|
persisted
|
|
|
|
|
|
? `✅ Model set to ${resolvedModelRef}.`
|
|
|
|
|
|
: `⚠️ Tried to set ${resolvedModelRef}, but current model is ${effectiveModelRef}.`,
|
|
|
|
|
|
),
|
|
|
|
|
|
ephemeral: true,
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (parsed.action === "cancel") {
|
|
|
|
|
|
const displayModel = currentModelRef ?? "default";
|
|
|
|
|
|
await safeDiscordInteractionCall("model picker update", () =>
|
|
|
|
|
|
interaction.update(buildDiscordModelPickerNoticePayload(`ℹ️ Model kept as ${displayModel}.`)),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 17:23:30 +00:00
|
|
|
|
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") ??
|
2026-01-17 17:23:30 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-20 21:03:19 -06:00
|
|
|
|
const argUpdateResult = await safeDiscordInteractionCall("command arg update", () =>
|
2026-01-17 17:23:30 +00:00
|
|
|
|
interaction.update({
|
|
|
|
|
|
content: `✅ Selected ${parsed.value}.`,
|
|
|
|
|
|
components: [],
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
2026-02-20 21:03:19 -06:00
|
|
|
|
if (argUpdateResult === null) {
|
2026-01-31 16:19:20 +09:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-17 17:23:30 +00:00
|
|
|
|
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-02-21 16:14:55 +01:00
|
|
|
|
threadBindings: ctx.threadBindings,
|
2026-01-17 17:23:30 +00:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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;
|
2026-02-21 16:14:55 +01:00
|
|
|
|
private threadBindings: ThreadBindingManager;
|
2026-01-15 01:13:36 -06:00
|
|
|
|
|
|
|
|
|
|
constructor(params: {
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
customId: string;
|
|
|
|
|
|
cfg: ReturnType<typeof loadConfig>;
|
|
|
|
|
|
discordConfig: DiscordConfig;
|
|
|
|
|
|
accountId: string;
|
|
|
|
|
|
sessionPrefix: string;
|
2026-02-21 16:14:55 +01:00
|
|
|
|
threadBindings: ThreadBindingManager;
|
2026-01-15 01:13:36 -06:00
|
|
|
|
}) {
|
|
|
|
|
|
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;
|
2026-02-21 16:14:55 +01:00
|
|
|
|
this.threadBindings = params.threadBindings;
|
2026-01-15 01:13:36 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async run(interaction: ButtonInteraction, data: ComponentData) {
|
2026-01-17 17:23:30 +00:00
|
|
|
|
await handleDiscordCommandArgInteraction(interaction, data, {
|
2026-01-15 01:13:36 -06:00
|
|
|
|
cfg: this.cfg,
|
|
|
|
|
|
discordConfig: this.discordConfig,
|
|
|
|
|
|
accountId: this.accountId,
|
|
|
|
|
|
sessionPrefix: this.sessionPrefix,
|
2026-02-21 16:14:55 +01:00
|
|
|
|
threadBindings: this.threadBindings,
|
2026-01-15 01:13:36 -06:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 17:23:30 +00:00
|
|
|
|
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-02-20 21:03:19 -06:00
|
|
|
|
class DiscordModelPickerFallbackButton extends Button {
|
|
|
|
|
|
label = DISCORD_MODEL_PICKER_CUSTOM_ID_KEY;
|
|
|
|
|
|
customId = `${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:seed=btn`;
|
|
|
|
|
|
private ctx: DiscordModelPickerContext;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(ctx: DiscordModelPickerContext) {
|
|
|
|
|
|
super();
|
|
|
|
|
|
this.ctx = ctx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async run(interaction: ButtonInteraction, data: ComponentData) {
|
|
|
|
|
|
await handleDiscordModelPickerInteraction(interaction, data, this.ctx);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class DiscordModelPickerFallbackSelect extends StringSelectMenu {
|
|
|
|
|
|
customId = `${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:seed=sel`;
|
|
|
|
|
|
options = [];
|
|
|
|
|
|
private ctx: DiscordModelPickerContext;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(ctx: DiscordModelPickerContext) {
|
|
|
|
|
|
super();
|
|
|
|
|
|
this.ctx = ctx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async run(interaction: StringSelectMenuInteraction, data: ComponentData) {
|
|
|
|
|
|
await handleDiscordModelPickerInteraction(interaction, data, this.ctx);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function createDiscordModelPickerFallbackButton(params: DiscordModelPickerContext): Button {
|
|
|
|
|
|
return new DiscordModelPickerFallbackButton(params);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function createDiscordModelPickerFallbackSelect(
|
|
|
|
|
|
params: DiscordModelPickerContext,
|
|
|
|
|
|
): StringSelectMenu {
|
|
|
|
|
|
return new DiscordModelPickerFallbackSelect(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;
|
2026-02-21 16:14:55 +01:00
|
|
|
|
threadBindings: ThreadBindingManager;
|
2026-01-15 01:13:36 -06:00
|
|
|
|
}): { 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,
|
2026-02-21 16:14:55 +01:00
|
|
|
|
threadBindings: params.threadBindings,
|
2026-01-15 01:13:36 -06:00
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
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;
|
2026-02-21 16:14:55 +01:00
|
|
|
|
threadBindings: ThreadBindingManager;
|
2026-01-31 21:57:21 +09:00
|
|
|
|
}): Command {
|
2026-02-21 16:14:55 +01:00
|
|
|
|
const {
|
|
|
|
|
|
command,
|
|
|
|
|
|
cfg,
|
|
|
|
|
|
discordConfig,
|
|
|
|
|
|
accountId,
|
|
|
|
|
|
sessionPrefix,
|
|
|
|
|
|
ephemeralDefault,
|
|
|
|
|
|
threadBindings,
|
|
|
|
|
|
} = 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-31 21:57:21 +09:00
|
|
|
|
|
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-02-21 16:14:55 +01:00
|
|
|
|
threadBindings,
|
2026-01-14 01:08:15 +00:00
|
|
|
|
});
|
2026-01-15 01:13:36 -06:00
|
|
|
|
}
|
|
|
|
|
|
})();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function dispatchDiscordCommandInteraction(params: {
|
2026-02-20 21:03:19 -06:00
|
|
|
|
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
2026-01-15 01:13:36 -06:00
|
|
|
|
prompt: string;
|
|
|
|
|
|
command: ChatCommandDefinition;
|
|
|
|
|
|
commandArgs?: CommandArgs;
|
|
|
|
|
|
cfg: ReturnType<typeof loadConfig>;
|
|
|
|
|
|
discordConfig: DiscordConfig;
|
|
|
|
|
|
accountId: string;
|
|
|
|
|
|
sessionPrefix: string;
|
|
|
|
|
|
preferFollowUp: boolean;
|
2026-02-21 16:14:55 +01:00
|
|
|
|
threadBindings: ThreadBindingManager;
|
2026-02-20 21:03:19 -06:00
|
|
|
|
suppressReplies?: boolean;
|
2026-01-15 01:13:36 -06:00
|
|
|
|
}) {
|
|
|
|
|
|
const {
|
|
|
|
|
|
interaction,
|
|
|
|
|
|
prompt,
|
|
|
|
|
|
command,
|
|
|
|
|
|
commandArgs,
|
|
|
|
|
|
cfg,
|
|
|
|
|
|
discordConfig,
|
|
|
|
|
|
accountId,
|
|
|
|
|
|
sessionPrefix,
|
|
|
|
|
|
preferFollowUp,
|
2026-02-21 16:14:55 +01:00
|
|
|
|
threadBindings,
|
2026-02-20 21:03:19 -06:00
|
|
|
|
suppressReplies,
|
2026-01-15 01:13:36 -06:00
|
|
|
|
} = params;
|
|
|
|
|
|
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
|
|
|
|
|
|
const payload = {
|
|
|
|
|
|
content,
|
|
|
|
|
|
...(options?.ephemeral !== undefined ? { ephemeral: options.ephemeral } : {}),
|
|
|
|
|
|
};
|
2026-01-17 17:23:30 +00:00
|
|
|
|
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;
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!user) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-31 19:50:06 -06:00
|
|
|
|
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 ?? "";
|
2026-02-12 19:50:10 -06:00
|
|
|
|
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
|
|
|
|
|
|
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
|
|
|
|
|
|
: [];
|
2026-02-14 20:32:12 +01:00
|
|
|
|
const ownerAllowList = normalizeDiscordAllowList(
|
|
|
|
|
|
discordConfig?.allowFrom ?? discordConfig?.dm?.allowFrom ?? [],
|
|
|
|
|
|
["discord:", "user:", "pk:"],
|
|
|
|
|
|
);
|
2026-01-17 05:25:37 +00:00
|
|
|
|
const ownerOk =
|
|
|
|
|
|
ownerAllowList && user
|
|
|
|
|
|
? allowListMatches(ownerAllowList, {
|
2026-01-31 19:50:06 -06:00
|
|
|
|
id: sender.id,
|
|
|
|
|
|
name: sender.name,
|
|
|
|
|
|
tag: sender.tag,
|
2026-01-17 05:25:37 +00:00
|
|
|
|
})
|
|
|
|
|
|
: 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,
|
2026-01-18 01:08:42 +00:00
|
|
|
|
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,
|
2026-01-17 22:30:37 +00:00
|
|
|
|
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",
|
2026-01-15 11:53:13 -06:00
|
|
|
|
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;
|
2026-02-14 20:32:12 +01:00
|
|
|
|
const dmPolicy = discordConfig?.dmPolicy ?? discordConfig?.dm?.policy ?? "pairing";
|
2026-01-15 01:13:36 -06:00
|
|
|
|
let commandAuthorized = true;
|
|
|
|
|
|
if (isDirectMessage) {
|
|
|
|
|
|
if (!dmEnabled || dmPolicy === "disabled") {
|
|
|
|
|
|
await respond("Discord DMs are disabled.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (dmPolicy !== "open") {
|
2026-02-22 00:00:23 +01:00
|
|
|
|
const storeAllowFrom =
|
|
|
|
|
|
dmPolicy === "allowlist" ? [] : await readChannelAllowFromStore("discord").catch(() => []);
|
2026-02-14 20:32:12 +01:00
|
|
|
|
const effectiveAllowFrom = [
|
|
|
|
|
|
...(discordConfig?.allowFrom ?? discordConfig?.dm?.allowFrom ?? []),
|
|
|
|
|
|
...storeAllowFrom,
|
|
|
|
|
|
];
|
2026-01-31 19:50:06 -06:00
|
|
|
|
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
|
2026-01-15 01:13:36 -06:00
|
|
|
|
const permitted = allowList
|
|
|
|
|
|
? allowListMatches(allowList, {
|
2026-01-31 19:50:06 -06:00
|
|
|
|
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: {
|
2026-01-31 19:50:06 -06:00
|
|
|
|
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) {
|
2026-02-16 01:55:31 +00:00
|
|
|
|
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
|
|
|
|
|
|
channelConfig,
|
|
|
|
|
|
guildInfo,
|
2026-02-12 19:50:10 -06:00
|
|
|
|
memberRoleIds,
|
2026-02-16 01:55:31 +00:00
|
|
|
|
sender,
|
2026-02-12 19:50:10 -06:00
|
|
|
|
});
|
2026-01-17 06:49:17 +00:00
|
|
|
|
const authorizers = useAccessGroups
|
|
|
|
|
|
? [
|
|
|
|
|
|
{ configured: ownerAllowList != null, allowed: ownerOk },
|
2026-02-12 19:50:10 -06:00
|
|
|
|
{ configured: hasAccessRestrictions, allowed: memberAllowed },
|
2026-01-17 06:49:17 +00:00
|
|
|
|
]
|
2026-02-12 19:50:10 -06:00
|
|
|
|
: [{ configured: hasAccessRestrictions, allowed: memberAllowed }];
|
2026-01-17 06:49:17 +00:00
|
|
|
|
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
|
|
|
|
|
useAccessGroups,
|
|
|
|
|
|
authorizers,
|
|
|
|
|
|
modeWhenAccessGroupsOff: "configured",
|
|
|
|
|
|
});
|
2026-01-17 05:25:37 +00:00
|
|
|
|
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,
|
2026-02-21 16:14:55 +01:00
|
|
|
|
threadBindings,
|
2026-01-15 01:13:36 -06:00
|
|
|
|
});
|
|
|
|
|
|
if (preferFollowUp) {
|
2026-01-17 17:23:30 +00:00
|
|
|
|
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-17 17:23:30 +00:00
|
|
|
|
}),
|
|
|
|
|
|
);
|
2026-01-15 01:13:36 -06:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 21:03:19 -06:00
|
|
|
|
const pickerCommandContext = shouldOpenDiscordModelPickerFromCommand({
|
|
|
|
|
|
command,
|
|
|
|
|
|
commandArgs,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (pickerCommandContext) {
|
|
|
|
|
|
await replyWithDiscordModelPickerProviders({
|
|
|
|
|
|
interaction,
|
|
|
|
|
|
cfg,
|
|
|
|
|
|
command: pickerCommandContext,
|
|
|
|
|
|
userId: user.id,
|
|
|
|
|
|
accountId,
|
2026-02-21 16:14:55 +01:00
|
|
|
|
threadBindings,
|
2026-02-20 21:03:19 -06:00
|
|
|
|
preferFollowUp,
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 01:13:36 -06:00
|
|
|
|
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,
|
2026-02-12 19:50:10 -06:00
|
|
|
|
memberRoleIds,
|
2026-01-15 01:13:36 -06:00
|
|
|
|
peer: {
|
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,
|
|
|
|
|
|
},
|
2026-02-01 03:30:45 +01:00
|
|
|
|
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
|
2026-01-15 01:13:36 -06:00
|
|
|
|
});
|
2026-02-21 16:14:55 +01:00
|
|
|
|
const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined;
|
|
|
|
|
|
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
|
|
|
|
|
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
|
|
|
|
|
|
const effectiveRoute = boundSessionKey
|
|
|
|
|
|
? {
|
|
|
|
|
|
...route,
|
|
|
|
|
|
sessionKey: boundSessionKey,
|
|
|
|
|
|
agentId: boundAgentId ?? route.agentId,
|
|
|
|
|
|
}
|
|
|
|
|
|
: route;
|
2026-01-17 04:04:05 +00:00
|
|
|
|
const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId;
|
2026-02-04 23:34:08 -08:00
|
|
|
|
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-02-10 00:35:56 -06:00
|
|
|
|
BodyForAgent: 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}`,
|
2026-02-21 16:14:55 +01:00
|
|
|
|
SessionKey: boundSessionKey ?? `agent:${effectiveRoute.agentId}:${sessionPrefix}:${user.id}`,
|
|
|
|
|
|
CommandTargetSessionKey: boundSessionKey ?? effectiveRoute.sessionKey,
|
|
|
|
|
|
AccountId: effectiveRoute.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
|
2026-02-03 23:02:28 -08:00
|
|
|
|
? (() => {
|
|
|
|
|
|
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;
|
2026-02-03 23:02:28 -08:00
|
|
|
|
const untrustedChannelMetadata = buildUntrustedChannelMetadata({
|
|
|
|
|
|
source: "discord",
|
|
|
|
|
|
label: "Discord channel topic",
|
|
|
|
|
|
entries: [channelTopic],
|
|
|
|
|
|
});
|
|
|
|
|
|
return untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined;
|
2026-01-15 01:13:36 -06:00
|
|
|
|
})()
|
|
|
|
|
|
: undefined,
|
2026-02-04 23:34:08 -08:00
|
|
|
|
OwnerAllowFrom: ownerAllowFrom,
|
2026-01-15 01:13:36 -06:00
|
|
|
|
SenderName: user.globalName ?? user.username,
|
|
|
|
|
|
SenderId: user.id,
|
|
|
|
|
|
SenderUsername: user.username,
|
2026-01-31 19:50:06 -06:00
|
|
|
|
SenderTag: sender.tag,
|
2026-01-15 01:13:36 -06:00
|
|
|
|
Provider: "discord" as const,
|
|
|
|
|
|
Surface: "discord" as const,
|
|
|
|
|
|
WasMentioned: true,
|
|
|
|
|
|
MessageSid: interactionId,
|
2026-02-21 16:14:55 +01:00
|
|
|
|
MessageThreadId: isThreadChannel ? channelId : undefined,
|
2026-01-15 01:13:36 -06:00
|
|
|
|
Timestamp: Date.now(),
|
|
|
|
|
|
CommandAuthorized: commandAuthorized,
|
|
|
|
|
|
CommandSource: "native" as const,
|
2026-02-18 02:42:52 +00:00
|
|
|
|
// Native slash contexts use To=slash:<user> for interaction routing.
|
|
|
|
|
|
// For follow-up delivery (for example subagent completion announces),
|
|
|
|
|
|
// preserve the real Discord target separately.
|
|
|
|
|
|
OriginatingChannel: "discord" as const,
|
|
|
|
|
|
OriginatingTo: isDirectMessage ? `user:${user.id}` : `channel:${channelId}`,
|
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,
|
2026-02-21 16:14:55 +01:00
|
|
|
|
agentId: effectiveRoute.agentId,
|
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
|
|
|
|
channel: "discord",
|
2026-02-21 16:14:55 +01:00
|
|
|
|
accountId: effectiveRoute.accountId,
|
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
|
|
|
|
});
|
2026-02-21 16:14:55 +01:00
|
|
|
|
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
|
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
|
|
|
|
|
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-02-21 16:14:55 +01:00
|
|
|
|
humanDelay: resolveHumanDelayConfig(cfg, effectiveRoute.agentId),
|
2026-01-15 01:13:36 -06:00
|
|
|
|
deliver: async (payload) => {
|
2026-02-20 21:03:19 -06:00
|
|
|
|
if (suppressReplies) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-17 17:23:30 +00:00
|
|
|
|
try {
|
|
|
|
|
|
await deliverDiscordInteractionReply({
|
|
|
|
|
|
interaction,
|
|
|
|
|
|
payload,
|
2026-02-15 10:53:45 -05:00
|
|
|
|
mediaLocalRoots,
|
2026-01-17 17:23:30 +00:00
|
|
|
|
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),
|
2026-01-17 17:23:30 +00:00
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (isDiscordUnknownInteraction(error)) {
|
2026-02-20 21:03:19 -06:00
|
|
|
|
logVerbose("discord: interaction reply skipped (interaction expired)");
|
2026-01-17 17:23:30 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
2026-01-15 01:13:36 -06:00
|
|
|
|
didReply = true;
|
|
|
|
|
|
},
|
|
|
|
|
|
onError: (err, info) => {
|
2026-02-21 17:44:00 -05:00
|
|
|
|
const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
|
|
|
|
log.error(`discord slash ${info.kind} reply failed: ${message}`);
|
2026-01-15 01:13:36 -06:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
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-02-20 21:03:19 -06:00
|
|
|
|
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
2026-01-14 01:08:15 +00:00
|
|
|
|
payload: ReplyPayload;
|
2026-02-15 10:53:45 -05:00
|
|
|
|
mediaLocalRoots?: readonly string[];
|
2026-01-14 01:08:15 +00:00
|
|
|
|
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;
|
2026-01-14 14:31:43 +00:00
|
|
|
|
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
2026-01-14 01:08:15 +00:00
|
|
|
|
const text = payload.text ?? "";
|
|
|
|
|
|
|
|
|
|
|
|
let hasReplied = false;
|
2026-01-14 14:31:43 +00:00
|
|
|
|
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 };
|
2026-01-17 17:23:30 +00:00
|
|
|
|
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-17 17:23:30 +00:00
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (mediaList.length > 0) {
|
|
|
|
|
|
const media = await Promise.all(
|
|
|
|
|
|
mediaList.map(async (url) => {
|
2026-02-15 10:53:45 -05:00
|
|
|
|
const loaded = await loadWebMedia(url, {
|
|
|
|
|
|
localRoots: params.mediaLocalRoots,
|
|
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
|
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
|
|
|
|
});
|
2026-01-31 16:19:20 +09: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)) {
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!chunk.trim()) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-01-14 01:08:15 +00:00
|
|
|
|
await interaction.followUp({ content: chunk });
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 16:19:20 +09:00
|
|
|
|
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
|
|
|
|
});
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!chunks.length && text) {
|
|
|
|
|
|
chunks.push(text);
|
|
|
|
|
|
}
|
2026-01-14 01:08:15 +00:00
|
|
|
|
for (const chunk of chunks) {
|
2026-01-31 16:19:20 +09:00
|
|
|
|
if (!chunk.trim()) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-01-14 01:08:15 +00:00
|
|
|
|
await sendMessage(chunk);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|