fix: restore Discord model picker UX (#21458) (thanks @pejmanjohn)

This commit is contained in:
Shadow
2026-02-20 21:03:19 -06:00
committed by Shadow
parent 5dae5e6ef2
commit b7644d61a2
10 changed files with 2871 additions and 7 deletions

View File

@@ -40,7 +40,6 @@ Docs: https://docs.openclaw.ai
- Security/Agents: restrict local MEDIA tool attachments to core tools and the OpenClaw temp root to prevent untrusted MCP tool file exfiltration. Thanks @NucleiAv and @thewilloftheshadow.
- macOS/Build: default release packaging to `BUNDLE_ID=ai.openclaw.mac` in `scripts/package-mac-dist.sh`, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit.
- Gateway/Pairing: clear persisted paired-device state when the gateway client closes with `device token mismatch` (`1008`) so reconnect flows can cleanly re-enter pairing. (#22071) Thanks @mbelinky.
- Signal/Outbound: preserve case for Base64 group IDs during outbound target normalization so cross-context routing and policy checks no longer break when group IDs include uppercase characters. (#5578) Thanks @heyhudson.
- Providers/Copilot: drop persisted assistant `thinking` blocks for Claude models (while preserving turn structure/tool blocks) so follow-up requests no longer fail on invalid `thinkingSignature` payloads. (#19459) Thanks @jackheuberger.
- Providers/Copilot: add `claude-sonnet-4.6` and `claude-sonnet-4.5` to the default GitHub Copilot model catalog and add coverage for model-list/definition helpers. (#20270, fixes #20091) Thanks @Clawborn.
@@ -53,6 +52,7 @@ Docs: https://docs.openclaw.ai
- WhatsApp/Cron/Heartbeat: enforce allowlisted routing for implicit scheduled/system delivery by merging pairing-store + configured `allowFrom` recipients, selecting authorized recipients when last-route context points to a non-allowlisted chat, and preventing heartbeat fan-out to recent unauthorized chats.
- Heartbeat/Active hours: constrain active-hours `24` sentinel parsing to `24:00` in time validation so invalid values like `24:30` are rejected early. (#21410) thanks @adhitShet.
- Heartbeat: treat `activeHours` windows with identical `start`/`end` times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet.
- Discord: restore model picker back navigation when a provider is missing and document the Discord picker flow. (#21458) Thanks @pejmanjohn and @thewilloftheshadow.
- Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant.
- Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd.
- Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing `provider:default` mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii.

View File

@@ -295,6 +295,8 @@ By default, components are single use. Set `components.reusable=true` to allow b
To restrict who can click a button, set `allowedUsers` on that button (Discord user IDs, tags, or `*`). When configured, unmatched users receive an ephemeral denial.
The `/model` and `/models` slash commands open an interactive model picker with provider and model dropdowns plus a Submit step. The picker reply is ephemeral and only the invoking user can use it.
File attachments:
- `file` blocks must point to an attachment reference (`attachment://<filename>`)

View File

@@ -104,6 +104,7 @@ You can switch models for the current session without restarting:
Notes:
- `/model` (and `/model list`) is a compact, numbered picker (model family + available providers).
- On Discord, `/model` and `/models` open an interactive picker with provider and model dropdowns plus a Submit step.
- `/model <#>` selects from that picker.
- `/model status` is the detailed view (auth candidates and, when configured, provider endpoint `baseUrl` + `api` mode).
- Model refs are parsed by splitting on the **first** `/`. Use `provider/model` when typing `/model <ref>`.

View File

@@ -159,6 +159,7 @@ Examples:
Notes:
- `/model` and `/model list` show a compact, numbered picker (model family + available providers).
- On Discord, `/model` and `/models` open an interactive picker with provider and model dropdowns plus a Submit step.
- `/model <#>` selects from that picker (and prefers the current provider when possible).
- `/model status` shows the detailed view, including configured provider endpoint (`baseUrl`) and API mode (`api`) when available.

View File

@@ -0,0 +1,193 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { normalizeProviderId } from "../../agents/model-selection.js";
import { resolveStateDir } from "../../config/paths.js";
import { withFileLock } from "../../infra/file-lock.js";
import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = {
retries: {
retries: 8,
factor: 2,
minTimeout: 50,
maxTimeout: 5_000,
randomize: true,
},
stale: 15_000,
} as const;
const DEFAULT_RECENT_LIMIT = 5;
type ModelPickerPreferencesEntry = {
recent: string[];
updatedAt: string;
};
type ModelPickerPreferencesStore = {
version: 1;
entries: Record<string, ModelPickerPreferencesEntry>;
};
export type DiscordModelPickerPreferenceScope = {
accountId?: string;
guildId?: string;
userId: string;
};
function resolvePreferencesStorePath(env: NodeJS.ProcessEnv = process.env): string {
const stateDir = resolveStateDir(env, () => resolveRequiredHomeDir(env, os.homedir));
return path.join(stateDir, "discord", "model-picker-preferences.json");
}
function normalizeAccountId(value?: string): string {
const normalized = value?.trim().toLowerCase();
return normalized || "default";
}
function normalizeId(value?: string): string {
return value?.trim() ?? "";
}
export function buildDiscordModelPickerPreferenceKey(
scope: DiscordModelPickerPreferenceScope,
): string | null {
const userId = normalizeId(scope.userId);
if (!userId) {
return null;
}
const accountId = normalizeAccountId(scope.accountId);
const guildId = normalizeId(scope.guildId);
if (guildId) {
return `discord:${accountId}:guild:${guildId}:user:${userId}`;
}
return `discord:${accountId}:dm:user:${userId}`;
}
function normalizeModelRef(raw?: string): string | null {
const value = raw?.trim();
if (!value) {
return null;
}
const slashIndex = value.indexOf("/");
if (slashIndex <= 0 || slashIndex >= value.length - 1) {
return null;
}
const provider = normalizeProviderId(value.slice(0, slashIndex));
const model = value.slice(slashIndex + 1).trim();
if (!provider || !model) {
return null;
}
return `${provider}/${model}`;
}
function sanitizeRecentModels(models: string[] | undefined, limit: number): string[] {
const deduped: string[] = [];
const seen = new Set<string>();
for (const item of models ?? []) {
const normalized = normalizeModelRef(item);
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
deduped.push(normalized);
if (deduped.length >= limit) {
break;
}
}
return deduped;
}
async function readJsonFileWithFallback<T>(
filePath: string,
fallback: T,
): Promise<{ value: T; exists: boolean }> {
try {
const raw = await fs.promises.readFile(filePath, "utf-8");
const parsed = JSON.parse(raw) as T;
return { value: parsed, exists: true };
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") {
return { value: fallback, exists: false };
}
return { value: fallback, exists: false };
}
}
async function writeJsonFileAtomically(filePath: string, value: unknown): Promise<void> {
const dir = path.dirname(filePath);
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`);
await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
await fs.promises.chmod(tmp, 0o600);
await fs.promises.rename(tmp, filePath);
}
async function readPreferencesStore(filePath: string): Promise<ModelPickerPreferencesStore> {
const { value } = await readJsonFileWithFallback<ModelPickerPreferencesStore>(filePath, {
version: 1,
entries: {},
});
if (!value || typeof value !== "object" || value.version !== 1) {
return { version: 1, entries: {} };
}
return {
version: 1,
entries: value.entries && typeof value.entries === "object" ? value.entries : {},
};
}
export async function readDiscordModelPickerRecentModels(params: {
scope: DiscordModelPickerPreferenceScope;
limit?: number;
allowedModelRefs?: Set<string>;
env?: NodeJS.ProcessEnv;
}): Promise<string[]> {
const key = buildDiscordModelPickerPreferenceKey(params.scope);
if (!key) {
return [];
}
const limit = Math.max(1, Math.min(params.limit ?? DEFAULT_RECENT_LIMIT, 10));
const filePath = resolvePreferencesStorePath(params.env);
const store = await readPreferencesStore(filePath);
const entry = store.entries[key];
const recent = sanitizeRecentModels(entry?.recent, limit);
if (!params.allowedModelRefs || params.allowedModelRefs.size === 0) {
return recent;
}
return recent.filter((modelRef) => params.allowedModelRefs?.has(modelRef));
}
export async function recordDiscordModelPickerRecentModel(params: {
scope: DiscordModelPickerPreferenceScope;
modelRef: string;
limit?: number;
env?: NodeJS.ProcessEnv;
}): Promise<void> {
const key = buildDiscordModelPickerPreferenceKey(params.scope);
const normalizedModelRef = normalizeModelRef(params.modelRef);
if (!key || !normalizedModelRef) {
return;
}
const limit = Math.max(1, Math.min(params.limit ?? DEFAULT_RECENT_LIMIT, 10));
const filePath = resolvePreferencesStorePath(params.env);
await withFileLock(filePath, MODEL_PICKER_PREFERENCES_LOCK_OPTIONS, async () => {
const store = await readPreferencesStore(filePath);
const existing = sanitizeRecentModels(store.entries[key]?.recent, limit);
const next = [
normalizedModelRef,
...existing.filter((entry) => entry !== normalizedModelRef),
].slice(0, limit);
store.entries[key] = {
recent: next,
updatedAt: new Date().toISOString(),
};
await writeJsonFileAtomically(filePath, store);
});
}

View File

@@ -0,0 +1,626 @@
import { serializePayload } from "@buape/carbon";
import { ComponentType } from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import type { ModelsProviderData } from "../../auto-reply/reply/commands-models.js";
import * as modelsCommandModule from "../../auto-reply/reply/commands-models.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
DISCORD_CUSTOM_ID_MAX_CHARS,
DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE,
DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE,
DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX,
buildDiscordModelPickerCustomId,
getDiscordModelPickerModelPage,
getDiscordModelPickerProviderPage,
loadDiscordModelPickerData,
parseDiscordModelPickerCustomId,
parseDiscordModelPickerData,
renderDiscordModelPickerModelsView,
renderDiscordModelPickerProvidersView,
renderDiscordModelPickerRecentsView,
toDiscordModelPickerMessagePayload,
} from "./model-picker.js";
function createModelsProviderData(entries: Record<string, string[]>): ModelsProviderData {
const byProvider = new Map<string, Set<string>>();
for (const [provider, models] of Object.entries(entries)) {
byProvider.set(provider, new Set(models));
}
return {
byProvider,
providers: Object.keys(entries).toSorted(),
resolvedDefault: {
provider: Object.keys(entries)[0] ?? "openai",
model: entries[Object.keys(entries)[0]]?.[0] ?? "gpt-4o",
},
};
}
type SerializedComponent = {
type: number;
custom_id?: string;
options?: Array<{ value: string; default?: boolean }>;
components?: SerializedComponent[];
};
function extractContainerRows(components?: SerializedComponent[]): SerializedComponent[] {
const container = components?.find(
(component) => component.type === Number(ComponentType.Container),
);
if (!container) {
return [];
}
return (container.components ?? []).filter(
(component) => component.type === Number(ComponentType.ActionRow),
);
}
describe("loadDiscordModelPickerData", () => {
it("reuses buildModelsProviderData as source of truth", async () => {
const expected = createModelsProviderData({ openai: ["gpt-4o"] });
const spy = vi
.spyOn(modelsCommandModule, "buildModelsProviderData")
.mockResolvedValue(expected);
const result = await loadDiscordModelPickerData({} as OpenClawConfig);
expect(spy).toHaveBeenCalledTimes(1);
expect(result).toBe(expected);
});
});
describe("Discord model picker custom_id", () => {
it("encodes and decodes command/provider/page/user context", () => {
const customId = buildDiscordModelPickerCustomId({
command: "models",
action: "provider",
view: "models",
provider: "OpenAI",
page: 3,
userId: "1234567890",
});
const parsed = parseDiscordModelPickerCustomId(customId);
expect(parsed).toEqual({
command: "models",
action: "provider",
view: "models",
provider: "openai",
page: 3,
userId: "1234567890",
});
});
it("parses component data payloads", () => {
const parsed = parseDiscordModelPickerData({
cmd: "model",
act: "back",
view: "providers",
u: "42",
p: "anthropic",
pg: "2",
});
expect(parsed).toEqual({
command: "model",
action: "back",
view: "providers",
userId: "42",
provider: "anthropic",
page: 2,
});
});
it("parses optional submit model index", () => {
const parsed = parseDiscordModelPickerData({
cmd: "models",
act: "submit",
view: "models",
u: "42",
p: "openai",
pg: "1",
mi: "7",
});
expect(parsed).toEqual({
command: "models",
action: "submit",
view: "models",
userId: "42",
provider: "openai",
page: 1,
modelIndex: 7,
});
});
it("rejects invalid command/action/view values", () => {
expect(
parseDiscordModelPickerData({
cmd: "status",
act: "nav",
view: "providers",
u: "42",
}),
).toBeNull();
expect(
parseDiscordModelPickerData({
cmd: "model",
act: "unknown",
view: "providers",
u: "42",
}),
).toBeNull();
expect(
parseDiscordModelPickerData({
cmd: "model",
act: "nav",
view: "unknown",
u: "42",
}),
).toBeNull();
});
it("enforces Discord custom_id max length", () => {
const longProvider = `provider-${"x".repeat(DISCORD_CUSTOM_ID_MAX_CHARS)}`;
expect(() =>
buildDiscordModelPickerCustomId({
command: "model",
action: "provider",
view: "models",
provider: longProvider,
page: 1,
userId: "42",
}),
).toThrow(/custom_id exceeds/i);
});
});
describe("provider paging", () => {
it("keeps providers on a single page when count fits Discord button rows", () => {
const entries: Record<string, string[]> = {};
for (let i = 1; i <= DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX - 2; i += 1) {
entries[`provider-${String(i).padStart(2, "0")}`] = [`model-${i}`];
}
const data = createModelsProviderData(entries);
const page = getDiscordModelPickerProviderPage({ data, page: 1 });
expect(page.items).toHaveLength(DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX - 2);
expect(page.totalPages).toBe(1);
expect(page.pageSize).toBe(DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX);
expect(page.hasPrev).toBe(false);
expect(page.hasNext).toBe(false);
});
it("paginates providers when count exceeds one-page Discord button limits", () => {
const entries: Record<string, string[]> = {};
for (let i = 1; i <= DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX + 3; i += 1) {
entries[`provider-${String(i).padStart(2, "0")}`] = [`model-${i}`];
}
const data = createModelsProviderData(entries);
const page1 = getDiscordModelPickerProviderPage({ data, page: 1 });
const lastPage = getDiscordModelPickerProviderPage({ data, page: 99 });
expect(page1.items).toHaveLength(DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE);
expect(page1.totalPages).toBe(2);
expect(page1.hasNext).toBe(true);
expect(lastPage.page).toBe(2);
expect(lastPage.items).toHaveLength(8);
expect(lastPage.hasPrev).toBe(true);
expect(lastPage.hasNext).toBe(false);
});
it("caps custom provider page size at Discord-safe max", () => {
const compactData = createModelsProviderData({
anthropic: ["claude-sonnet-4-5"],
openai: ["gpt-4o"],
google: ["gemini-3-pro"],
});
const compactPage = getDiscordModelPickerProviderPage({
data: compactData,
page: 1,
pageSize: 999,
});
expect(compactPage.pageSize).toBe(DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX);
const pagedEntries: Record<string, string[]> = {};
for (let i = 1; i <= DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX + 1; i += 1) {
pagedEntries[`provider-${String(i).padStart(2, "0")}`] = [`model-${i}`];
}
const pagedData = createModelsProviderData(pagedEntries);
const pagedPage = getDiscordModelPickerProviderPage({
data: pagedData,
page: 1,
pageSize: 999,
});
expect(pagedPage.pageSize).toBe(DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE);
});
});
describe("model paging", () => {
it("sorts models and paginates with Discord select-option constraints", () => {
const models = Array.from(
{ length: DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE + 4 },
(_, idx) =>
`model-${String(DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE + 4 - idx).padStart(2, "0")}`,
);
const data = createModelsProviderData({ openai: models });
const page1 = getDiscordModelPickerModelPage({ data, provider: "openai", page: 1 });
const page2 = getDiscordModelPickerModelPage({ data, provider: "openai", page: 2 });
expect(page1).not.toBeNull();
expect(page2).not.toBeNull();
expect(page1?.items).toHaveLength(DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE);
expect(page1?.items[0]).toBe("model-01");
expect(page1?.hasNext).toBe(true);
expect(page2?.items).toHaveLength(4);
expect(page2?.page).toBe(2);
expect(page2?.hasPrev).toBe(true);
expect(page2?.hasNext).toBe(false);
});
it("returns null for unknown provider", () => {
const data = createModelsProviderData({ anthropic: ["claude-sonnet-4-5"] });
const page = getDiscordModelPickerModelPage({ data, provider: "openai", page: 1 });
expect(page).toBeNull();
});
it("caps custom model page size at Discord select-option max", () => {
const data = createModelsProviderData({ openai: ["gpt-4o", "gpt-4.1"] });
const page = getDiscordModelPickerModelPage({ data, provider: "openai", pageSize: 999 });
expect(page?.pageSize).toBe(DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE);
});
});
describe("Discord model picker rendering", () => {
it("renders provider view on one page when provider count is <= 25", () => {
const entries: Record<string, string[]> = {};
for (let i = 1; i <= 22; i += 1) {
entries[`provider-${String(i).padStart(2, "0")}`] = [`model-${i}`];
}
entries["azure-openai-responses"] = ["gpt-4.1"];
entries["vercel-ai-gateway"] = ["gpt-4o-mini"];
const data = createModelsProviderData(entries);
const rendered = renderDiscordModelPickerProvidersView({
command: "models",
userId: "42",
data,
currentModel: "provider-01/model-1",
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
content?: string;
components?: SerializedComponent[];
};
expect(payload.content).toBeUndefined();
expect(payload.components?.[0]?.type).toBe(ComponentType.Container);
const rows = extractContainerRows(payload.components);
expect(rows.length).toBeGreaterThan(0);
const rowProviderCounts = rows.map(
(row) =>
(row.components ?? []).filter((component) => {
const parsed = parseDiscordModelPickerCustomId(component.custom_id ?? "");
return parsed?.action === "provider";
}).length,
);
expect(rowProviderCounts).toEqual([4, 5, 5, 5, 5]);
const allButtons = rows.flatMap((row) => row.components ?? []);
const providerButtons = allButtons.filter((component) => {
const parsed = parseDiscordModelPickerCustomId(component.custom_id ?? "");
return parsed?.action === "provider";
});
expect(providerButtons).toHaveLength(Object.keys(entries).length);
expect(allButtons.some((component) => (component.custom_id ?? "").includes(":act=nav:"))).toBe(
false,
);
});
it("does not render navigation buttons even when provider count exceeds one page", () => {
const entries: Record<string, string[]> = {};
for (let i = 1; i <= DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX + 4; i += 1) {
entries[`provider-${String(i).padStart(2, "0")}`] = [`model-${i}`];
}
const data = createModelsProviderData(entries);
const rendered = renderDiscordModelPickerProvidersView({
command: "models",
userId: "42",
data,
currentModel: "provider-01/model-1",
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
components?: SerializedComponent[];
};
const rows = extractContainerRows(payload.components);
expect(rows.length).toBeGreaterThan(0);
const allButtons = rows.flatMap((row) => row.components ?? []);
expect(allButtons.some((component) => (component.custom_id ?? "").includes(":act=nav:"))).toBe(
false,
);
});
it("supports classic fallback rendering with content + action rows", () => {
const data = createModelsProviderData({ openai: ["gpt-4o"], anthropic: ["claude-sonnet-4-5"] });
const rendered = renderDiscordModelPickerProvidersView({
command: "model",
userId: "99",
data,
layout: "classic",
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
content?: string;
components?: SerializedComponent[];
};
expect(payload.content).toContain("Model Picker");
expect(payload.components?.[0]?.type).toBe(ComponentType.ActionRow);
});
it("renders model view with select menu and explicit submit button", () => {
const data = createModelsProviderData({
openai: ["gpt-4.1", "gpt-4o", "o3"],
anthropic: ["claude-sonnet-4-5"],
});
const rendered = renderDiscordModelPickerModelsView({
command: "models",
userId: "42",
data,
provider: "openai",
page: 1,
providerPage: 2,
currentModel: "openai/gpt-4o",
pendingModel: "openai/o3",
pendingModelIndex: 3,
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
components?: SerializedComponent[];
};
const rows = extractContainerRows(payload.components);
expect(rows).toHaveLength(3);
const providerSelect = rows[0]?.components?.find(
(component) => component.type === Number(ComponentType.StringSelect),
);
expect(providerSelect).toBeTruthy();
expect(providerSelect?.options?.length).toBe(2);
expect(providerSelect?.options?.find((option) => option.value === "openai")?.default).toBe(
true,
);
const parsedProviderState = parseDiscordModelPickerCustomId(providerSelect?.custom_id ?? "");
expect(parsedProviderState?.action).toBe("provider");
const modelSelect = rows[1]?.components?.find(
(component) => component.type === Number(ComponentType.StringSelect),
);
expect(modelSelect).toBeTruthy();
expect(modelSelect?.options?.length).toBe(3);
expect(modelSelect?.options?.find((option) => option.value === "o3")?.default).toBe(true);
const parsedModelSelectState = parseDiscordModelPickerCustomId(modelSelect?.custom_id ?? "");
expect(parsedModelSelectState?.action).toBe("model");
expect(parsedModelSelectState?.provider).toBe("openai");
const navButtons = rows[2]?.components ?? [];
expect(navButtons).toHaveLength(3);
const cancelState = parseDiscordModelPickerCustomId(navButtons[0]?.custom_id ?? "");
expect(cancelState?.action).toBe("cancel");
const resetState = parseDiscordModelPickerCustomId(navButtons[1]?.custom_id ?? "");
expect(resetState?.action).toBe("reset");
expect(resetState?.provider).toBe("openai");
const submitState = parseDiscordModelPickerCustomId(navButtons[2]?.custom_id ?? "");
expect(submitState?.action).toBe("submit");
expect(submitState?.provider).toBe("openai");
expect(submitState?.modelIndex).toBe(3);
});
it("renders not-found model view with a back button", () => {
const data = createModelsProviderData({ openai: ["gpt-4o"] });
const rendered = renderDiscordModelPickerModelsView({
command: "model",
userId: "42",
data,
provider: "does-not-exist",
providerPage: 3,
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
components?: SerializedComponent[];
};
const rows = extractContainerRows(payload.components);
expect(rows).toHaveLength(1);
const backButton = rows[0]?.components?.[0];
expect(backButton?.type).toBe(ComponentType.Button);
const state = parseDiscordModelPickerCustomId(backButton?.custom_id ?? "");
expect(state?.action).toBe("back");
expect(state?.view).toBe("providers");
expect(state?.page).toBe(3);
});
it("shows Recents button when quickModels are provided", () => {
const data = createModelsProviderData({
openai: ["gpt-4.1", "gpt-4o"],
anthropic: ["claude-sonnet-4-5"],
});
const rendered = renderDiscordModelPickerModelsView({
command: "model",
userId: "42",
data,
provider: "openai",
page: 1,
providerPage: 1,
currentModel: "openai/gpt-4o",
quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"],
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
components?: SerializedComponent[];
};
const rows = extractContainerRows(payload.components);
const buttonRow = rows[2];
const buttons = buttonRow?.components ?? [];
expect(buttons).toHaveLength(4);
const favoritesState = parseDiscordModelPickerCustomId(buttons[2]?.custom_id ?? "");
expect(favoritesState?.action).toBe("recents");
expect(favoritesState?.view).toBe("recents");
});
it("omits Recents button when no quickModels", () => {
const data = createModelsProviderData({
openai: ["gpt-4.1", "gpt-4o"],
});
const rendered = renderDiscordModelPickerModelsView({
command: "model",
userId: "42",
data,
provider: "openai",
page: 1,
providerPage: 1,
currentModel: "openai/gpt-4o",
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
components?: SerializedComponent[];
};
const rows = extractContainerRows(payload.components);
const buttonRow = rows[2];
const buttons = buttonRow?.components ?? [];
expect(buttons).toHaveLength(3);
const allActions = buttons.map(
(b) => parseDiscordModelPickerCustomId(b?.custom_id ?? "")?.action,
);
expect(allActions).not.toContain("recents");
});
});
describe("Discord model picker recents view", () => {
it("renders one button per model with back button after divider", () => {
const data = createModelsProviderData({
openai: ["gpt-4.1", "gpt-4o"],
anthropic: ["claude-sonnet-4-5"],
});
// Default is openai/gpt-4.1 (first key in entries).
// Neither quickModel matches, so no deduping — 1 default + 2 recents + 1 back = 4 rows.
const rendered = renderDiscordModelPickerRecentsView({
command: "model",
userId: "42",
data,
quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"],
currentModel: "openai/gpt-4o",
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
components?: SerializedComponent[];
};
const rows = extractContainerRows(payload.components);
expect(rows).toHaveLength(4);
// First row: default model button (slot 1).
const defaultBtn = rows[0]?.components?.[0];
expect(defaultBtn?.type).toBe(ComponentType.Button);
const defaultState = parseDiscordModelPickerCustomId(defaultBtn?.custom_id ?? "");
expect(defaultState?.action).toBe("submit");
expect(defaultState?.view).toBe("recents");
expect(defaultState?.recentSlot).toBe(1);
// Second row: first recent (slot 2).
const recentBtn1 = rows[1]?.components?.[0];
const recentState1 = parseDiscordModelPickerCustomId(recentBtn1?.custom_id ?? "");
expect(recentState1?.recentSlot).toBe(2);
// Third row: second recent (slot 3).
const recentBtn2 = rows[2]?.components?.[0];
const recentState2 = parseDiscordModelPickerCustomId(recentBtn2?.custom_id ?? "");
expect(recentState2?.recentSlot).toBe(3);
// Fourth row (after divider): Back button.
const backBtn = rows[3]?.components?.[0];
const backState = parseDiscordModelPickerCustomId(backBtn?.custom_id ?? "");
expect(backState?.action).toBe("back");
expect(backState?.view).toBe("models");
});
it("includes (default) suffix on default model button label", () => {
const data = createModelsProviderData({
openai: ["gpt-4o"],
});
const rendered = renderDiscordModelPickerRecentsView({
command: "model",
userId: "42",
data,
quickModels: ["openai/gpt-4o"],
currentModel: "openai/gpt-4o",
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
components?: SerializedComponent[];
};
const rows = extractContainerRows(payload.components);
const defaultBtn = rows[0]?.components?.[0] as { label?: string };
expect(defaultBtn?.label).toContain("(default)");
});
it("deduplicates recents that match the default model", () => {
const data = createModelsProviderData({
openai: ["gpt-4o"],
anthropic: ["claude-sonnet-4-5"],
});
// Default is openai/gpt-4o (first key). quickModels contains the default.
const rendered = renderDiscordModelPickerRecentsView({
command: "model",
userId: "42",
data,
quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"],
currentModel: "openai/gpt-4o",
});
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
components?: SerializedComponent[];
};
const rows = extractContainerRows(payload.components);
// 1 default + 1 deduped recent + 1 back = 3 rows (openai/gpt-4o not shown twice)
expect(rows).toHaveLength(3);
const defaultBtn = rows[0]?.components?.[0] as { label?: string };
expect(defaultBtn?.label).toContain("openai/gpt-4o");
expect(defaultBtn?.label).toContain("(default)");
const recentBtn = rows[1]?.components?.[0] as { label?: string };
expect(recentBtn?.label).toContain("anthropic/claude-sonnet-4-5");
});
});

View File

@@ -0,0 +1,937 @@
import {
Button,
Container,
Row,
Separator,
StringSelectMenu,
TextDisplay,
type ComponentData,
type MessagePayloadObject,
type TopLevelComponents,
} from "@buape/carbon";
import type { APISelectMenuOption } from "discord-api-types/v10";
import { ButtonStyle } from "discord-api-types/v10";
import { normalizeProviderId } from "../../agents/model-selection.js";
import {
buildModelsProviderData,
type ModelsProviderData,
} from "../../auto-reply/reply/commands-models.js";
import type { OpenClawConfig } from "../../config/config.js";
export const DISCORD_MODEL_PICKER_CUSTOM_ID_KEY = "mdlpk";
export const DISCORD_CUSTOM_ID_MAX_CHARS = 100;
// Discord component limits.
export const DISCORD_COMPONENT_MAX_ROWS = 5;
export const DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW = 5;
export const DISCORD_COMPONENT_MAX_SELECT_OPTIONS = 25;
// Reserve one row for navigation/utility buttons when rendering providers.
export const DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE =
DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW * (DISCORD_COMPONENT_MAX_ROWS - 1);
// When providers fit in one page, we can use all button rows and hide nav controls.
export const DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX =
DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW * DISCORD_COMPONENT_MAX_ROWS;
export const DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE = DISCORD_COMPONENT_MAX_SELECT_OPTIONS;
const DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS = 18;
const COMMAND_CONTEXTS = ["model", "models"] as const;
const PICKER_ACTIONS = [
"open",
"provider",
"model",
"submit",
"quick",
"back",
"reset",
"cancel",
"recents",
] as const;
const PICKER_VIEWS = ["providers", "models", "recents"] as const;
export type DiscordModelPickerCommandContext = (typeof COMMAND_CONTEXTS)[number];
export type DiscordModelPickerAction = (typeof PICKER_ACTIONS)[number];
export type DiscordModelPickerView = (typeof PICKER_VIEWS)[number];
export type DiscordModelPickerState = {
command: DiscordModelPickerCommandContext;
action: DiscordModelPickerAction;
view: DiscordModelPickerView;
userId: string;
provider?: string;
page: number;
providerPage?: number;
modelIndex?: number;
recentSlot?: number;
};
export type DiscordModelPickerProviderItem = {
id: string;
count: number;
};
export type DiscordModelPickerPage<T> = {
items: T[];
page: number;
pageSize: number;
totalPages: number;
totalItems: number;
hasPrev: boolean;
hasNext: boolean;
};
export type DiscordModelPickerModelPage = DiscordModelPickerPage<string> & {
provider: string;
};
export type DiscordModelPickerLayout = "v2" | "classic";
type DiscordModelPickerButtonOptions = {
label: string;
customId: string;
style?: ButtonStyle;
disabled?: boolean;
};
type DiscordModelPickerCurrentModelRef = {
provider: string;
model: string;
};
type DiscordModelPickerRow = Row<Button> | Row<StringSelectMenu>;
type DiscordModelPickerRenderShellParams = {
layout: DiscordModelPickerLayout;
title: string;
detailLines: string[];
rows: DiscordModelPickerRow[];
footer?: string;
/** Text shown after the divider but before the interactive rows. */
preRowText?: string;
/** Extra rows appended after the main rows, preceded by a divider. */
trailingRows?: DiscordModelPickerRow[];
};
export type DiscordModelPickerRenderedView = {
layout: DiscordModelPickerLayout;
content?: string;
components: TopLevelComponents[];
};
export type DiscordModelPickerProviderViewParams = {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
page?: number;
currentModel?: string;
layout?: DiscordModelPickerLayout;
};
export type DiscordModelPickerModelViewParams = {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
provider: string;
page?: number;
providerPage?: number;
currentModel?: string;
pendingModel?: string;
pendingModelIndex?: number;
quickModels?: string[];
layout?: DiscordModelPickerLayout;
};
function encodeCustomIdValue(value: string): string {
return encodeURIComponent(value);
}
function decodeCustomIdValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function isValidCommandContext(value: string): value is DiscordModelPickerCommandContext {
return (COMMAND_CONTEXTS as readonly string[]).includes(value);
}
function isValidPickerAction(value: string): value is DiscordModelPickerAction {
return (PICKER_ACTIONS as readonly string[]).includes(value);
}
function isValidPickerView(value: string): value is DiscordModelPickerView {
return (PICKER_VIEWS as readonly string[]).includes(value);
}
function normalizePage(value: number | undefined): number {
const numeric = typeof value === "number" ? value : Number.NaN;
if (!Number.isFinite(numeric)) {
return 1;
}
return Math.max(1, Math.floor(numeric));
}
function parseRawPage(value: unknown): number {
if (typeof value === "number") {
return normalizePage(value);
}
if (typeof value === "string" && value.trim()) {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) {
return normalizePage(parsed);
}
}
return 1;
}
function parseRawPositiveInt(value: unknown): number | undefined {
if (typeof value !== "string" && typeof value !== "number") {
return undefined;
}
const parsed = Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed < 1) {
return undefined;
}
return Math.floor(parsed);
}
function coerceString(value: unknown): string {
return typeof value === "string" || typeof value === "number" ? String(value) : "";
}
function clampPageSize(rawPageSize: number | undefined, max: number, fallback: number): number {
if (!Number.isFinite(rawPageSize)) {
return fallback;
}
return Math.min(max, Math.max(1, Math.floor(rawPageSize ?? fallback)));
}
function paginateItems<T>(params: {
items: T[];
page: number;
pageSize: number;
}): DiscordModelPickerPage<T> {
const totalItems = params.items.length;
const totalPages = Math.max(1, Math.ceil(totalItems / params.pageSize));
const page = Math.max(1, Math.min(params.page, totalPages));
const startIndex = (page - 1) * params.pageSize;
const endIndexExclusive = Math.min(totalItems, startIndex + params.pageSize);
return {
items: params.items.slice(startIndex, endIndexExclusive),
page,
pageSize: params.pageSize,
totalPages,
totalItems,
hasPrev: page > 1,
hasNext: page < totalPages,
};
}
function parseCurrentModelRef(raw?: string): DiscordModelPickerCurrentModelRef | null {
const trimmed = raw?.trim();
if (!trimmed) {
return null;
}
const slashIndex = trimmed.indexOf("/");
if (slashIndex <= 0 || slashIndex >= trimmed.length - 1) {
return null;
}
const provider = normalizeProviderId(trimmed.slice(0, slashIndex));
const model = trimmed.slice(slashIndex + 1);
if (!provider || !model) {
return null;
}
return { provider, model };
}
function formatCurrentModelLine(currentModel?: string): string {
const parsed = parseCurrentModelRef(currentModel);
if (!parsed) {
return "Current model: default";
}
return `Current model: ${parsed.provider}/${parsed.model}`;
}
function formatProviderButtonLabel(provider: string): string {
if (provider.length <= DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS) {
return provider;
}
return `${provider.slice(0, DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS - 1)}`;
}
function chunkProvidersForRows(
items: DiscordModelPickerProviderItem[],
): DiscordModelPickerProviderItem[][] {
if (items.length === 0) {
return [];
}
const rowCount = Math.max(1, Math.ceil(items.length / DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW));
const minPerRow = Math.floor(items.length / rowCount);
const rowsWithExtraItem = items.length % rowCount;
const counts = Array.from({ length: rowCount }, (_, index) =>
index < rowCount - rowsWithExtraItem ? minPerRow : minPerRow + 1,
);
const rows: DiscordModelPickerProviderItem[][] = [];
let cursor = 0;
for (const count of counts) {
rows.push(items.slice(cursor, cursor + count));
cursor += count;
}
return rows;
}
function createModelPickerButton(params: DiscordModelPickerButtonOptions): Button {
class DiscordModelPickerButton extends Button {
label = params.label;
customId = params.customId;
style = params.style ?? ButtonStyle.Secondary;
disabled = params.disabled ?? false;
}
return new DiscordModelPickerButton();
}
function createModelSelect(params: {
customId: string;
options: APISelectMenuOption[];
placeholder?: string;
disabled?: boolean;
}): StringSelectMenu {
class DiscordModelPickerSelect extends StringSelectMenu {
customId = params.customId;
options = params.options;
minValues = 1;
maxValues = 1;
placeholder = params.placeholder;
disabled = params.disabled ?? false;
}
return new DiscordModelPickerSelect();
}
function buildRenderedShell(
params: DiscordModelPickerRenderShellParams,
): DiscordModelPickerRenderedView {
if (params.layout === "classic") {
const lines = [params.title, ...params.detailLines, "", params.footer].filter(Boolean);
return {
layout: "classic",
content: lines.join("\n"),
components: params.rows,
};
}
const containerComponents: Array<TextDisplay | Separator | DiscordModelPickerRow> = [
new TextDisplay(`## ${params.title}`),
];
if (params.detailLines.length > 0) {
containerComponents.push(new TextDisplay(params.detailLines.join("\n")));
}
containerComponents.push(new Separator({ divider: true, spacing: "small" }));
if (params.preRowText) {
containerComponents.push(new TextDisplay(params.preRowText));
}
containerComponents.push(...params.rows);
if (params.trailingRows && params.trailingRows.length > 0) {
containerComponents.push(new Separator({ divider: true, spacing: "small" }));
containerComponents.push(...params.trailingRows);
}
if (params.footer) {
containerComponents.push(new Separator({ divider: false, spacing: "small" }));
containerComponents.push(new TextDisplay(`-# ${params.footer}`));
}
const container = new Container(containerComponents);
return {
layout: "v2",
components: [container],
};
}
function buildProviderRows(params: {
command: DiscordModelPickerCommandContext;
userId: string;
page: DiscordModelPickerPage<DiscordModelPickerProviderItem>;
currentProvider?: string;
}): Row<Button>[] {
const rows = chunkProvidersForRows(params.page.items).map(
(providers) =>
new Row(
providers.map((provider) => {
const style =
provider.id === params.currentProvider ? ButtonStyle.Primary : ButtonStyle.Secondary;
return createModelPickerButton({
label: formatProviderButtonLabel(provider.id),
style,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "provider",
view: "models",
provider: provider.id,
page: params.page.page,
userId: params.userId,
}),
});
}),
),
);
return rows;
}
function buildModelRows(params: {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
providerPage: number;
modelPage: DiscordModelPickerModelPage;
currentModel?: string;
pendingModel?: string;
pendingModelIndex?: number;
quickModels?: string[];
}): { rows: DiscordModelPickerRow[]; buttonRow: Row<Button> } {
const parsedCurrentModel = parseCurrentModelRef(params.currentModel);
const parsedPendingModel = parseCurrentModelRef(params.pendingModel);
const rows: DiscordModelPickerRow[] = [];
const hasQuickModels = (params.quickModels ?? []).length > 0;
const providerPage = getDiscordModelPickerProviderPage({
data: params.data,
page: params.providerPage,
});
const providerOptions: APISelectMenuOption[] = providerPage.items.map((provider) => ({
label: provider.id,
value: provider.id,
default: provider.id === params.modelPage.provider,
}));
rows.push(
new Row([
createModelSelect({
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "provider",
view: "models",
provider: params.modelPage.provider,
page: providerPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
options: providerOptions,
placeholder: "Select provider",
}),
]),
);
const selectedModelRef = parsedPendingModel ?? parsedCurrentModel;
const modelOptions: APISelectMenuOption[] = params.modelPage.items.map((model) => ({
label: model,
value: model,
default: selectedModelRef
? selectedModelRef.provider === params.modelPage.provider && selectedModelRef.model === model
: false,
}));
rows.push(
new Row([
createModelSelect({
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "model",
view: "models",
provider: params.modelPage.provider,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
options: modelOptions,
placeholder: `Select ${params.modelPage.provider} model`,
}),
]),
);
const resolvedDefault = params.data.resolvedDefault;
const shouldDisableReset =
Boolean(parsedCurrentModel) &&
parsedCurrentModel?.provider === resolvedDefault.provider &&
parsedCurrentModel?.model === resolvedDefault.model;
const hasPendingSelection =
Boolean(parsedPendingModel) &&
parsedPendingModel?.provider === params.modelPage.provider &&
typeof params.pendingModelIndex === "number" &&
params.pendingModelIndex > 0;
const buttonRowItems: Button[] = [
createModelPickerButton({
label: "Cancel",
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "cancel",
view: "models",
provider: params.modelPage.provider,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
}),
createModelPickerButton({
label: "Reset to default",
style: ButtonStyle.Secondary,
disabled: shouldDisableReset,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "reset",
view: "models",
provider: params.modelPage.provider,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
}),
];
if (hasQuickModels) {
buttonRowItems.push(
createModelPickerButton({
label: "Recents",
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "recents",
view: "recents",
provider: params.modelPage.provider,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
}),
);
}
buttonRowItems.push(
createModelPickerButton({
label: "Submit",
style: ButtonStyle.Primary,
disabled: !hasPendingSelection,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "submit",
view: "models",
provider: params.modelPage.provider,
page: params.modelPage.page,
providerPage: providerPage.page,
modelIndex: params.pendingModelIndex,
userId: params.userId,
}),
}),
);
return { rows, buttonRow: new Row(buttonRowItems) };
}
/**
* Source-of-truth data for Discord picker views. This intentionally reuses the
* same provider/model resolver used by text and Telegram model commands.
*/
export async function loadDiscordModelPickerData(cfg: OpenClawConfig): Promise<ModelsProviderData> {
return buildModelsProviderData(cfg);
}
export function buildDiscordModelPickerCustomId(params: {
command: DiscordModelPickerCommandContext;
action: DiscordModelPickerAction;
view: DiscordModelPickerView;
userId: string;
provider?: string;
page?: number;
providerPage?: number;
modelIndex?: number;
recentSlot?: number;
}): string {
const userId = params.userId.trim();
if (!userId) {
throw new Error("Discord model picker custom_id requires userId");
}
const page = normalizePage(params.page);
const providerPage =
typeof params.providerPage === "number" && Number.isFinite(params.providerPage)
? Math.max(1, Math.floor(params.providerPage))
: undefined;
const normalizedProvider = params.provider ? normalizeProviderId(params.provider) : undefined;
const modelIndex =
typeof params.modelIndex === "number" && Number.isFinite(params.modelIndex)
? Math.max(1, Math.floor(params.modelIndex))
: undefined;
const recentSlot =
typeof params.recentSlot === "number" && Number.isFinite(params.recentSlot)
? Math.max(1, Math.floor(params.recentSlot))
: undefined;
const parts = [
`${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:cmd=${encodeCustomIdValue(params.command)}`,
`act=${encodeCustomIdValue(params.action)}`,
`view=${encodeCustomIdValue(params.view)}`,
`u=${encodeCustomIdValue(userId)}`,
`pg=${String(page)}`,
];
if (normalizedProvider) {
parts.push(`p=${encodeCustomIdValue(normalizedProvider)}`);
}
if (providerPage) {
parts.push(`pp=${String(providerPage)}`);
}
if (modelIndex) {
parts.push(`mi=${String(modelIndex)}`);
}
if (recentSlot) {
parts.push(`rs=${String(recentSlot)}`);
}
const customId = parts.join(";");
if (customId.length > DISCORD_CUSTOM_ID_MAX_CHARS) {
throw new Error(
`Discord model picker custom_id exceeds ${DISCORD_CUSTOM_ID_MAX_CHARS} chars (${customId.length})`,
);
}
return customId;
}
export function parseDiscordModelPickerCustomId(customId: string): DiscordModelPickerState | null {
const trimmed = customId.trim();
if (!trimmed.startsWith(`${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:`)) {
return null;
}
const rawParts = trimmed.split(";");
const data: Record<string, string> = {};
for (const part of rawParts) {
const equalsIndex = part.indexOf("=");
if (equalsIndex <= 0) {
continue;
}
const rawKey = part.slice(0, equalsIndex);
const rawValue = part.slice(equalsIndex + 1);
const key = rawKey.includes(":") ? rawKey.split(":").slice(1).join(":") : rawKey;
if (!key) {
continue;
}
data[key] = rawValue;
}
return parseDiscordModelPickerData(data);
}
export function parseDiscordModelPickerData(data: ComponentData): DiscordModelPickerState | null {
if (!data || typeof data !== "object") {
return null;
}
const command = decodeCustomIdValue(coerceString(data.cmd));
const action = decodeCustomIdValue(coerceString(data.act));
const view = decodeCustomIdValue(coerceString(data.view));
const userId = decodeCustomIdValue(coerceString(data.u));
const providerRaw = decodeCustomIdValue(coerceString(data.p));
const page = parseRawPage(data.pg);
const providerPage = parseRawPositiveInt(data.pp);
const modelIndex = parseRawPositiveInt(data.mi);
const recentSlot = parseRawPositiveInt(data.rs);
if (!isValidCommandContext(command) || !isValidPickerAction(action) || !isValidPickerView(view)) {
return null;
}
const trimmedUserId = userId.trim();
if (!trimmedUserId) {
return null;
}
const provider = providerRaw ? normalizeProviderId(providerRaw) : undefined;
return {
command,
action,
view,
userId: trimmedUserId,
provider,
page,
...(typeof providerPage === "number" ? { providerPage } : {}),
...(typeof modelIndex === "number" ? { modelIndex } : {}),
...(typeof recentSlot === "number" ? { recentSlot } : {}),
};
}
export function buildDiscordModelPickerProviderItems(
data: ModelsProviderData,
): DiscordModelPickerProviderItem[] {
return data.providers.map((provider) => ({
id: provider,
count: data.byProvider.get(provider)?.size ?? 0,
}));
}
export function getDiscordModelPickerProviderPage(params: {
data: ModelsProviderData;
page?: number;
pageSize?: number;
}): DiscordModelPickerPage<DiscordModelPickerProviderItem> {
const items = buildDiscordModelPickerProviderItems(params.data);
const canFitSinglePage = items.length <= DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX;
const maxPageSize = canFitSinglePage
? DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX
: DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE;
const pageSize = clampPageSize(params.pageSize, maxPageSize, maxPageSize);
return paginateItems({
items,
page: normalizePage(params.page),
pageSize,
});
}
export function getDiscordModelPickerModelPage(params: {
data: ModelsProviderData;
provider: string;
page?: number;
pageSize?: number;
}): DiscordModelPickerModelPage | null {
const provider = normalizeProviderId(params.provider);
const modelSet = params.data.byProvider.get(provider);
if (!modelSet) {
return null;
}
const pageSize = clampPageSize(
params.pageSize,
DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE,
DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE,
);
const models = [...modelSet].toSorted();
const page = paginateItems({
items: models,
page: normalizePage(params.page),
pageSize,
});
return {
...page,
provider,
};
}
export function renderDiscordModelPickerProvidersView(
params: DiscordModelPickerProviderViewParams,
): DiscordModelPickerRenderedView {
const page = getDiscordModelPickerProviderPage({ data: params.data, page: params.page });
const parsedCurrent = parseCurrentModelRef(params.currentModel);
const rows = buildProviderRows({
command: params.command,
userId: params.userId,
page,
currentProvider: parsedCurrent?.provider,
});
const detailLines = [
formatCurrentModelLine(params.currentModel),
`Select a provider (${page.totalItems} available).`,
];
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Model Picker",
detailLines,
rows,
footer: `All ${page.totalItems} providers shown`,
});
}
export function renderDiscordModelPickerModelsView(
params: DiscordModelPickerModelViewParams,
): DiscordModelPickerRenderedView {
const providerPage = normalizePage(params.providerPage);
const modelPage = getDiscordModelPickerModelPage({
data: params.data,
provider: params.provider,
page: params.page,
});
if (!modelPage) {
const rows: Row<Button>[] = [
new Row([
createModelPickerButton({
label: "Back",
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "back",
view: "providers",
page: providerPage,
userId: params.userId,
}),
}),
]),
];
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Model Picker",
detailLines: [
formatCurrentModelLine(params.currentModel),
`Provider not found: ${normalizeProviderId(params.provider)}`,
],
rows,
footer: "Choose a different provider.",
});
}
const { rows, buttonRow } = buildModelRows({
command: params.command,
userId: params.userId,
data: params.data,
providerPage,
modelPage,
currentModel: params.currentModel,
pendingModel: params.pendingModel,
pendingModelIndex: params.pendingModelIndex,
quickModels: params.quickModels,
});
const defaultModel = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
const pendingLine = params.pendingModel
? `Selected: ${params.pendingModel} (press Submit)`
: "Select a model, then press Submit.";
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Model Picker",
detailLines: [formatCurrentModelLine(params.currentModel), `Default: ${defaultModel}`],
preRowText: pendingLine,
rows,
trailingRows: [buttonRow],
});
}
export type DiscordModelPickerRecentsViewParams = {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
quickModels: string[];
currentModel?: string;
provider?: string;
page?: number;
providerPage?: number;
layout?: DiscordModelPickerLayout;
};
function formatRecentsButtonLabel(modelRef: string, suffix?: string): string {
const maxLen = 80;
const label = suffix ? `${modelRef} ${suffix}` : modelRef;
if (label.length <= maxLen) {
return label;
}
const trimmed = suffix
? `${modelRef.slice(0, maxLen - suffix.length - 2)}${suffix}`
: `${modelRef.slice(0, maxLen - 1)}`;
return trimmed;
}
export function renderDiscordModelPickerRecentsView(
params: DiscordModelPickerRecentsViewParams,
): DiscordModelPickerRenderedView {
const defaultModelRef = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
const rows: DiscordModelPickerRow[] = [];
// Dedupe: filter recents that match the default model.
const dedupedQuickModels = params.quickModels.filter((modelRef) => modelRef !== defaultModelRef);
// Default model button — slot 1.
rows.push(
new Row([
createModelPickerButton({
label: formatRecentsButtonLabel(defaultModelRef, "(default)"),
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "submit",
view: "recents",
recentSlot: 1,
provider: params.provider,
page: params.page,
providerPage: params.providerPage,
userId: params.userId,
}),
}),
]),
);
// Recent model buttons — slot 2+.
for (let i = 0; i < dedupedQuickModels.length; i++) {
const modelRef = dedupedQuickModels[i];
rows.push(
new Row([
createModelPickerButton({
label: formatRecentsButtonLabel(modelRef),
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "submit",
view: "recents",
recentSlot: i + 2,
provider: params.provider,
page: params.page,
providerPage: params.providerPage,
userId: params.userId,
}),
}),
]),
);
}
// Back button after a divider (via trailingRows).
const backRow: Row<Button> = new Row([
createModelPickerButton({
label: "Back",
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "back",
view: "models",
provider: params.provider,
page: params.page,
providerPage: params.providerPage,
userId: params.userId,
}),
}),
]);
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Recents",
detailLines: [
"Models you've previously selected appear here.",
formatCurrentModelLine(params.currentModel),
],
preRowText: "Tap a model to switch.",
rows,
trailingRows: [backRow],
});
}
export function toDiscordModelPickerMessagePayload(
view: DiscordModelPickerRenderedView,
): MessagePayloadObject {
if (view.layout === "classic") {
return {
content: view.content,
components: view.components,
};
}
return {
components: view.components,
};
}

View File

@@ -0,0 +1,378 @@
import { ChannelType } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as commandRegistryModule from "../../auto-reply/commands-registry.js";
import type {
ChatCommandDefinition,
CommandArgsParsing,
} from "../../auto-reply/commands-registry.types.js";
import type { ModelsProviderData } from "../../auto-reply/reply/commands-models.js";
import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js";
import type { OpenClawConfig } from "../../config/config.js";
import * as timeoutModule from "../../utils/with-timeout.js";
import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
import * as modelPickerModule from "./model-picker.js";
import {
createDiscordModelPickerFallbackButton,
createDiscordModelPickerFallbackSelect,
} from "./native-command.js";
function createModelsProviderData(entries: Record<string, string[]>): ModelsProviderData {
const byProvider = new Map<string, Set<string>>();
for (const [provider, models] of Object.entries(entries)) {
byProvider.set(provider, new Set(models));
}
const providers = Object.keys(entries).toSorted();
return {
byProvider,
providers,
resolvedDefault: {
provider: providers[0] ?? "openai",
model: entries[providers[0] ?? "openai"]?.[0] ?? "gpt-4o",
},
};
}
type ModelPickerContext = Parameters<typeof createDiscordModelPickerFallbackButton>[0];
type PickerButton = ReturnType<typeof createDiscordModelPickerFallbackButton>;
type PickerSelect = ReturnType<typeof createDiscordModelPickerFallbackSelect>;
type PickerButtonInteraction = Parameters<PickerButton["run"]>[0];
type PickerButtonData = Parameters<PickerButton["run"]>[1];
type PickerSelectInteraction = Parameters<PickerSelect["run"]>[0];
type PickerSelectData = Parameters<PickerSelect["run"]>[1];
type MockInteraction = {
user: { id: string; username: string; globalName: string };
channel: { type: ChannelType; id: string };
guild: null;
rawData: { id: string; member: { roles: string[] } };
values?: string[];
reply: ReturnType<typeof vi.fn>;
followUp: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
acknowledge: ReturnType<typeof vi.fn>;
client: object;
};
function createModelPickerContext(): ModelPickerContext {
const cfg = {
channels: {
discord: {
dm: {
enabled: true,
policy: "open",
},
},
},
} as unknown as OpenClawConfig;
return {
cfg,
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
sessionPrefix: "discord:slash",
};
}
function createInteraction(params?: { userId?: string; values?: string[] }): MockInteraction {
const userId = params?.userId ?? "owner";
return {
user: {
id: userId,
username: "tester",
globalName: "Tester",
},
channel: {
type: ChannelType.DM,
id: "dm-1",
},
guild: null,
rawData: {
id: "interaction-1",
member: { roles: [] },
},
values: params?.values,
reply: vi.fn().mockResolvedValue({ ok: true }),
followUp: vi.fn().mockResolvedValue({ ok: true }),
update: vi.fn().mockResolvedValue({ ok: true }),
acknowledge: vi.fn().mockResolvedValue({ ok: true }),
client: {},
};
}
describe("Discord model picker interactions", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("registers distinct fallback ids for button and select handlers", () => {
const context = createModelPickerContext();
const button = createDiscordModelPickerFallbackButton(context);
const select = createDiscordModelPickerFallbackSelect(context);
expect(button.customId).not.toBe(select.customId);
expect(button.customId.split(":")[0]).toBe(select.customId.split(":")[0]);
});
it("ignores interactions from users other than the picker owner", async () => {
const context = createModelPickerContext();
const loadSpy = vi.spyOn(modelPickerModule, "loadDiscordModelPickerData");
const button = createDiscordModelPickerFallbackButton(context);
const interaction = createInteraction({ userId: "intruder" });
const data: PickerButtonData = {
cmd: "model",
act: "back",
view: "providers",
u: "owner",
pg: "1",
};
await button.run(interaction as unknown as PickerButtonInteraction, data);
expect(interaction.acknowledge).toHaveBeenCalledTimes(1);
expect(interaction.update).not.toHaveBeenCalled();
expect(loadSpy).not.toHaveBeenCalled();
});
it("requires submit click before routing selected model through /model pipeline", async () => {
const context = createModelPickerContext();
const pickerData = createModelsProviderData({
openai: ["gpt-4.1", "gpt-4o"],
anthropic: ["claude-sonnet-4-5"],
});
const modelCommand: ChatCommandDefinition = {
key: "model",
nativeName: "model",
description: "Switch model",
textAliases: ["/model"],
acceptsArgs: true,
argsParsing: "none" as CommandArgsParsing,
scope: "native",
};
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockImplementation((name) =>
name === "model" ? modelCommand : undefined,
);
vi.spyOn(commandRegistryModule, "listChatCommands").mockReturnValue([modelCommand]);
vi.spyOn(commandRegistryModule, "resolveCommandArgMenu").mockReturnValue(null);
const dispatchSpy = vi
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
.mockResolvedValue({} as never);
const select = createDiscordModelPickerFallbackSelect(context);
const selectInteraction = createInteraction({
userId: "owner",
values: ["gpt-4o"],
});
const selectData: PickerSelectData = {
cmd: "model",
act: "model",
view: "models",
u: "owner",
p: "openai",
pg: "1",
};
await select.run(selectInteraction as unknown as PickerSelectInteraction, selectData);
expect(selectInteraction.update).toHaveBeenCalledTimes(1);
expect(dispatchSpy).not.toHaveBeenCalled();
const button = createDiscordModelPickerFallbackButton(context);
const submitInteraction = createInteraction({ userId: "owner" });
const submitData: PickerButtonData = {
cmd: "model",
act: "submit",
view: "models",
u: "owner",
p: "openai",
pg: "1",
mi: "2",
};
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
expect(submitInteraction.update).toHaveBeenCalledTimes(1);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
ctx?: {
CommandBody?: string;
CommandArgs?: { values?: { model?: string } };
CommandTargetSessionKey?: string;
};
};
expect(dispatchCall.ctx?.CommandBody).toBe("/model openai/gpt-4o");
expect(dispatchCall.ctx?.CommandArgs?.values?.model).toBe("openai/gpt-4o");
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBeDefined();
});
it("shows timeout status and skips recents write when apply is still processing", async () => {
const context = createModelPickerContext();
const pickerData = createModelsProviderData({
openai: ["gpt-4.1", "gpt-4o"],
anthropic: ["claude-sonnet-4-5"],
});
const modelCommand: ChatCommandDefinition = {
key: "model",
nativeName: "model",
description: "Switch model",
textAliases: ["/model"],
acceptsArgs: true,
argsParsing: "none" as CommandArgsParsing,
scope: "native",
};
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockImplementation((name) =>
name === "model" ? modelCommand : undefined,
);
vi.spyOn(commandRegistryModule, "listChatCommands").mockReturnValue([modelCommand]);
vi.spyOn(commandRegistryModule, "resolveCommandArgMenu").mockReturnValue(null);
const recordRecentSpy = vi
.spyOn(modelPickerPreferencesModule, "recordDiscordModelPickerRecentModel")
.mockResolvedValue();
const dispatchSpy = vi
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
.mockImplementation(() => new Promise(() => {}) as never);
const withTimeoutSpy = vi
.spyOn(timeoutModule, "withTimeout")
.mockRejectedValue(new Error("timeout"));
const select = createDiscordModelPickerFallbackSelect(context);
const selectInteraction = createInteraction({
userId: "owner",
values: ["gpt-4o"],
});
const selectData: PickerSelectData = {
cmd: "model",
act: "model",
view: "models",
u: "owner",
p: "openai",
pg: "1",
};
await select.run(selectInteraction as unknown as PickerSelectInteraction, selectData);
const button = createDiscordModelPickerFallbackButton(context);
const submitInteraction = createInteraction({ userId: "owner" });
const submitData: PickerButtonData = {
cmd: "model",
act: "submit",
view: "models",
u: "owner",
p: "openai",
pg: "1",
mi: "2",
};
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
expect(withTimeoutSpy).toHaveBeenCalledTimes(1);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
expect(submitInteraction.followUp).toHaveBeenCalledTimes(1);
const followUpPayload = submitInteraction.followUp.mock.calls[0]?.[0] as {
components?: Array<{ components?: Array<{ content?: string }> }>;
};
const followUpText = JSON.stringify(followUpPayload);
expect(followUpText).toContain("still processing");
expect(recordRecentSpy).not.toHaveBeenCalled();
});
it("clicking Recents button renders recents view", async () => {
const context = createModelPickerContext();
const pickerData = createModelsProviderData({
openai: ["gpt-4.1", "gpt-4o"],
anthropic: ["claude-sonnet-4-5"],
});
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
vi.spyOn(modelPickerPreferencesModule, "readDiscordModelPickerRecentModels").mockResolvedValue([
"openai/gpt-4o",
"anthropic/claude-sonnet-4-5",
]);
const button = createDiscordModelPickerFallbackButton(context);
const interaction = createInteraction({ userId: "owner" });
const data: PickerButtonData = {
cmd: "model",
act: "recents",
view: "recents",
u: "owner",
p: "openai",
pg: "1",
};
await button.run(interaction as unknown as PickerButtonInteraction, data);
expect(interaction.update).toHaveBeenCalledTimes(1);
const updatePayload = interaction.update.mock.calls[0]?.[0];
expect(updatePayload).toBeDefined();
expect(updatePayload.components).toBeDefined();
});
it("clicking recents model button applies model through /model pipeline", async () => {
const context = createModelPickerContext();
const pickerData = createModelsProviderData({
openai: ["gpt-4.1", "gpt-4o"],
anthropic: ["claude-sonnet-4-5"],
});
const modelCommand: ChatCommandDefinition = {
key: "model",
nativeName: "model",
description: "Switch model",
textAliases: ["/model"],
acceptsArgs: true,
argsParsing: "none" as CommandArgsParsing,
scope: "native",
};
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
vi.spyOn(modelPickerPreferencesModule, "readDiscordModelPickerRecentModels").mockResolvedValue([
"openai/gpt-4o",
"anthropic/claude-sonnet-4-5",
]);
vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockImplementation((name) =>
name === "model" ? modelCommand : (undefined as never),
);
vi.spyOn(commandRegistryModule, "listChatCommands").mockReturnValue([modelCommand]);
vi.spyOn(commandRegistryModule, "resolveCommandArgMenu").mockReturnValue(null);
const dispatchSpy = vi
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
.mockResolvedValue({} as never);
const button = createDiscordModelPickerFallbackButton(context);
const submitInteraction = createInteraction({ userId: "owner" });
// rs=2 → first deduped recent (default is anthropic/claude-sonnet-4-5, so openai/gpt-4o remains)
const submitData: PickerButtonData = {
cmd: "model",
act: "submit",
view: "recents",
u: "owner",
pg: "1",
rs: "2",
};
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
expect(submitInteraction.update).toHaveBeenCalledTimes(1);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
ctx?: {
CommandBody?: string;
CommandArgs?: { values?: { model?: string } };
};
};
expect(dispatchCall.ctx?.CommandBody).toBe("/model openai/gpt-4o");
expect(dispatchCall.ctx?.CommandArgs?.values?.model).toBe("openai/gpt-4o");
});
});

View File

@@ -2,12 +2,16 @@ import {
Button,
ChannelType,
Command,
Container,
Row,
StringSelectMenu,
TextDisplay,
type AutocompleteInteraction,
type ButtonInteraction,
type CommandInteraction,
type CommandOptions,
type ComponentData,
type StringSelectMenuInteraction,
} from "@buape/carbon";
import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10";
import { resolveHumanDelayConfig } from "../../agents/identity.js";
@@ -29,11 +33,14 @@ import {
serializeCommandArgs,
} from "../../auto-reply/commands-registry.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { resolveStoredModelOverride } from "../../auto-reply/reply/model-selection.js";
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
import type { OpenClawConfig, loadConfig } from "../../config/config.js";
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
@@ -43,6 +50,7 @@ import {
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
import { chunkItems } from "../../utils/chunk-items.js";
import { withTimeout } from "../../utils/with-timeout.js";
import { loadWebMedia } from "../../web/media.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import {
@@ -56,6 +64,21 @@ import {
resolveDiscordOwnerAllowFrom,
} from "./allow-list.js";
import { resolveDiscordChannelInfo } from "./message-utils.js";
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";
import { resolveDiscordSenderIdentity } from "./sender-identity.js";
import { resolveDiscordThreadParentInfo } from "./threading.js";
@@ -196,7 +219,7 @@ async function safeDiscordInteractionCall<T>(
return await fn();
} catch (error) {
if (isDiscordUnknownInteraction(error)) {
console.warn(`discord: ${label} skipped (interaction expired)`);
logVerbose(`discord: ${label} skipped (interaction expired)`);
return null;
}
throw error;
@@ -247,6 +270,634 @@ type DiscordCommandArgContext = {
sessionPrefix: string;
};
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;
}) {
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;
}
return resolveAgentRoute({
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,
});
}
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;
preferFollowUp: boolean;
}) {
const data = await loadDiscordModelPickerData(params.cfg);
const route = await resolveDiscordModelPickerRoute({
interaction: params.interaction,
cfg: params.cfg,
accountId: params.accountId,
});
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,
});
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,
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;
}
}
async function handleDiscordCommandArgInteraction(
interaction: ButtonInteraction,
data: ComponentData,
@@ -278,13 +929,13 @@ async function handleDiscordCommandArgInteraction(
);
return;
}
const updated = await safeDiscordInteractionCall("command arg update", () =>
const argUpdateResult = await safeDiscordInteractionCall("command arg update", () =>
interaction.update({
content: `✅ Selected ${parsed.value}.`,
components: [],
}),
);
if (!updated) {
if (argUpdateResult === null) {
return;
}
const commandArgs = createCommandArgsWithValue({
@@ -364,6 +1015,46 @@ export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgC
return new DiscordCommandArgFallbackButton(params);
}
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);
}
function buildDiscordCommandArgMenu(params: {
command: ChatCommandDefinition;
menu: {
@@ -479,7 +1170,7 @@ export function createDiscordNativeCommand(params: {
}
async function dispatchDiscordCommandInteraction(params: {
interaction: CommandInteraction | ButtonInteraction;
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
prompt: string;
command: ChatCommandDefinition;
commandArgs?: CommandArgs;
@@ -488,6 +1179,7 @@ async function dispatchDiscordCommandInteraction(params: {
accountId: string;
sessionPrefix: string;
preferFollowUp: boolean;
suppressReplies?: boolean;
}) {
const {
interaction,
@@ -499,6 +1191,7 @@ async function dispatchDiscordCommandInteraction(params: {
accountId,
sessionPrefix,
preferFollowUp,
suppressReplies,
} = params;
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
const payload = {
@@ -719,6 +1412,22 @@ async function dispatchDiscordCommandInteraction(params: {
return;
}
const pickerCommandContext = shouldOpenDiscordModelPickerFromCommand({
command,
commandArgs,
});
if (pickerCommandContext) {
await replyWithDiscordModelPickerProviders({
interaction,
cfg,
command: pickerCommandContext,
userId: user.id,
accountId,
preferFollowUp,
});
return;
}
const isGuild = Boolean(interaction.guild);
const channelId = rawChannelId || "unknown";
const interactionId = interaction.rawData.id;
@@ -813,6 +1522,9 @@ async function dispatchDiscordCommandInteraction(params: {
...prefixOptions,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
if (suppressReplies) {
return;
}
try {
await deliverDiscordInteractionReply({
interaction,
@@ -827,7 +1539,7 @@ async function dispatchDiscordCommandInteraction(params: {
});
} catch (error) {
if (isDiscordUnknownInteraction(error)) {
console.warn("discord: interaction reply skipped (interaction expired)");
logVerbose("discord: interaction reply skipped (interaction expired)");
return;
}
throw error;
@@ -850,7 +1562,7 @@ async function dispatchDiscordCommandInteraction(params: {
}
async function deliverDiscordInteractionReply(params: {
interaction: CommandInteraction | ButtonInteraction;
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
payload: ReplyPayload;
mediaLocalRoots?: readonly string[];
textLimit: number;

View File

@@ -67,6 +67,8 @@ import {
import { createDiscordMessageHandler } from "./message-handler.js";
import {
createDiscordCommandArgFallbackButton,
createDiscordModelPickerFallbackButton,
createDiscordModelPickerFallbackSelect,
createDiscordNativeCommand,
} from "./native-command.js";
import { resolveDiscordPresenceUpdate } from "./presence.js";
@@ -481,6 +483,18 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
accountId: account.accountId,
sessionPrefix,
}),
createDiscordModelPickerFallbackButton({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
}),
createDiscordModelPickerFallbackSelect({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
}),
];
const modals: Modal[] = [];