1031 lines
32 KiB
TypeScript
1031 lines
32 KiB
TypeScript
import {
|
||
Button,
|
||
ChannelType,
|
||
Container,
|
||
Row,
|
||
StringSelectMenu,
|
||
TextDisplay,
|
||
type ButtonInteraction,
|
||
type CommandInteraction,
|
||
type ComponentData,
|
||
type StringSelectMenuInteraction,
|
||
} from "@buape/carbon";
|
||
import { ButtonStyle } from "discord-api-types/v10";
|
||
import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
||
import {
|
||
buildCommandTextFromArgs,
|
||
findCommandByNativeName,
|
||
listChatCommands,
|
||
resolveCommandArgChoices,
|
||
serializeCommandArgs,
|
||
} from "openclaw/plugin-sdk/reply-runtime";
|
||
import { resolveStoredModelOverride } from "openclaw/plugin-sdk/reply-runtime";
|
||
import type {
|
||
ChatCommandDefinition,
|
||
CommandArgDefinition,
|
||
CommandArgValues,
|
||
CommandArgs,
|
||
} from "openclaw/plugin-sdk/reply-runtime";
|
||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||
import { chunkItems, withTimeout } from "openclaw/plugin-sdk/text-runtime";
|
||
import { normalizeDiscordSlug } from "./allow-list.js";
|
||
import { resolveDiscordChannelInfo } from "./message-utils.js";
|
||
import {
|
||
readDiscordModelPickerRecentModels,
|
||
recordDiscordModelPickerRecentModel,
|
||
type DiscordModelPickerPreferenceScope,
|
||
} from "./model-picker-preferences.js";
|
||
import {
|
||
loadDiscordModelPickerData,
|
||
parseDiscordModelPickerData,
|
||
renderDiscordModelPickerModelsView,
|
||
renderDiscordModelPickerProvidersView,
|
||
renderDiscordModelPickerRecentsView,
|
||
toDiscordModelPickerMessagePayload,
|
||
type DiscordModelPickerCommandContext,
|
||
} from "./model-picker.js";
|
||
import { resolveDiscordBoundConversationRoute } from "./route-resolution.js";
|
||
import type { ThreadBindingManager } from "./thread-bindings.js";
|
||
import { resolveDiscordThreadParentInfo } from "./threading.js";
|
||
|
||
type DiscordConfig = NonNullable<OpenClawConfig["channels"]>["discord"];
|
||
|
||
const DISCORD_COMMAND_ARG_CUSTOM_ID_KEY = "cmdarg";
|
||
|
||
export type DiscordCommandArgContext = {
|
||
cfg: ReturnType<typeof loadConfig>;
|
||
discordConfig: DiscordConfig;
|
||
accountId: string;
|
||
sessionPrefix: string;
|
||
threadBindings: ThreadBindingManager;
|
||
};
|
||
|
||
export type DiscordModelPickerContext = DiscordCommandArgContext;
|
||
|
||
export type DispatchDiscordCommandInteractionParams = {
|
||
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
||
prompt: string;
|
||
command: ChatCommandDefinition;
|
||
commandArgs?: CommandArgs;
|
||
cfg: ReturnType<typeof loadConfig>;
|
||
discordConfig: DiscordConfig;
|
||
accountId: string;
|
||
sessionPrefix: string;
|
||
preferFollowUp: boolean;
|
||
threadBindings: ThreadBindingManager;
|
||
suppressReplies?: boolean;
|
||
};
|
||
|
||
export type DispatchDiscordCommandInteraction = (
|
||
params: DispatchDiscordCommandInteractionParams,
|
||
) => Promise<void>;
|
||
|
||
export type SafeDiscordInteractionCall = <T>(
|
||
label: string,
|
||
fn: () => Promise<T>,
|
||
) => Promise<T | null>;
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
export function buildDiscordCommandArgCustomId(params: {
|
||
command: string;
|
||
arg: string;
|
||
value: string;
|
||
userId: string;
|
||
}): string {
|
||
return [
|
||
`${DISCORD_COMMAND_ARG_CUSTOM_ID_KEY}:command=${encodeDiscordCommandArgValue(params.command)}`,
|
||
`arg=${encodeDiscordCommandArgValue(params.arg)}`,
|
||
`value=${encodeDiscordCommandArgValue(params.value)}`,
|
||
`user=${encodeDiscordCommandArgValue(params.userId)}`,
|
||
].join(";");
|
||
}
|
||
|
||
function parseDiscordCommandArgData(
|
||
data: ComponentData,
|
||
): { command: string; arg: string; value: string; userId: string } | null {
|
||
if (!data || typeof data !== "object") {
|
||
return null;
|
||
}
|
||
const coerce = (value: unknown) =>
|
||
typeof value === "string" || typeof value === "number" ? String(value) : "";
|
||
const rawCommand = coerce(data.command);
|
||
const rawArg = coerce(data.arg);
|
||
const rawValue = coerce(data.value);
|
||
const rawUser = coerce(data.user);
|
||
if (!rawCommand || !rawArg || !rawValue || !rawUser) {
|
||
return null;
|
||
}
|
||
return {
|
||
command: decodeDiscordCommandArgValue(rawCommand),
|
||
arg: decodeDiscordCommandArgValue(rawArg),
|
||
value: decodeDiscordCommandArgValue(rawValue),
|
||
userId: decodeDiscordCommandArgValue(rawUser),
|
||
};
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
export 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;
|
||
threadBindings: ThreadBindingManager;
|
||
}) {
|
||
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;
|
||
}
|
||
|
||
const threadBinding = isThreadChannel
|
||
? params.threadBindings.getByThreadId(rawChannelId)
|
||
: undefined;
|
||
return resolveDiscordBoundConversationRoute({
|
||
cfg,
|
||
accountId,
|
||
guildId: interaction.guild?.id ?? undefined,
|
||
memberRoleIds,
|
||
isDirectMessage,
|
||
isGroupDm,
|
||
directUserId: interaction.user?.id ?? rawChannelId,
|
||
conversationId: rawChannelId,
|
||
parentConversationId: threadParentId,
|
||
boundSessionKey: threadBinding?.targetSessionKey,
|
||
});
|
||
}
|
||
|
||
function resolveDiscordModelPickerCurrentModel(params: {
|
||
cfg: ReturnType<typeof loadConfig>;
|
||
route: ResolvedAgentRoute;
|
||
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;
|
||
}
|
||
}
|
||
|
||
export async function replyWithDiscordModelPickerProviders(params: {
|
||
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
||
cfg: ReturnType<typeof loadConfig>;
|
||
command: DiscordModelPickerCommandContext;
|
||
userId: string;
|
||
accountId: string;
|
||
threadBindings: ThreadBindingManager;
|
||
preferFollowUp: boolean;
|
||
safeInteractionCall: SafeDiscordInteractionCall;
|
||
}) {
|
||
const route = await resolveDiscordModelPickerRoute({
|
||
interaction: params.interaction,
|
||
cfg: params.cfg,
|
||
accountId: params.accountId,
|
||
threadBindings: params.threadBindings,
|
||
});
|
||
const data = await loadDiscordModelPickerData(params.cfg, route.agentId);
|
||
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 params.safeInteractionCall("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 };
|
||
}
|
||
|
||
export async function handleDiscordModelPickerInteraction(params: {
|
||
interaction: ButtonInteraction | StringSelectMenuInteraction;
|
||
data: ComponentData;
|
||
ctx: DiscordModelPickerContext;
|
||
safeInteractionCall: SafeDiscordInteractionCall;
|
||
dispatchCommandInteraction: DispatchDiscordCommandInteraction;
|
||
}) {
|
||
const { interaction, data, ctx } = params;
|
||
const parsed = parseDiscordModelPickerData(data);
|
||
if (!parsed) {
|
||
await params.safeInteractionCall("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 params.safeInteractionCall("model picker ack", () => interaction.acknowledge());
|
||
return;
|
||
}
|
||
|
||
const route = await resolveDiscordModelPickerRoute({
|
||
interaction,
|
||
cfg: ctx.cfg,
|
||
accountId: ctx.accountId,
|
||
threadBindings: ctx.threadBindings,
|
||
});
|
||
const pickerData = await loadDiscordModelPickerData(ctx.cfg, route.agentId);
|
||
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 params.safeInteractionCall("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 params.safeInteractionCall("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 params.safeInteractionCall("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 params.safeInteractionCall("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 params.safeInteractionCall("model picker update", () =>
|
||
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (parsed.action === "model") {
|
||
const selectedModel = resolveModelPickerSelectionValue(interaction);
|
||
const provider = parsed.provider;
|
||
if (!provider || !selectedModel) {
|
||
await params.safeInteractionCall("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 params.safeInteractionCall("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 params.safeInteractionCall("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 params.safeInteractionCall("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 params.safeInteractionCall("model picker update", () =>
|
||
interaction.update(
|
||
buildDiscordModelPickerNoticePayload("Sorry, /model is unavailable right now."),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
|
||
const updateResult = await params.safeInteractionCall("model picker update", () =>
|
||
interaction.update(
|
||
buildDiscordModelPickerNoticePayload(`Applying model change to ${resolvedModelRef}...`),
|
||
),
|
||
);
|
||
if (updateResult === null) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await withTimeout(
|
||
params.dispatchCommandInteraction({
|
||
interaction,
|
||
prompt: selectionCommand.prompt,
|
||
command: selectionCommand.command,
|
||
commandArgs: selectionCommand.args,
|
||
cfg: ctx.cfg,
|
||
discordConfig: ctx.discordConfig,
|
||
accountId: ctx.accountId,
|
||
sessionPrefix: ctx.sessionPrefix,
|
||
preferFollowUp: true,
|
||
threadBindings: ctx.threadBindings,
|
||
suppressReplies: true,
|
||
}),
|
||
12000,
|
||
);
|
||
} catch (error) {
|
||
if (error instanceof Error && error.message === "timeout") {
|
||
await params.safeInteractionCall("model picker follow-up", () =>
|
||
interaction.followUp({
|
||
...buildDiscordModelPickerNoticePayload(
|
||
`⏳ Model change to ${resolvedModelRef} is still processing. Check /status in a few seconds.`,
|
||
),
|
||
ephemeral: true,
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
await params.safeInteractionCall("model picker follow-up", () =>
|
||
interaction.followUp({
|
||
...buildDiscordModelPickerNoticePayload(
|
||
`❌ Failed to apply ${resolvedModelRef}. Try /model ${resolvedModelRef} directly.`,
|
||
),
|
||
ephemeral: true,
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||
|
||
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 params.safeInteractionCall("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 params.safeInteractionCall("model picker update", () =>
|
||
interaction.update(buildDiscordModelPickerNoticePayload(`ℹ️ Model kept as ${displayModel}.`)),
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function handleDiscordCommandArgInteraction(params: {
|
||
interaction: ButtonInteraction;
|
||
data: ComponentData;
|
||
ctx: DiscordCommandArgContext;
|
||
safeInteractionCall: SafeDiscordInteractionCall;
|
||
dispatchCommandInteraction: DispatchDiscordCommandInteraction;
|
||
}) {
|
||
const { interaction, data, ctx } = params;
|
||
const parsed = parseDiscordCommandArgData(data);
|
||
if (!parsed) {
|
||
await params.safeInteractionCall("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 params.safeInteractionCall("command arg ack", () => interaction.acknowledge());
|
||
return;
|
||
}
|
||
const commandDefinition =
|
||
findCommandByNativeName(parsed.command, "discord") ??
|
||
listChatCommands().find((entry) => entry.key === parsed.command);
|
||
if (!commandDefinition) {
|
||
await params.safeInteractionCall("command arg update", () =>
|
||
interaction.update({
|
||
content: "Sorry, that command is no longer available.",
|
||
components: [],
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
const argUpdateResult = await params.safeInteractionCall("command arg update", () =>
|
||
interaction.update({
|
||
content: `✅ Selected ${parsed.value}.`,
|
||
components: [],
|
||
}),
|
||
);
|
||
if (argUpdateResult === null) {
|
||
return;
|
||
}
|
||
const commandArgs = createCommandArgsWithValue({
|
||
argName: parsed.arg,
|
||
value: parsed.value,
|
||
});
|
||
const commandArgsWithRaw: CommandArgs = {
|
||
...commandArgs,
|
||
raw: serializeCommandArgs(commandDefinition, commandArgs),
|
||
};
|
||
const prompt = buildCommandTextFromArgs(commandDefinition, commandArgsWithRaw);
|
||
await params.dispatchCommandInteraction({
|
||
interaction,
|
||
prompt,
|
||
command: commandDefinition,
|
||
commandArgs: commandArgsWithRaw,
|
||
cfg: ctx.cfg,
|
||
discordConfig: ctx.discordConfig,
|
||
accountId: ctx.accountId,
|
||
sessionPrefix: ctx.sessionPrefix,
|
||
preferFollowUp: true,
|
||
threadBindings: ctx.threadBindings,
|
||
});
|
||
}
|
||
|
||
class DiscordCommandArgButton extends Button {
|
||
label: string;
|
||
customId: string;
|
||
style = ButtonStyle.Secondary;
|
||
private ctx: DiscordCommandArgContext;
|
||
private safeInteractionCall: SafeDiscordInteractionCall;
|
||
private dispatchCommandInteraction: DispatchDiscordCommandInteraction;
|
||
|
||
constructor(params: {
|
||
label: string;
|
||
customId: string;
|
||
ctx: DiscordCommandArgContext;
|
||
safeInteractionCall: SafeDiscordInteractionCall;
|
||
dispatchCommandInteraction: DispatchDiscordCommandInteraction;
|
||
}) {
|
||
super();
|
||
this.label = params.label;
|
||
this.customId = params.customId;
|
||
this.ctx = params.ctx;
|
||
this.safeInteractionCall = params.safeInteractionCall;
|
||
this.dispatchCommandInteraction = params.dispatchCommandInteraction;
|
||
}
|
||
|
||
async run(interaction: ButtonInteraction, data: ComponentData) {
|
||
await handleDiscordCommandArgInteraction({
|
||
interaction,
|
||
data,
|
||
ctx: this.ctx,
|
||
safeInteractionCall: this.safeInteractionCall,
|
||
dispatchCommandInteraction: this.dispatchCommandInteraction,
|
||
});
|
||
}
|
||
}
|
||
|
||
export function buildDiscordCommandArgMenu(params: {
|
||
command: ChatCommandDefinition;
|
||
menu: {
|
||
arg: CommandArgDefinition;
|
||
choices: Array<{ value: string; label: string }>;
|
||
title?: string;
|
||
};
|
||
interaction: CommandInteraction;
|
||
ctx: DiscordCommandArgContext;
|
||
safeInteractionCall: SafeDiscordInteractionCall;
|
||
dispatchCommandInteraction: DispatchDiscordCommandInteraction;
|
||
}): { 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({
|
||
label: choice.label,
|
||
customId: buildDiscordCommandArgCustomId({
|
||
command: commandLabel,
|
||
arg: menu.arg.name,
|
||
value: choice.value,
|
||
userId,
|
||
}),
|
||
ctx: params.ctx,
|
||
safeInteractionCall: params.safeInteractionCall,
|
||
dispatchCommandInteraction: params.dispatchCommandInteraction,
|
||
}),
|
||
);
|
||
return new Row(buttons);
|
||
});
|
||
const content =
|
||
menu.title ?? `Choose ${menu.arg.description || menu.arg.name} for /${commandLabel}.`;
|
||
return { content, components: rows };
|
||
}
|
||
|
||
class DiscordCommandArgFallbackButton extends Button {
|
||
label = "cmdarg";
|
||
customId = "cmdarg:seed=1";
|
||
private ctx: DiscordCommandArgContext;
|
||
private safeInteractionCall: SafeDiscordInteractionCall;
|
||
private dispatchCommandInteraction: DispatchDiscordCommandInteraction;
|
||
|
||
constructor(params: {
|
||
ctx: DiscordCommandArgContext;
|
||
safeInteractionCall: SafeDiscordInteractionCall;
|
||
dispatchCommandInteraction: DispatchDiscordCommandInteraction;
|
||
}) {
|
||
super();
|
||
this.ctx = params.ctx;
|
||
this.safeInteractionCall = params.safeInteractionCall;
|
||
this.dispatchCommandInteraction = params.dispatchCommandInteraction;
|
||
}
|
||
|
||
async run(interaction: ButtonInteraction, data: ComponentData) {
|
||
await handleDiscordCommandArgInteraction({
|
||
interaction,
|
||
data,
|
||
ctx: this.ctx,
|
||
safeInteractionCall: this.safeInteractionCall,
|
||
dispatchCommandInteraction: this.dispatchCommandInteraction,
|
||
});
|
||
}
|
||
}
|
||
|
||
class DiscordModelPickerFallbackButton extends Button {
|
||
label = "modelpick";
|
||
customId = "modelpick:seed=btn";
|
||
private ctx: DiscordModelPickerContext;
|
||
private safeInteractionCall: SafeDiscordInteractionCall;
|
||
private dispatchCommandInteraction: DispatchDiscordCommandInteraction;
|
||
|
||
constructor(params: {
|
||
ctx: DiscordModelPickerContext;
|
||
safeInteractionCall: SafeDiscordInteractionCall;
|
||
dispatchCommandInteraction: DispatchDiscordCommandInteraction;
|
||
}) {
|
||
super();
|
||
this.ctx = params.ctx;
|
||
this.safeInteractionCall = params.safeInteractionCall;
|
||
this.dispatchCommandInteraction = params.dispatchCommandInteraction;
|
||
}
|
||
|
||
async run(interaction: ButtonInteraction, data: ComponentData) {
|
||
await handleDiscordModelPickerInteraction({
|
||
interaction,
|
||
data,
|
||
ctx: this.ctx,
|
||
safeInteractionCall: this.safeInteractionCall,
|
||
dispatchCommandInteraction: this.dispatchCommandInteraction,
|
||
});
|
||
}
|
||
}
|
||
|
||
class DiscordModelPickerFallbackSelect extends StringSelectMenu {
|
||
customId = "modelpick:seed=sel";
|
||
options = [];
|
||
private ctx: DiscordModelPickerContext;
|
||
private safeInteractionCall: SafeDiscordInteractionCall;
|
||
private dispatchCommandInteraction: DispatchDiscordCommandInteraction;
|
||
|
||
constructor(params: {
|
||
ctx: DiscordModelPickerContext;
|
||
safeInteractionCall: SafeDiscordInteractionCall;
|
||
dispatchCommandInteraction: DispatchDiscordCommandInteraction;
|
||
}) {
|
||
super();
|
||
this.ctx = params.ctx;
|
||
this.safeInteractionCall = params.safeInteractionCall;
|
||
this.dispatchCommandInteraction = params.dispatchCommandInteraction;
|
||
}
|
||
|
||
async run(interaction: StringSelectMenuInteraction, data: ComponentData) {
|
||
await handleDiscordModelPickerInteraction({
|
||
interaction,
|
||
data,
|
||
ctx: this.ctx,
|
||
safeInteractionCall: this.safeInteractionCall,
|
||
dispatchCommandInteraction: this.dispatchCommandInteraction,
|
||
});
|
||
}
|
||
}
|
||
|
||
export function createDiscordCommandArgFallbackButton(params: {
|
||
ctx: DiscordCommandArgContext;
|
||
safeInteractionCall: SafeDiscordInteractionCall;
|
||
dispatchCommandInteraction: DispatchDiscordCommandInteraction;
|
||
}): Button {
|
||
return new DiscordCommandArgFallbackButton(params);
|
||
}
|
||
|
||
export function createDiscordModelPickerFallbackButton(params: {
|
||
ctx: DiscordModelPickerContext;
|
||
safeInteractionCall: SafeDiscordInteractionCall;
|
||
dispatchCommandInteraction: DispatchDiscordCommandInteraction;
|
||
}): Button {
|
||
return new DiscordModelPickerFallbackButton(params);
|
||
}
|
||
|
||
export function createDiscordModelPickerFallbackSelect(params: {
|
||
ctx: DiscordModelPickerContext;
|
||
safeInteractionCall: SafeDiscordInteractionCall;
|
||
dispatchCommandInteraction: DispatchDiscordCommandInteraction;
|
||
}): StringSelectMenu {
|
||
return new DiscordModelPickerFallbackSelect(params);
|
||
}
|