mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-29 01:31:18 +00:00
fix: restore Discord model picker UX (#21458) (thanks @pejmanjohn)
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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: 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.
|
- 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.
|
- 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/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.
|
- 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: 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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.
|
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 attachments:
|
||||||
|
|
||||||
- `file` blocks must point to an attachment reference (`attachment://<filename>`)
|
- `file` blocks must point to an attachment reference (`attachment://<filename>`)
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ You can switch models for the current session without restarting:
|
|||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `/model` (and `/model list`) is a compact, numbered picker (model family + available providers).
|
- `/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 <#>` selects from that picker.
|
||||||
- `/model status` is the detailed view (auth candidates and, when configured, provider endpoint `baseUrl` + `api` mode).
|
- `/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>`.
|
- Model refs are parsed by splitting on the **first** `/`. Use `provider/model` when typing `/model <ref>`.
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ Examples:
|
|||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `/model` and `/model list` show a compact, numbered picker (model family + available providers).
|
- `/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 <#>` 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.
|
- `/model status` shows the detailed view, including configured provider endpoint (`baseUrl`) and API mode (`api`) when available.
|
||||||
|
|
||||||
|
|||||||
193
src/discord/monitor/model-picker-preferences.ts
Normal file
193
src/discord/monitor/model-picker-preferences.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
626
src/discord/monitor/model-picker.test.ts
Normal file
626
src/discord/monitor/model-picker.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
937
src/discord/monitor/model-picker.ts
Normal file
937
src/discord/monitor/model-picker.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
378
src/discord/monitor/native-command.model-picker.test.ts
Normal file
378
src/discord/monitor/native-command.model-picker.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,12 +2,16 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
ChannelType,
|
ChannelType,
|
||||||
Command,
|
Command,
|
||||||
|
Container,
|
||||||
Row,
|
Row,
|
||||||
|
StringSelectMenu,
|
||||||
|
TextDisplay,
|
||||||
type AutocompleteInteraction,
|
type AutocompleteInteraction,
|
||||||
type ButtonInteraction,
|
type ButtonInteraction,
|
||||||
type CommandInteraction,
|
type CommandInteraction,
|
||||||
type CommandOptions,
|
type CommandOptions,
|
||||||
type ComponentData,
|
type ComponentData,
|
||||||
|
type StringSelectMenuInteraction,
|
||||||
} from "@buape/carbon";
|
} from "@buape/carbon";
|
||||||
import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10";
|
import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10";
|
||||||
import { resolveHumanDelayConfig } from "../../agents/identity.js";
|
import { resolveHumanDelayConfig } from "../../agents/identity.js";
|
||||||
@@ -29,11 +33,14 @@ import {
|
|||||||
serializeCommandArgs,
|
serializeCommandArgs,
|
||||||
} from "../../auto-reply/commands-registry.js";
|
} from "../../auto-reply/commands-registry.js";
|
||||||
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.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 { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
||||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
||||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||||
import type { OpenClawConfig, loadConfig } from "../../config/config.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 { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
||||||
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +50,7 @@ import {
|
|||||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||||
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
|
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
|
||||||
import { chunkItems } from "../../utils/chunk-items.js";
|
import { chunkItems } from "../../utils/chunk-items.js";
|
||||||
|
import { withTimeout } from "../../utils/with-timeout.js";
|
||||||
import { loadWebMedia } from "../../web/media.js";
|
import { loadWebMedia } from "../../web/media.js";
|
||||||
import { chunkDiscordTextWithMode } from "../chunk.js";
|
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +64,21 @@ import {
|
|||||||
resolveDiscordOwnerAllowFrom,
|
resolveDiscordOwnerAllowFrom,
|
||||||
} from "./allow-list.js";
|
} from "./allow-list.js";
|
||||||
import { resolveDiscordChannelInfo } from "./message-utils.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 { resolveDiscordSenderIdentity } from "./sender-identity.js";
|
||||||
import { resolveDiscordThreadParentInfo } from "./threading.js";
|
import { resolveDiscordThreadParentInfo } from "./threading.js";
|
||||||
|
|
||||||
@@ -196,7 +219,7 @@ async function safeDiscordInteractionCall<T>(
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isDiscordUnknownInteraction(error)) {
|
if (isDiscordUnknownInteraction(error)) {
|
||||||
console.warn(`discord: ${label} skipped (interaction expired)`);
|
logVerbose(`discord: ${label} skipped (interaction expired)`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -247,6 +270,634 @@ type DiscordCommandArgContext = {
|
|||||||
sessionPrefix: string;
|
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(
|
async function handleDiscordCommandArgInteraction(
|
||||||
interaction: ButtonInteraction,
|
interaction: ButtonInteraction,
|
||||||
data: ComponentData,
|
data: ComponentData,
|
||||||
@@ -278,13 +929,13 @@ async function handleDiscordCommandArgInteraction(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const updated = await safeDiscordInteractionCall("command arg update", () =>
|
const argUpdateResult = await safeDiscordInteractionCall("command arg update", () =>
|
||||||
interaction.update({
|
interaction.update({
|
||||||
content: `✅ Selected ${parsed.value}.`,
|
content: `✅ Selected ${parsed.value}.`,
|
||||||
components: [],
|
components: [],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (!updated) {
|
if (argUpdateResult === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const commandArgs = createCommandArgsWithValue({
|
const commandArgs = createCommandArgsWithValue({
|
||||||
@@ -364,6 +1015,46 @@ export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgC
|
|||||||
return new DiscordCommandArgFallbackButton(params);
|
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: {
|
function buildDiscordCommandArgMenu(params: {
|
||||||
command: ChatCommandDefinition;
|
command: ChatCommandDefinition;
|
||||||
menu: {
|
menu: {
|
||||||
@@ -479,7 +1170,7 @@ export function createDiscordNativeCommand(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchDiscordCommandInteraction(params: {
|
async function dispatchDiscordCommandInteraction(params: {
|
||||||
interaction: CommandInteraction | ButtonInteraction;
|
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
command: ChatCommandDefinition;
|
command: ChatCommandDefinition;
|
||||||
commandArgs?: CommandArgs;
|
commandArgs?: CommandArgs;
|
||||||
@@ -488,6 +1179,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
accountId: string;
|
accountId: string;
|
||||||
sessionPrefix: string;
|
sessionPrefix: string;
|
||||||
preferFollowUp: boolean;
|
preferFollowUp: boolean;
|
||||||
|
suppressReplies?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
interaction,
|
interaction,
|
||||||
@@ -499,6 +1191,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
accountId,
|
accountId,
|
||||||
sessionPrefix,
|
sessionPrefix,
|
||||||
preferFollowUp,
|
preferFollowUp,
|
||||||
|
suppressReplies,
|
||||||
} = params;
|
} = params;
|
||||||
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
|
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -719,6 +1412,22 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
return;
|
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 isGuild = Boolean(interaction.guild);
|
||||||
const channelId = rawChannelId || "unknown";
|
const channelId = rawChannelId || "unknown";
|
||||||
const interactionId = interaction.rawData.id;
|
const interactionId = interaction.rawData.id;
|
||||||
@@ -813,6 +1522,9 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
...prefixOptions,
|
...prefixOptions,
|
||||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
|
if (suppressReplies) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await deliverDiscordInteractionReply({
|
await deliverDiscordInteractionReply({
|
||||||
interaction,
|
interaction,
|
||||||
@@ -827,7 +1539,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isDiscordUnknownInteraction(error)) {
|
if (isDiscordUnknownInteraction(error)) {
|
||||||
console.warn("discord: interaction reply skipped (interaction expired)");
|
logVerbose("discord: interaction reply skipped (interaction expired)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -850,7 +1562,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deliverDiscordInteractionReply(params: {
|
async function deliverDiscordInteractionReply(params: {
|
||||||
interaction: CommandInteraction | ButtonInteraction;
|
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
||||||
payload: ReplyPayload;
|
payload: ReplyPayload;
|
||||||
mediaLocalRoots?: readonly string[];
|
mediaLocalRoots?: readonly string[];
|
||||||
textLimit: number;
|
textLimit: number;
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ import {
|
|||||||
import { createDiscordMessageHandler } from "./message-handler.js";
|
import { createDiscordMessageHandler } from "./message-handler.js";
|
||||||
import {
|
import {
|
||||||
createDiscordCommandArgFallbackButton,
|
createDiscordCommandArgFallbackButton,
|
||||||
|
createDiscordModelPickerFallbackButton,
|
||||||
|
createDiscordModelPickerFallbackSelect,
|
||||||
createDiscordNativeCommand,
|
createDiscordNativeCommand,
|
||||||
} from "./native-command.js";
|
} from "./native-command.js";
|
||||||
import { resolveDiscordPresenceUpdate } from "./presence.js";
|
import { resolveDiscordPresenceUpdate } from "./presence.js";
|
||||||
@@ -481,6 +483,18 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
sessionPrefix,
|
sessionPrefix,
|
||||||
}),
|
}),
|
||||||
|
createDiscordModelPickerFallbackButton({
|
||||||
|
cfg,
|
||||||
|
discordConfig: discordCfg,
|
||||||
|
accountId: account.accountId,
|
||||||
|
sessionPrefix,
|
||||||
|
}),
|
||||||
|
createDiscordModelPickerFallbackSelect({
|
||||||
|
cfg,
|
||||||
|
discordConfig: discordCfg,
|
||||||
|
accountId: account.accountId,
|
||||||
|
sessionPrefix,
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
const modals: Modal[] = [];
|
const modals: Modal[] = [];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user