mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
refactor(discord): isolate model picker apply flow
This commit is contained in:
35
extensions/discord/src/monitor/native-command-dispatch.ts
Normal file
35
extensions/discord/src/monitor/native-command-dispatch.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
ButtonInteraction,
|
||||
CommandInteraction,
|
||||
StringSelectMenuInteraction,
|
||||
} from "@buape/carbon";
|
||||
import type { ChatCommandDefinition, CommandArgs } from "openclaw/plugin-sdk/command-auth";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import type { ThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
type DiscordConfig = NonNullable<OpenClawConfig["channels"]>["discord"];
|
||||
|
||||
export type DispatchDiscordCommandInteractionParams = {
|
||||
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
||||
prompt: string;
|
||||
command: ChatCommandDefinition;
|
||||
commandArgs?: CommandArgs;
|
||||
cfg: OpenClawConfig;
|
||||
discordConfig: DiscordConfig;
|
||||
accountId: string;
|
||||
sessionPrefix: string;
|
||||
preferFollowUp: boolean;
|
||||
threadBindings: ThreadBindingManager;
|
||||
responseEphemeral?: boolean;
|
||||
suppressReplies?: boolean;
|
||||
};
|
||||
|
||||
export type DispatchDiscordCommandInteractionResult = {
|
||||
accepted: boolean;
|
||||
effectiveRoute?: ResolvedAgentRoute;
|
||||
};
|
||||
|
||||
export type DispatchDiscordCommandInteraction = (
|
||||
params: DispatchDiscordCommandInteractionParams,
|
||||
) => Promise<DispatchDiscordCommandInteractionResult>;
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { ButtonInteraction, StringSelectMenuInteraction } from "@buape/carbon";
|
||||
import type { ChatCommandDefinition, CommandArgs } from "openclaw/plugin-sdk/command-auth";
|
||||
import {
|
||||
applyModelOverrideToSessionEntry,
|
||||
resolveStorePath,
|
||||
updateSessionStore,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { withTimeout } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
recordDiscordModelPickerRecentModel,
|
||||
type DiscordModelPickerPreferenceScope,
|
||||
} from "./model-picker-preferences.js";
|
||||
import type { DispatchDiscordCommandInteraction } from "./native-command-dispatch.js";
|
||||
import type { ThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
type DiscordConfig = NonNullable<OpenClawConfig["channels"]>["discord"];
|
||||
|
||||
export type DiscordModelPickerSelectionCommand = {
|
||||
prompt: string;
|
||||
command: ChatCommandDefinition;
|
||||
args?: CommandArgs;
|
||||
};
|
||||
|
||||
export type DiscordModelPickerApplyResult =
|
||||
| { status: "success"; effectiveModelRef: string; noticeMessage: string }
|
||||
| { status: "mismatch"; effectiveModelRef: string; noticeMessage: string }
|
||||
| { status: "rejected"; noticeMessage: string }
|
||||
| { status: "timeout"; noticeMessage: string }
|
||||
| { status: "failed"; noticeMessage: string };
|
||||
|
||||
async function persistDiscordModelPickerOverride(params: {
|
||||
cfg: OpenClawConfig;
|
||||
route: ResolvedAgentRoute;
|
||||
provider: string;
|
||||
model: string;
|
||||
isDefault: boolean;
|
||||
}): Promise<boolean> {
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.route.agentId,
|
||||
});
|
||||
let persisted = false;
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
const entry = store[params.route.sessionKey];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
persisted =
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry,
|
||||
selection: {
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
isDefault: params.isDefault,
|
||||
},
|
||||
markLiveSwitchPending: true,
|
||||
}).updated || persisted;
|
||||
});
|
||||
return persisted;
|
||||
}
|
||||
|
||||
export async function applyDiscordModelPickerSelection(params: {
|
||||
interaction: ButtonInteraction | StringSelectMenuInteraction;
|
||||
selectionCommand: DiscordModelPickerSelectionCommand;
|
||||
dispatchCommandInteraction: DispatchDiscordCommandInteraction;
|
||||
cfg: OpenClawConfig;
|
||||
discordConfig: DiscordConfig;
|
||||
accountId: string;
|
||||
sessionPrefix: string;
|
||||
threadBindings: ThreadBindingManager;
|
||||
route: ResolvedAgentRoute;
|
||||
resolvedModelRef: string;
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
preferenceScope: DiscordModelPickerPreferenceScope;
|
||||
settleMs: number;
|
||||
resolveCurrentModel: (route: ResolvedAgentRoute) => string;
|
||||
}): Promise<DiscordModelPickerApplyResult> {
|
||||
try {
|
||||
const dispatchResult = await withTimeout(
|
||||
params.dispatchCommandInteraction({
|
||||
interaction: params.interaction,
|
||||
prompt: params.selectionCommand.prompt,
|
||||
command: params.selectionCommand.command,
|
||||
commandArgs: params.selectionCommand.args,
|
||||
cfg: params.cfg,
|
||||
discordConfig: params.discordConfig,
|
||||
accountId: params.accountId,
|
||||
sessionPrefix: params.sessionPrefix,
|
||||
preferFollowUp: true,
|
||||
threadBindings: params.threadBindings,
|
||||
suppressReplies: true,
|
||||
}),
|
||||
12000,
|
||||
);
|
||||
if (!dispatchResult.accepted) {
|
||||
return {
|
||||
status: "rejected",
|
||||
noticeMessage: `❌ Failed to apply ${params.resolvedModelRef}. Try /model ${params.resolvedModelRef} directly.`,
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackRoute = dispatchResult.effectiveRoute ?? params.route;
|
||||
if (params.settleMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, params.settleMs));
|
||||
}
|
||||
|
||||
let effectiveModelRef = params.resolveCurrentModel(fallbackRoute);
|
||||
let persisted = effectiveModelRef === params.resolvedModelRef;
|
||||
|
||||
if (!persisted) {
|
||||
logVerbose(
|
||||
`discord: model picker override mismatch — expected ${params.resolvedModelRef} but read ${effectiveModelRef} from session key ${fallbackRoute.sessionKey}; attempting direct session override persist`,
|
||||
);
|
||||
try {
|
||||
const directlyPersisted = await persistDiscordModelPickerOverride({
|
||||
cfg: params.cfg,
|
||||
route: fallbackRoute,
|
||||
provider: params.selectedProvider,
|
||||
model: params.selectedModel,
|
||||
isDefault:
|
||||
params.selectedProvider === params.defaultProvider &&
|
||||
params.selectedModel === params.defaultModel,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
effectiveModelRef = params.resolveCurrentModel(fallbackRoute);
|
||||
persisted = effectiveModelRef === params.resolvedModelRef;
|
||||
if (!persisted) {
|
||||
logVerbose(
|
||||
`discord: direct session override persist failed — expected ${params.resolvedModelRef} but read ${effectiveModelRef} from session key ${fallbackRoute.sessionKey}`,
|
||||
);
|
||||
} else if (!directlyPersisted) {
|
||||
logVerbose(
|
||||
`discord: direct session override persist became a no-op because ${params.resolvedModelRef} was already present on re-read for session key ${fallbackRoute.sessionKey}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logVerbose(
|
||||
`discord: direct session override persist threw for session key ${fallbackRoute.sessionKey}: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (persisted) {
|
||||
await recordDiscordModelPickerRecentModel({
|
||||
scope: params.preferenceScope,
|
||||
modelRef: params.resolvedModelRef,
|
||||
limit: 5,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
return persisted
|
||||
? {
|
||||
status: "success",
|
||||
effectiveModelRef,
|
||||
noticeMessage: `✅ Model set to ${params.resolvedModelRef}.`,
|
||||
}
|
||||
: {
|
||||
status: "mismatch",
|
||||
effectiveModelRef,
|
||||
noticeMessage: `⚠️ Tried to set ${params.resolvedModelRef}, but current model is ${effectiveModelRef}.`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "timeout") {
|
||||
return {
|
||||
status: "timeout",
|
||||
noticeMessage: `⏳ Model change to ${params.resolvedModelRef} is still processing. Check /status in a few seconds.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "failed",
|
||||
noticeMessage: `❌ Failed to apply ${params.resolvedModelRef}. Try /model ${params.resolvedModelRef} directly.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -25,24 +25,16 @@ import {
|
||||
type CommandArgs,
|
||||
} from "openclaw/plugin-sdk/command-auth";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
applyModelOverrideToSessionEntry,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
updateSessionStore,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
chunkItems,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
withTimeout,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveDiscordSlashCommandConfig } from "./commands.js";
|
||||
import {
|
||||
readDiscordModelPickerRecentModels,
|
||||
recordDiscordModelPickerRecentModel,
|
||||
type DiscordModelPickerPreferenceScope,
|
||||
} from "./model-picker-preferences.js";
|
||||
import {
|
||||
@@ -55,7 +47,14 @@ import {
|
||||
toDiscordModelPickerMessagePayload,
|
||||
type DiscordModelPickerCommandContext,
|
||||
} from "./model-picker.js";
|
||||
import type { DispatchDiscordCommandInteraction } from "./native-command-dispatch.js";
|
||||
import { applyDiscordModelPickerSelection } from "./native-command-model-picker-apply.js";
|
||||
import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js";
|
||||
export type {
|
||||
DispatchDiscordCommandInteraction,
|
||||
DispatchDiscordCommandInteractionParams,
|
||||
DispatchDiscordCommandInteractionResult,
|
||||
} from "./native-command-dispatch.js";
|
||||
import { resolveDiscordNativeInteractionChannelContext } from "./native-interaction-channel-context.js";
|
||||
import type { ThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
@@ -79,30 +78,6 @@ export type DiscordCommandArgContext = {
|
||||
|
||||
export type DiscordModelPickerContext = DiscordCommandArgContext;
|
||||
|
||||
export type DispatchDiscordCommandInteractionParams = {
|
||||
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
||||
prompt: string;
|
||||
command: ChatCommandDefinition;
|
||||
commandArgs?: CommandArgs;
|
||||
cfg: OpenClawConfig;
|
||||
discordConfig: DiscordConfig;
|
||||
accountId: string;
|
||||
sessionPrefix: string;
|
||||
preferFollowUp: boolean;
|
||||
threadBindings: ThreadBindingManager;
|
||||
responseEphemeral?: boolean;
|
||||
suppressReplies?: boolean;
|
||||
};
|
||||
|
||||
export type DispatchDiscordCommandInteractionResult = {
|
||||
accepted: boolean;
|
||||
effectiveRoute?: ResolvedAgentRoute;
|
||||
};
|
||||
|
||||
export type DispatchDiscordCommandInteraction = (
|
||||
params: DispatchDiscordCommandInteractionParams,
|
||||
) => Promise<DispatchDiscordCommandInteractionResult>;
|
||||
|
||||
export type SafeDiscordInteractionCall = <T>(
|
||||
label: string,
|
||||
fn: () => Promise<T>,
|
||||
@@ -383,36 +358,6 @@ function resolveDiscordModelPickerCurrentModel(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function persistDiscordModelPickerOverride(params: {
|
||||
cfg: OpenClawConfig;
|
||||
route: ResolvedAgentRoute;
|
||||
provider: string;
|
||||
model: string;
|
||||
isDefault: boolean;
|
||||
}): Promise<boolean> {
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.route.agentId,
|
||||
});
|
||||
let persisted = false;
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
const entry = store[params.route.sessionKey];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
persisted =
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry,
|
||||
selection: {
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
isDefault: params.isDefault,
|
||||
},
|
||||
markLiveSwitchPending: true,
|
||||
}).updated || persisted;
|
||||
});
|
||||
return persisted;
|
||||
}
|
||||
|
||||
function resolveDiscordModelPickerCurrentRuntime(params: {
|
||||
cfg: OpenClawConfig;
|
||||
route: ResolvedAgentRoute;
|
||||
@@ -896,128 +841,38 @@ export async function handleDiscordModelPickerInteraction(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dispatchResult = await withTimeout(
|
||||
params.dispatchCommandInteraction({
|
||||
interaction,
|
||||
prompt: selectionCommand.prompt,
|
||||
command: selectionCommand.command,
|
||||
commandArgs: selectionCommand.args,
|
||||
const applyResult = await applyDiscordModelPickerSelection({
|
||||
interaction,
|
||||
selectionCommand,
|
||||
dispatchCommandInteraction: params.dispatchCommandInteraction,
|
||||
cfg: ctx.cfg,
|
||||
discordConfig: ctx.discordConfig,
|
||||
accountId: ctx.accountId,
|
||||
sessionPrefix: ctx.sessionPrefix,
|
||||
threadBindings: ctx.threadBindings,
|
||||
route,
|
||||
resolvedModelRef,
|
||||
selectedProvider: parsedModelRef.provider,
|
||||
selectedModel: parsedModelRef.model,
|
||||
defaultProvider: pickerData.resolvedDefault.provider,
|
||||
defaultModel: pickerData.resolvedDefault.model,
|
||||
preferenceScope,
|
||||
settleMs: ctx.postApplySettleMs ?? 250,
|
||||
resolveCurrentModel: (currentRoute) =>
|
||||
resolveDiscordModelPickerCurrentModel({
|
||||
cfg: ctx.cfg,
|
||||
discordConfig: ctx.discordConfig,
|
||||
accountId: ctx.accountId,
|
||||
sessionPrefix: ctx.sessionPrefix,
|
||||
preferFollowUp: true,
|
||||
threadBindings: ctx.threadBindings,
|
||||
suppressReplies: true,
|
||||
route: currentRoute,
|
||||
data: pickerData,
|
||||
}),
|
||||
12000,
|
||||
);
|
||||
if (!dispatchResult.accepted) {
|
||||
await params.safeInteractionCall("model picker follow-up", () =>
|
||||
interaction.followUp({
|
||||
...buildDiscordModelPickerNoticePayload(
|
||||
`❌ Failed to apply ${resolvedModelRef}. Try /model ${resolvedModelRef} directly.`,
|
||||
),
|
||||
ephemeral: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const fallbackRoute = dispatchResult.effectiveRoute ?? route;
|
||||
const settleMs = ctx.postApplySettleMs ?? 250;
|
||||
if (settleMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, settleMs));
|
||||
}
|
||||
|
||||
let effectiveModelRef = resolveDiscordModelPickerCurrentModel({
|
||||
cfg: ctx.cfg,
|
||||
route: fallbackRoute,
|
||||
data: pickerData,
|
||||
});
|
||||
let persisted = effectiveModelRef === resolvedModelRef;
|
||||
|
||||
if (!persisted) {
|
||||
logVerbose(
|
||||
`discord: model picker override mismatch — expected ${resolvedModelRef} but read ${effectiveModelRef} from session key ${fallbackRoute.sessionKey}; attempting direct session override persist`,
|
||||
);
|
||||
try {
|
||||
const directlyPersisted = await persistDiscordModelPickerOverride({
|
||||
cfg: ctx.cfg,
|
||||
route: fallbackRoute,
|
||||
provider: parsedModelRef.provider,
|
||||
model: parsedModelRef.model,
|
||||
isDefault:
|
||||
parsedModelRef.provider === pickerData.resolvedDefault.provider &&
|
||||
parsedModelRef.model === pickerData.resolvedDefault.model,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
effectiveModelRef = resolveDiscordModelPickerCurrentModel({
|
||||
cfg: ctx.cfg,
|
||||
route: fallbackRoute,
|
||||
data: pickerData,
|
||||
});
|
||||
persisted = effectiveModelRef === resolvedModelRef;
|
||||
if (!persisted) {
|
||||
logVerbose(
|
||||
`discord: direct session override persist failed — expected ${resolvedModelRef} but read ${effectiveModelRef} from session key ${fallbackRoute.sessionKey}`,
|
||||
);
|
||||
} else if (!directlyPersisted) {
|
||||
logVerbose(
|
||||
`discord: direct session override persist became a no-op because ${resolvedModelRef} was already present on re-read for session key ${fallbackRoute.sessionKey}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logVerbose(
|
||||
`discord: direct session override persist threw for session key ${fallbackRoute.sessionKey}: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (persisted) {
|
||||
await recordDiscordModelPickerRecentModel({
|
||||
scope: preferenceScope,
|
||||
modelRef: resolvedModelRef,
|
||||
limit: 5,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
await params.safeInteractionCall("model picker follow-up", () =>
|
||||
interaction.followUp({
|
||||
...buildDiscordModelPickerNoticePayload(
|
||||
persisted
|
||||
? `✅ Model set to ${resolvedModelRef}.`
|
||||
: `⚠️ Tried to set ${resolvedModelRef}, but current model is ${effectiveModelRef}.`,
|
||||
),
|
||||
ephemeral: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "timeout") {
|
||||
await params.safeInteractionCall("model picker follow-up", () =>
|
||||
interaction.followUp({
|
||||
...buildDiscordModelPickerNoticePayload(
|
||||
`⏳ Model change to ${resolvedModelRef} is still processing. Check /status in a few seconds.`,
|
||||
),
|
||||
ephemeral: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await params.safeInteractionCall("model picker follow-up", () =>
|
||||
interaction.followUp({
|
||||
...buildDiscordModelPickerNoticePayload(
|
||||
`❌ Failed to apply ${resolvedModelRef}. Try /model ${resolvedModelRef} directly.`,
|
||||
),
|
||||
ephemeral: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await params.safeInteractionCall("model picker follow-up", () =>
|
||||
interaction.followUp({
|
||||
...buildDiscordModelPickerNoticePayload(applyResult.noticeMessage),
|
||||
ephemeral: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.action === "cancel") {
|
||||
|
||||
@@ -67,6 +67,7 @@ import { resolveDiscordChannelTopicSafe } from "./channel-access.js";
|
||||
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
|
||||
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
|
||||
import { buildDiscordNativeCommandContext } from "./native-command-context.js";
|
||||
import type { DispatchDiscordCommandInteractionResult } from "./native-command-dispatch.js";
|
||||
import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js";
|
||||
import {
|
||||
buildDiscordCommandArgMenu,
|
||||
@@ -77,7 +78,6 @@ import {
|
||||
resolveDiscordNativeChoiceContext,
|
||||
shouldOpenDiscordModelPickerFromCommand,
|
||||
type DiscordCommandArgContext,
|
||||
type DispatchDiscordCommandInteractionResult,
|
||||
type DiscordModelPickerContext,
|
||||
} from "./native-command-ui.js";
|
||||
import { resolveDiscordNativeInteractionChannelContext } from "./native-interaction-channel-context.js";
|
||||
|
||||
Reference in New Issue
Block a user