mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-31 14:58:36 +00:00
Summary: - Add alpha-bucket selects when the Discord provider/model picker exceeds select-menu limits. - Split bucket/runtime lookup helpers and keep compact recents runtime decoding provider-scoped. Verification: - node scripts/run-vitest.mjs --config test/vitest/vitest.extension-discord.config.ts extensions/discord/src/monitor/model-picker.test.ts extensions/discord/src/monitor/native-command.model-picker.test.ts - node scripts/run-tsgo.mjs - git diff --check origin/main...HEAD - autoreview --mode local: no accepted/actionable findings - CI run 26468173320, OpenGrep run 26468171525, CodeQL Critical Quality run 26468171885 Co-authored-by: rendrag-git <253747599+rendrag-git@users.noreply.github.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org> Co-authored-by: Peter Steinberger <steipete@gmail.com>
373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
|
|
import {
|
|
resolveStoredModelOverride,
|
|
serializeCommandArgs,
|
|
type ChatCommandDefinition,
|
|
type CommandArgs,
|
|
} from "openclaw/plugin-sdk/command-auth-native";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
|
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
|
import {
|
|
normalizeLowercaseStringOrEmpty,
|
|
normalizeOptionalString,
|
|
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import {
|
|
Container,
|
|
TextDisplay,
|
|
type AutocompleteInteraction,
|
|
type ButtonInteraction,
|
|
type CommandInteraction,
|
|
type StringSelectMenuInteraction,
|
|
} from "../internal/discord.js";
|
|
import {
|
|
readDiscordModelPickerRecentModels,
|
|
type DiscordModelPickerPreferenceScope,
|
|
} from "./model-picker-preferences.js";
|
|
import {
|
|
findProviderBucketLocation,
|
|
loadDiscordModelPickerData,
|
|
renderDiscordModelPickerModelsView,
|
|
resolveDiscordModelPickerPageForModel,
|
|
toDiscordModelPickerMessagePayload,
|
|
type DiscordModelPickerCommandContext,
|
|
} from "./model-picker.js";
|
|
import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js";
|
|
import type { SafeDiscordInteractionCall } from "./native-command-ui.types.js";
|
|
import { resolveDiscordNativeInteractionChannelContext } from "./native-interaction-channel-context.js";
|
|
import type { ThreadBindingManager } from "./thread-bindings.js";
|
|
|
|
type DiscordNativeChoiceInteraction =
|
|
| AutocompleteInteraction
|
|
| CommandInteraction
|
|
| ButtonInteraction
|
|
| StringSelectMenuInteraction;
|
|
|
|
function resolveDiscordModelPickerCommandContext(
|
|
command: ChatCommandDefinition,
|
|
): DiscordModelPickerCommandContext | null {
|
|
const normalized = normalizeLowercaseStringOrEmpty(command.nativeName ?? command.key);
|
|
if (normalized === "model" || normalized === "models") {
|
|
return normalized;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveCommandArgStringValue(args: CommandArgs | undefined, key: string): string {
|
|
const value = args?.values?.[key];
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
export function shouldOpenDiscordModelPickerFromCommand(params: {
|
|
command: ChatCommandDefinition;
|
|
commandArgs?: CommandArgs;
|
|
}): DiscordModelPickerCommandContext | null {
|
|
const context = resolveDiscordModelPickerCommandContext(params.command);
|
|
if (!context) {
|
|
return null;
|
|
}
|
|
|
|
const serializedArgs =
|
|
normalizeOptionalString(serializeCommandArgs(params.command, params.commandArgs)) ?? "";
|
|
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}`;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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,
|
|
};
|
|
}
|
|
|
|
export function buildDiscordModelPickerNoticePayload(message: string): { components: Container[] } {
|
|
return {
|
|
components: [new Container([new TextDisplay(message)])],
|
|
};
|
|
}
|
|
|
|
async function resolveDiscordModelPickerRouteState(params: {
|
|
interaction:
|
|
| CommandInteraction
|
|
| ButtonInteraction
|
|
| StringSelectMenuInteraction
|
|
| AutocompleteInteraction;
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
threadBindings: ThreadBindingManager;
|
|
enforceConfiguredBindingReadiness?: boolean;
|
|
}) {
|
|
const { interaction, cfg, accountId } = params;
|
|
const { isDirectMessage, isGroupDm, isThreadChannel, rawChannelId, threadParentId } =
|
|
await resolveDiscordNativeInteractionChannelContext({
|
|
channel: interaction.channel,
|
|
client: interaction.client,
|
|
hasGuild: Boolean(interaction.guild),
|
|
channelIdFallback: "unknown",
|
|
});
|
|
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
|
|
? interaction.rawData.member.roles.map((roleId: string) => roleId)
|
|
: [];
|
|
|
|
const threadBinding = isThreadChannel
|
|
? params.threadBindings.getByThreadId(rawChannelId)
|
|
: undefined;
|
|
return await resolveDiscordNativeInteractionRouteState({
|
|
cfg,
|
|
accountId,
|
|
guildId: interaction.guild?.id ?? undefined,
|
|
memberRoleIds,
|
|
isDirectMessage,
|
|
isGroupDm,
|
|
directUserId: interaction.user?.id ?? rawChannelId,
|
|
conversationId: rawChannelId,
|
|
parentConversationId: threadParentId,
|
|
threadBinding,
|
|
enforceConfiguredBindingReadiness: params.enforceConfiguredBindingReadiness,
|
|
});
|
|
}
|
|
|
|
export async function resolveDiscordModelPickerRoute(params: {
|
|
interaction:
|
|
| CommandInteraction
|
|
| ButtonInteraction
|
|
| StringSelectMenuInteraction
|
|
| AutocompleteInteraction;
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
threadBindings: ThreadBindingManager;
|
|
}) {
|
|
const resolved = await resolveDiscordModelPickerRouteState(params);
|
|
return resolved.effectiveRoute;
|
|
}
|
|
|
|
export async function resolveDiscordNativeChoiceContext(params: {
|
|
interaction: DiscordNativeChoiceInteraction;
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
threadBindings: ThreadBindingManager;
|
|
}): Promise<{ provider?: string; model?: string } | null> {
|
|
try {
|
|
const resolved = await resolveDiscordModelPickerRouteState({
|
|
interaction: params.interaction,
|
|
cfg: params.cfg,
|
|
accountId: params.accountId,
|
|
threadBindings: params.threadBindings,
|
|
enforceConfiguredBindingReadiness: true,
|
|
});
|
|
if (resolved.bindingReadiness && !resolved.bindingReadiness.ok) {
|
|
return null;
|
|
}
|
|
const route = resolved.effectiveRoute;
|
|
const fallback = resolveDefaultModelForAgent({
|
|
cfg: params.cfg,
|
|
agentId: route.agentId,
|
|
});
|
|
const storePath = resolveStorePath(params.cfg.session?.store, {
|
|
agentId: route.agentId,
|
|
});
|
|
const sessionStore = loadSessionStore(storePath);
|
|
const sessionEntry = sessionStore[route.sessionKey];
|
|
const override = resolveStoredModelOverride({
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey: route.sessionKey,
|
|
defaultProvider: fallback.provider,
|
|
});
|
|
if (!override?.model) {
|
|
return {
|
|
provider: fallback.provider,
|
|
model: fallback.model,
|
|
};
|
|
}
|
|
return {
|
|
provider: override.provider || fallback.provider,
|
|
model: override.model,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function resolveDiscordModelPickerCurrentModel(params: {
|
|
cfg: OpenClawConfig;
|
|
route: ResolvedAgentRoute;
|
|
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>;
|
|
}): string {
|
|
const fallback = buildDiscordModelPickerCurrentModel(
|
|
params.data.resolvedDefault.provider,
|
|
params.data.resolvedDefault.model,
|
|
);
|
|
try {
|
|
const storePath = resolveStorePath(params.cfg.session?.store, {
|
|
agentId: params.route.agentId,
|
|
});
|
|
const sessionStore = loadSessionStore(storePath, { skipCache: true });
|
|
const sessionEntry = sessionStore[params.route.sessionKey];
|
|
const override = resolveStoredModelOverride({
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey: params.route.sessionKey,
|
|
defaultProvider: params.data.resolvedDefault.provider,
|
|
});
|
|
if (!override?.model) {
|
|
return fallback;
|
|
}
|
|
const provider = (override.provider || params.data.resolvedDefault.provider).trim();
|
|
if (!provider) {
|
|
return fallback;
|
|
}
|
|
return `${provider}/${override.model}`;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
export function resolveDiscordModelPickerCurrentRuntime(params: {
|
|
cfg: OpenClawConfig;
|
|
route: ResolvedAgentRoute;
|
|
}): string {
|
|
try {
|
|
const storePath = resolveStorePath(params.cfg.session?.store, {
|
|
agentId: params.route.agentId,
|
|
});
|
|
const sessionStore = loadSessionStore(storePath, { skipCache: true });
|
|
const sessionRuntime = normalizeOptionalString(
|
|
sessionStore[params.route.sessionKey]?.agentRuntimeOverride,
|
|
);
|
|
if (sessionRuntime) {
|
|
return sessionRuntime;
|
|
}
|
|
} catch {}
|
|
|
|
return "auto";
|
|
}
|
|
|
|
export async function replyWithDiscordModelPickerProviders(params: {
|
|
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
|
cfg: OpenClawConfig;
|
|
command: DiscordModelPickerCommandContext;
|
|
userId: string;
|
|
accountId: string;
|
|
threadBindings: ThreadBindingManager;
|
|
preferFollowUp: boolean;
|
|
safeInteractionCall: SafeDiscordInteractionCall;
|
|
}) {
|
|
const route = await resolveDiscordModelPickerRoute({
|
|
interaction: params.interaction,
|
|
cfg: params.cfg,
|
|
accountId: params.accountId,
|
|
threadBindings: params.threadBindings,
|
|
});
|
|
const data = await loadDiscordModelPickerData(params.cfg, route.agentId);
|
|
const currentModel = resolveDiscordModelPickerCurrentModel({
|
|
cfg: params.cfg,
|
|
route,
|
|
data,
|
|
});
|
|
const currentRuntime = resolveDiscordModelPickerCurrentRuntime({
|
|
cfg: params.cfg,
|
|
route,
|
|
});
|
|
const quickModels = await readDiscordModelPickerRecentModels({
|
|
scope: resolveDiscordModelPickerPreferenceScope({
|
|
interaction: params.interaction,
|
|
accountId: params.accountId,
|
|
userId: params.userId,
|
|
}),
|
|
allowedModelRefs: buildDiscordModelPickerAllowedModelRefs(data),
|
|
limit: 5,
|
|
});
|
|
const parsedCurrentRef = splitDiscordModelRef(currentModel ?? "");
|
|
const initialProvider =
|
|
parsedCurrentRef && data.byProvider.has(parsedCurrentRef.provider)
|
|
? parsedCurrentRef.provider
|
|
: (data.providers[0] ?? data.resolvedDefault.provider);
|
|
const initialResolved =
|
|
parsedCurrentRef && parsedCurrentRef.provider === initialProvider
|
|
? resolveDiscordModelPickerPageForModel({
|
|
data,
|
|
provider: initialProvider,
|
|
model: parsedCurrentRef.model,
|
|
})
|
|
: { page: 1 };
|
|
const initialPage = initialResolved.page;
|
|
const initialModelBucket = initialResolved.bucket;
|
|
const initialProviderLocation = findProviderBucketLocation(data, initialProvider);
|
|
|
|
const rendered = renderDiscordModelPickerModelsView({
|
|
command: params.command,
|
|
userId: params.userId,
|
|
data,
|
|
provider: initialProvider,
|
|
page: initialPage,
|
|
providerPage: initialProviderLocation?.page ?? 1,
|
|
providerBucket: initialProviderLocation?.bucket,
|
|
modelBucket: initialModelBucket,
|
|
currentModel,
|
|
currentRuntime,
|
|
quickModels,
|
|
});
|
|
const payload = {
|
|
...toDiscordModelPickerMessagePayload(rendered),
|
|
ephemeral: true,
|
|
};
|
|
|
|
await params.safeInteractionCall("model picker reply", async () => {
|
|
if (params.preferFollowUp) {
|
|
await params.interaction.followUp(payload);
|
|
return;
|
|
}
|
|
await params.interaction.reply(payload);
|
|
});
|
|
}
|
|
|
|
export 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 };
|
|
}
|