mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 09:12:13 +08:00
Compare commits
4 Commits
v2026.6.9
...
codex/tele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9f9661960 | ||
|
|
7154ad89c5 | ||
|
|
a8d2b54fd3 | ||
|
|
b89c57a1ba |
@@ -31,6 +31,7 @@ import {
|
||||
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime";
|
||||
import { formatModelsAvailableHeader } from "openclaw/plugin-sdk/models-provider-runtime";
|
||||
import { submitProviderSetupTextInput } from "openclaw/plugin-sdk/provider-setup-runtime";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
||||
import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
|
||||
@@ -1028,6 +1029,50 @@ export const registerTelegramHandlers = ({
|
||||
return { replyMedia, replyChain };
|
||||
};
|
||||
|
||||
const handleProviderSetupTextReply = async (msg: Message, isGroup: boolean): Promise<boolean> => {
|
||||
const text = typeof msg.text === "string" ? msg.text.trim() : "";
|
||||
const senderId = msg.from?.id != null ? String(msg.from.id) : undefined;
|
||||
if (!text || text.startsWith("/") || isGroup) {
|
||||
return false;
|
||||
}
|
||||
const providerSetup = await submitProviderSetupTextInput({
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
conversationId: `telegram:${msg.chat.id}`,
|
||||
...(senderId ? { senderId } : {}),
|
||||
text,
|
||||
});
|
||||
if (!providerSetup.handled) {
|
||||
return false;
|
||||
}
|
||||
if (providerSetup.deleteInputMessage === true) {
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "deleteMessage",
|
||||
runtime,
|
||||
fn: () => bot.api.deleteMessage(msg.chat.id, msg.message_id),
|
||||
}).catch(() => {});
|
||||
}
|
||||
const reply = providerSetup.reply;
|
||||
if (reply?.text) {
|
||||
const telegramData = reply.channelData?.telegram as
|
||||
| { buttons?: Parameters<typeof buildInlineKeyboard>[0] }
|
||||
| undefined;
|
||||
const buttons = telegramData?.buttons;
|
||||
const keyboard = Array.isArray(buttons) ? buildInlineKeyboard(buttons) : undefined;
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendMessage",
|
||||
runtime,
|
||||
fn: () =>
|
||||
bot.api.sendMessage(
|
||||
msg.chat.id,
|
||||
reply.text ?? "",
|
||||
keyboard ? { reply_markup: keyboard } : undefined,
|
||||
),
|
||||
}).catch(() => {});
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const processMessageWithReplyChain = async (
|
||||
ctx: TelegramContext,
|
||||
msg: Message,
|
||||
@@ -2703,6 +2748,9 @@ export const registerTelegramHandlers = ({
|
||||
}).sessionEntry?.sessionStartedAt,
|
||||
);
|
||||
|
||||
if (await handleProviderSetupTextReply(event.msg, event.isGroup)) {
|
||||
return;
|
||||
}
|
||||
recordMessageForReplyChain(event.msg, resolvedThreadId ?? dmThreadId);
|
||||
await processInboundMessage({
|
||||
ctx: event.ctx,
|
||||
|
||||
@@ -262,6 +262,7 @@
|
||||
"provider-auth-api-key",
|
||||
"provider-auth-result",
|
||||
"provider-auth-login",
|
||||
"provider-setup-runtime",
|
||||
"provider-selection-runtime",
|
||||
"plugin-entry",
|
||||
"provider-catalog-runtime",
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
"qa-channel-protocol",
|
||||
"qa-lab",
|
||||
"qa-runtime",
|
||||
"provider-setup-runtime",
|
||||
"test-utils"
|
||||
]
|
||||
|
||||
@@ -904,6 +904,16 @@ export function buildBuiltinChatCommands(
|
||||
acceptsArgs: true,
|
||||
category: "options",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "providers",
|
||||
nativeName: "providers",
|
||||
description: "Manage model provider authentication.",
|
||||
textAlias: "/providers",
|
||||
tier: "standard",
|
||||
argsParsing: "none",
|
||||
acceptsArgs: true,
|
||||
category: "management",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "queue",
|
||||
nativeName: "queue",
|
||||
|
||||
@@ -21,6 +21,7 @@ import { handleMcpCommand } from "./commands-mcp.js";
|
||||
import { handleModelsCommand } from "./commands-models.js";
|
||||
import { handlePluginCommand } from "./commands-plugin.js";
|
||||
import { handlePluginsCommand } from "./commands-plugins.js";
|
||||
import { handleProvidersCommand } from "./commands-providers.js";
|
||||
import {
|
||||
handleAbortTrigger,
|
||||
handleActivationCommand,
|
||||
@@ -69,6 +70,7 @@ export function loadCommandHandlers(): CommandHandler[] {
|
||||
handleAcpCommand,
|
||||
handleMcpCommand,
|
||||
handlePluginsCommand,
|
||||
handleProvidersCommand,
|
||||
handleConfigCommand,
|
||||
handleDebugCommand,
|
||||
handleModelsCommand,
|
||||
|
||||
86
src/auto-reply/reply/commands-providers.test.ts
Normal file
86
src/auto-reply/reply/commands-providers.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { handleProvidersCommand } from "./commands-providers.js";
|
||||
import { buildCommandTestParams } from "./commands.test-harness.js";
|
||||
|
||||
const handleProviderSetupCommandMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ text: "provider reply" })),
|
||||
);
|
||||
|
||||
vi.mock("../../provider-setup/runtime.js", () => ({
|
||||
handleProviderSetupCommand: handleProviderSetupCommandMock,
|
||||
}));
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["owner"],
|
||||
configWrites: true,
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
function buildTelegramCommandParams(commandBody: string) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
AccountId: "primary",
|
||||
});
|
||||
params.command = {
|
||||
...params.command,
|
||||
channel: "telegram",
|
||||
commandBodyNormalized: commandBody,
|
||||
rawBodyNormalized: commandBody,
|
||||
senderId: "owner",
|
||||
senderIsOwner: true,
|
||||
isAuthorizedSender: true,
|
||||
to: "telegram:owner-dm",
|
||||
};
|
||||
params.isGroup = false;
|
||||
return params;
|
||||
}
|
||||
|
||||
describe("handleProvidersCommand", () => {
|
||||
beforeEach(() => {
|
||||
handleProviderSetupCommandMock.mockClear();
|
||||
});
|
||||
|
||||
it("allows Telegram callback payloads when text commands are disabled", async () => {
|
||||
const result = await handleProvidersCommand(
|
||||
buildTelegramCommandParams("/providers c missing-callback"),
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toBe("provider reply");
|
||||
});
|
||||
|
||||
it("does not allow typed provider subcommands when text commands are disabled", async () => {
|
||||
const result = await handleProvidersCommand(
|
||||
buildTelegramCommandParams("/providers start"),
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("does not allow typed provider dashboard when text commands are disabled", async () => {
|
||||
const result = await handleProvidersCommand(buildTelegramCommandParams("/providers"), false);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the originating Telegram chat instead of native slash targets", async () => {
|
||||
const params = buildTelegramCommandParams("/providers start");
|
||||
params.ctx.OriginatingTo = "telegram:owner-dm";
|
||||
params.command.to = "slash:owner";
|
||||
|
||||
await handleProvidersCommand(params, true);
|
||||
|
||||
expect(handleProviderSetupCommandMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conversationId: "telegram:owner-dm",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
30
src/auto-reply/reply/commands-providers.ts
Normal file
30
src/auto-reply/reply/commands-providers.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { handleProviderSetupCommand } from "../../provider-setup/runtime.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
export const handleProvidersCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands && !params.command.commandBodyNormalized.startsWith("/providers c ")) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
params.command.commandBodyNormalized !== "/providers" &&
|
||||
!params.command.commandBodyNormalized.startsWith("/providers ")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const reply = await handleProviderSetupCommand({
|
||||
cfg: params.cfg,
|
||||
commandBody: params.command.commandBodyNormalized,
|
||||
channel: params.command.channel,
|
||||
...(params.ctx.AccountId ? { accountId: params.ctx.AccountId } : {}),
|
||||
...((params.ctx.OriginatingTo ?? params.command.to ?? params.command.from)
|
||||
? { conversationId: params.ctx.OriginatingTo ?? params.command.to ?? params.command.from }
|
||||
: {}),
|
||||
...(params.command.senderId ? { senderId: params.command.senderId } : {}),
|
||||
senderIsOwner: params.command.senderIsOwner,
|
||||
isAuthorizedSender: params.command.isAuthorizedSender,
|
||||
isGroup: params.isGroup,
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
return reply ? { reply, shouldContinue: false } : null;
|
||||
};
|
||||
@@ -60,14 +60,14 @@ export async function loadValidConfigOrThrow(): Promise<OpenClawConfig> {
|
||||
}
|
||||
|
||||
export async function updateConfig(
|
||||
mutator: (cfg: OpenClawConfig) => OpenClawConfig,
|
||||
mutator: (cfg: OpenClawConfig) => OpenClawConfig | Promise<OpenClawConfig>,
|
||||
): Promise<OpenClawConfig> {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (!snapshot.valid) {
|
||||
const issues = formatConfigIssueLines(snapshot.issues, "-").join("\n");
|
||||
throw new Error(`Invalid config at ${snapshot.path}\n${issues}`);
|
||||
}
|
||||
const next = mutator(structuredClone(snapshot.sourceConfig ?? snapshot.config));
|
||||
const next = await mutator(structuredClone(snapshot.sourceConfig ?? snapshot.config));
|
||||
await replaceConfigFile({
|
||||
nextConfig: next,
|
||||
baseHash: snapshot.hash,
|
||||
|
||||
7
src/plugin-sdk/provider-setup-runtime.ts
Normal file
7
src/plugin-sdk/provider-setup-runtime.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
handleProviderSetupCommand,
|
||||
submitProviderSetupTextInput,
|
||||
__testing as providerSetupRuntimeTesting,
|
||||
type ProviderSetupCommandParams,
|
||||
type ProviderSetupTextInputResult,
|
||||
} from "../provider-setup/runtime.js";
|
||||
@@ -15,6 +15,13 @@ import {
|
||||
} from "../shared/string-coerce.js";
|
||||
import type { ProviderAuthMethod, ProviderPlugin } from "./types.js";
|
||||
|
||||
type ConfigPatchDiff =
|
||||
| { changed: false }
|
||||
| {
|
||||
changed: true;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export function resolveProviderMatch(
|
||||
providers: ProviderPlugin[],
|
||||
rawProvider?: string,
|
||||
@@ -96,6 +103,80 @@ function mergeConfigPatch<T>(base: T, patch: unknown): T {
|
||||
return next as T;
|
||||
}
|
||||
|
||||
function diffConfigPatchValue(base: unknown, next: unknown): ConfigPatchDiff {
|
||||
if (Object.is(base, next)) {
|
||||
return { changed: false };
|
||||
}
|
||||
if (Array.isArray(base) && Array.isArray(next)) {
|
||||
if (
|
||||
base.length === next.length &&
|
||||
next.every((value, index) => !diffConfigPatchValue(base[index], value).changed)
|
||||
) {
|
||||
return { changed: false };
|
||||
}
|
||||
return { changed: true, value: sanitizeConfigPatchValue(next) };
|
||||
}
|
||||
if (!isPlainRecord(base) || !isPlainRecord(next)) {
|
||||
return { changed: true, value: sanitizeConfigPatchValue(next) };
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(next)) {
|
||||
if (BLOCKED_MERGE_KEYS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const diff = diffConfigPatchValue(base[key], next[key]);
|
||||
if (diff.changed) {
|
||||
patch[key] = diff.value;
|
||||
}
|
||||
}
|
||||
return Object.keys(patch).length > 0 ? { changed: true, value: patch } : { changed: false };
|
||||
}
|
||||
|
||||
function withDefaultModelsReplacementPatch(params: {
|
||||
patch: Partial<OpenClawConfig> | undefined;
|
||||
nextConfig: OpenClawConfig;
|
||||
replaceDefaultModels?: boolean;
|
||||
}): Partial<OpenClawConfig> | undefined {
|
||||
if (!params.replaceDefaultModels) {
|
||||
return params.patch;
|
||||
}
|
||||
const replacementModels = params.nextConfig.agents?.defaults?.models;
|
||||
if (!replacementModels) {
|
||||
return params.patch;
|
||||
}
|
||||
const patch = params.patch ?? {};
|
||||
const patchAgents = isPlainRecord(patch.agents) ? patch.agents : {};
|
||||
const patchDefaults = isPlainRecord(patchAgents.defaults) ? patchAgents.defaults : {};
|
||||
return {
|
||||
...patch,
|
||||
agents: {
|
||||
...patchAgents,
|
||||
defaults: {
|
||||
...patchDefaults,
|
||||
models: sanitizeConfigPatchValue(replacementModels) as NonNullable<
|
||||
NonNullable<OpenClawConfig["agents"]>["defaults"]
|
||||
>["models"],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMinimalProviderAuthConfigPatch(params: {
|
||||
baseConfig: OpenClawConfig;
|
||||
nextConfig: OpenClawConfig;
|
||||
replaceDefaultModels?: boolean;
|
||||
}): Partial<OpenClawConfig> | undefined {
|
||||
const diff = diffConfigPatchValue(params.baseConfig, params.nextConfig);
|
||||
const patch =
|
||||
diff.changed && isPlainRecord(diff.value) ? (diff.value as Partial<OpenClawConfig>) : undefined;
|
||||
return withDefaultModelsReplacementPatch({
|
||||
patch,
|
||||
nextConfig: params.nextConfig,
|
||||
replaceDefaultModels: params.replaceDefaultModels,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAgentModelConfigForWrite(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
return normalizeAgentModelRefForConfig(value);
|
||||
@@ -287,6 +368,38 @@ export function applyProviderAuthConfigPatch(
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreConfiguredPrimaryModel(
|
||||
nextConfig: OpenClawConfig,
|
||||
originalConfig: OpenClawConfig,
|
||||
): OpenClawConfig {
|
||||
const originalModel = originalConfig.agents?.defaults?.model;
|
||||
const nextAgents = nextConfig.agents;
|
||||
const nextDefaults = nextAgents?.defaults;
|
||||
if (!nextDefaults) {
|
||||
return nextConfig;
|
||||
}
|
||||
if (originalModel !== undefined) {
|
||||
return {
|
||||
...nextConfig,
|
||||
agents: {
|
||||
...nextAgents,
|
||||
defaults: {
|
||||
...nextDefaults,
|
||||
model: originalModel,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const { model: _model, ...restDefaults } = nextDefaults;
|
||||
return {
|
||||
...nextConfig,
|
||||
agents: {
|
||||
...nextAgents,
|
||||
defaults: restDefaults,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore `agents.defaults.model` after a provider auth config merge when the user did not pass
|
||||
* `--set-default`, so `applyConfig` patches cannot replace the primary without an explicit opt-in.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { createNonExitingRuntime } from "../runtime.js";
|
||||
import type { ProviderPlugin } from "./types.js";
|
||||
import type { ProviderAuthMethod, ProviderPlugin } from "./types.js";
|
||||
|
||||
const ensureCodexRuntimePluginForModelSelection = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../commands/codex-runtime-plugin-install.js", () => ({
|
||||
@@ -15,7 +15,8 @@ vi.mock("../wizard/setup.post-install-migration.js", () => ({
|
||||
offerPostInstallMigrations,
|
||||
}));
|
||||
|
||||
const { testing, applyAuthChoicePluginProvider } = await import("./provider-auth-choice.js");
|
||||
const { testing, applyAuthChoicePluginProvider, runProviderPluginAuthMethod } =
|
||||
await import("./provider-auth-choice.js");
|
||||
|
||||
function buildProvider(): ProviderPlugin {
|
||||
return {
|
||||
@@ -134,4 +135,102 @@ describe("applyAuthChoicePluginProvider", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("applies deferred auth config patches without replaying unchanged config", async () => {
|
||||
const baseConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["old-owner"],
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
const method = {
|
||||
id: "api-key",
|
||||
label: "API key",
|
||||
kind: "api_key",
|
||||
run: vi.fn(async ({ config }: { config: OpenClawConfig }) => ({
|
||||
profiles: [],
|
||||
configPatch: {
|
||||
...config,
|
||||
models: {
|
||||
...config.models,
|
||||
providers: {
|
||||
...config.models?.providers,
|
||||
openai: { models: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
} satisfies ProviderAuthMethod;
|
||||
|
||||
const result = await runProviderPluginAuthMethod({
|
||||
config: baseConfig,
|
||||
runtime: createNonExitingRuntime(),
|
||||
prompter: createWizardPrompter(),
|
||||
method,
|
||||
});
|
||||
const nextConfig = result.applyToConfig({
|
||||
...baseConfig,
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["new-owner"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(nextConfig.channels?.telegram?.allowFrom).toEqual(["new-owner"]);
|
||||
expect(nextConfig.models?.providers?.openai).toEqual({ models: [] });
|
||||
});
|
||||
|
||||
it("preserves replaceDefaultModels removals in deferred auth config patches", async () => {
|
||||
const method = {
|
||||
id: "local",
|
||||
label: "Local",
|
||||
kind: "custom",
|
||||
run: vi.fn(async () => ({
|
||||
profiles: [],
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
replaceDefaultModels: true,
|
||||
})),
|
||||
} satisfies ProviderAuthMethod;
|
||||
|
||||
const result = await runProviderPluginAuthMethod({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": {},
|
||||
"claude-cli/claude-sonnet-4-6": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: createNonExitingRuntime(),
|
||||
prompter: createWizardPrompter(),
|
||||
method,
|
||||
});
|
||||
const nextConfig = result.applyToConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": {},
|
||||
"claude-cli/claude-sonnet-4-6": {},
|
||||
"openai/gpt-5.5": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(nextConfig.agents?.defaults?.models).toEqual({
|
||||
"anthropic/claude-sonnet-4-6": {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,10 @@ import { enablePluginInConfig } from "./enable.js";
|
||||
import {
|
||||
applyProviderAuthConfigPatch,
|
||||
applyDefaultModel,
|
||||
buildMinimalProviderAuthConfigPatch,
|
||||
pickAuthMethod,
|
||||
resolveProviderMatch,
|
||||
restoreConfiguredPrimaryModel,
|
||||
} from "./provider-auth-choice-helpers.js";
|
||||
import {
|
||||
resolveManifestProviderAuthChoice,
|
||||
@@ -30,6 +32,9 @@ import { isRemoteEnvironment, openUrl } from "./setup-browser.js";
|
||||
import type { ProviderAuthMethod, ProviderAuthOptionBag, ProviderPlugin } from "./types.js";
|
||||
|
||||
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
|
||||
type ProviderAuthMethodResult = Awaited<ReturnType<ProviderAuthMethod["run"]>>;
|
||||
type ProviderAuthConfigUpdate = (cfg: OpenClawConfig) => OpenClawConfig;
|
||||
type ProviderAuthProfileConfig = Parameters<typeof applyAuthProfileConfig>[1];
|
||||
|
||||
export type ApplyProviderAuthChoiceParams = {
|
||||
authChoice: string;
|
||||
@@ -65,38 +70,6 @@ function formatModelRefForDisplay(modelRef: string, provider: ProviderPlugin): s
|
||||
return formatLiteralProviderPrefixedModelRef(provider.id, modelRef);
|
||||
}
|
||||
|
||||
function restoreConfiguredPrimaryModel(
|
||||
nextConfig: OpenClawConfig,
|
||||
originalConfig: OpenClawConfig,
|
||||
): OpenClawConfig {
|
||||
const originalModel = originalConfig.agents?.defaults?.model;
|
||||
const nextAgents = nextConfig.agents;
|
||||
const nextDefaults = nextAgents?.defaults;
|
||||
if (!nextDefaults) {
|
||||
return nextConfig;
|
||||
}
|
||||
if (originalModel !== undefined) {
|
||||
return {
|
||||
...nextConfig,
|
||||
agents: {
|
||||
...nextAgents,
|
||||
defaults: {
|
||||
...nextDefaults,
|
||||
model: originalModel,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const { model: _model, ...restDefaults } = nextDefaults;
|
||||
return {
|
||||
...nextConfig,
|
||||
agents: {
|
||||
...nextAgents,
|
||||
defaults: restDefaults,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfiguredDefaultModelPrimary(cfg: OpenClawConfig): string | undefined {
|
||||
const model = cfg.agents?.defaults?.model;
|
||||
if (typeof model === "string") {
|
||||
@@ -256,7 +229,12 @@ export async function runProviderPluginAuthMethod(params: {
|
||||
secretInputMode?: ProviderAuthOptionBag["secretInputMode"];
|
||||
allowSecretRefPrompt?: boolean;
|
||||
opts?: Partial<ProviderAuthOptionBag>;
|
||||
}): Promise<{ config: OpenClawConfig; defaultModel?: string }> {
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
}): Promise<{
|
||||
config: OpenClawConfig;
|
||||
defaultModel?: string;
|
||||
applyToConfig: ProviderAuthConfigUpdate;
|
||||
}> {
|
||||
const agentId = params.agentId ?? resolveDefaultAgentId(params.config);
|
||||
const agentDir = params.agentDir ?? resolveAgentDir(params.config, agentId);
|
||||
const workspaceDir =
|
||||
@@ -275,40 +253,25 @@ export async function runProviderPluginAuthMethod(params: {
|
||||
secretInputMode: params.secretInputMode,
|
||||
allowSecretRefPrompt: params.allowSecretRefPrompt,
|
||||
isRemote: isRemoteEnvironment(),
|
||||
openUrl: async (url) => {
|
||||
await openUrl(url);
|
||||
},
|
||||
openUrl:
|
||||
params.openUrl ??
|
||||
(async (url) => {
|
||||
await openUrl(url);
|
||||
}),
|
||||
oauth: {
|
||||
createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts),
|
||||
},
|
||||
});
|
||||
|
||||
let nextConfig = params.config;
|
||||
if (result.configPatch) {
|
||||
nextConfig = applyProviderAuthConfigPatch(nextConfig, result.configPatch, {
|
||||
replaceDefaultModels: result.replaceDefaultModels,
|
||||
});
|
||||
}
|
||||
|
||||
for (const profile of result.profiles) {
|
||||
await upsertAuthProfileWithLockOrThrow({
|
||||
profileId: profile.profileId,
|
||||
credential: profile.credential,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: profile.profileId,
|
||||
provider: profile.credential.provider,
|
||||
mode: profile.credential.type === "token" ? "token" : profile.credential.type,
|
||||
...("email" in profile.credential && profile.credential.email
|
||||
? { email: profile.credential.email }
|
||||
: {}),
|
||||
...("displayName" in profile.credential && profile.credential.displayName
|
||||
? { displayName: profile.credential.displayName }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
const applyToConfig = buildProviderAuthConfigUpdate(result, params.config);
|
||||
const nextConfig = applyToConfig(params.config);
|
||||
|
||||
if (params.emitNotes !== false && result.notes && result.notes.length > 0) {
|
||||
await params.prompter.note(result.notes.join("\n"), "Provider notes");
|
||||
@@ -321,6 +284,45 @@ export async function runProviderPluginAuthMethod(params: {
|
||||
return {
|
||||
config: nextConfig,
|
||||
...(defaultModel ? { defaultModel } : {}),
|
||||
applyToConfig,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProviderAuthConfigUpdate(
|
||||
result: ProviderAuthMethodResult,
|
||||
baseConfig: OpenClawConfig,
|
||||
): ProviderAuthConfigUpdate {
|
||||
const profileConfigs: ProviderAuthProfileConfig[] = result.profiles.map((profile) => ({
|
||||
profileId: profile.profileId,
|
||||
provider: profile.credential.provider,
|
||||
mode: profile.credential.type,
|
||||
...("email" in profile.credential && profile.credential.email
|
||||
? { email: profile.credential.email }
|
||||
: {}),
|
||||
...("displayName" in profile.credential && profile.credential.displayName
|
||||
? { displayName: profile.credential.displayName }
|
||||
: {}),
|
||||
}));
|
||||
const configPatch = result.configPatch
|
||||
? buildMinimalProviderAuthConfigPatch({
|
||||
baseConfig,
|
||||
nextConfig: applyProviderAuthConfigPatch(baseConfig, result.configPatch, {
|
||||
replaceDefaultModels: result.replaceDefaultModels,
|
||||
}),
|
||||
replaceDefaultModels: result.replaceDefaultModels,
|
||||
})
|
||||
: undefined;
|
||||
return (cfg) => {
|
||||
let nextConfig = cfg;
|
||||
if (configPatch) {
|
||||
nextConfig = applyProviderAuthConfigPatch(nextConfig, configPatch, {
|
||||
replaceDefaultModels: result.replaceDefaultModels,
|
||||
});
|
||||
}
|
||||
for (const profileConfig of profileConfigs) {
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, profileConfig);
|
||||
}
|
||||
return nextConfig;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
537
src/provider-setup/runtime.test.ts
Normal file
537
src/provider-setup/runtime.test.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
|
||||
type AuthMockParams = {
|
||||
config: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const resolvePluginProvidersMock = vi.hoisted(() => vi.fn());
|
||||
const resolvePluginSetupRegistryMock = vi.hoisted(() => vi.fn());
|
||||
const runProviderPluginAuthMethodMock = vi.hoisted(() => vi.fn());
|
||||
const runProviderModelSelectedHookMock = vi.hoisted(() => vi.fn());
|
||||
const updateConfigMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../plugins/providers.runtime.js", () => ({
|
||||
resolvePluginProviders: resolvePluginProvidersMock,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/setup-registry.js", () => ({
|
||||
resolvePluginSetupRegistry: resolvePluginSetupRegistryMock,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/provider-auth-choice.js", () => ({
|
||||
runProviderPluginAuthMethod: runProviderPluginAuthMethodMock,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/provider-wizard.js", () => ({
|
||||
runProviderModelSelectedHook: runProviderModelSelectedHookMock,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/models/shared.js", () => ({
|
||||
updateConfig: updateConfigMock,
|
||||
}));
|
||||
|
||||
const apiKeyMethod: ProviderAuthMethod = {
|
||||
id: "api-key",
|
||||
label: "API key",
|
||||
kind: "api_key",
|
||||
run: async () => ({ profiles: [] }),
|
||||
};
|
||||
|
||||
const oauthMethod: ProviderAuthMethod = {
|
||||
id: "oauth",
|
||||
label: "OAuth",
|
||||
kind: "oauth",
|
||||
run: async () => ({ profiles: [] }),
|
||||
};
|
||||
|
||||
function buildProvider(auth: ProviderAuthMethod[] = [apiKeyMethod]): ProviderPlugin {
|
||||
return {
|
||||
id: "test-provider",
|
||||
pluginId: "test-provider",
|
||||
label: "Test Provider",
|
||||
auth,
|
||||
};
|
||||
}
|
||||
|
||||
function buildConfig(configWrites = true): OpenClawConfig {
|
||||
return {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["owner"],
|
||||
configWrites,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function commandParams(params: {
|
||||
commandBody: string;
|
||||
cfg?: OpenClawConfig;
|
||||
senderIsOwner?: boolean;
|
||||
isAuthorizedSender?: boolean;
|
||||
isGroup?: boolean;
|
||||
}) {
|
||||
return {
|
||||
cfg: params.cfg ?? buildConfig(),
|
||||
commandBody: params.commandBody,
|
||||
channel: "telegram",
|
||||
accountId: "primary",
|
||||
conversationId: "telegram:owner-dm",
|
||||
senderId: "owner",
|
||||
senderIsOwner: params.senderIsOwner ?? true,
|
||||
isAuthorizedSender: params.isAuthorizedSender ?? true,
|
||||
isGroup: params.isGroup ?? false,
|
||||
workspaceDir: "/tmp/openclaw",
|
||||
};
|
||||
}
|
||||
|
||||
function telegramButtons(reply: ReplyPayload) {
|
||||
const telegramData = reply.channelData?.telegram as
|
||||
| { buttons?: Array<Array<{ text: string; callback_data?: string; url?: string }>> }
|
||||
| undefined;
|
||||
return telegramData?.buttons ?? [];
|
||||
}
|
||||
|
||||
function callbackData(reply: ReplyPayload, row: number, column = 0): string {
|
||||
const value = telegramButtons(reply)[row]?.[column]?.callback_data;
|
||||
expect(value).toBeDefined();
|
||||
expect(value?.length).toBeLessThanOrEqual(64);
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
function firstUrl(reply: ReplyPayload): string | undefined {
|
||||
return telegramButtons(reply)
|
||||
.flat()
|
||||
.find((button) => button.url)?.url;
|
||||
}
|
||||
|
||||
describe("Telegram provider setup runtime", () => {
|
||||
let provider: ProviderPlugin;
|
||||
let sourceConfig: OpenClawConfig;
|
||||
let savedConfig: OpenClawConfig | undefined;
|
||||
let capturedSecret: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const runtime = await import("./runtime.js");
|
||||
runtime.__testing.clearProviderSetupSessions();
|
||||
provider = buildProvider();
|
||||
sourceConfig = buildConfig();
|
||||
savedConfig = undefined;
|
||||
capturedSecret = undefined;
|
||||
resolvePluginProvidersMock.mockReturnValue([provider]);
|
||||
resolvePluginSetupRegistryMock.mockReturnValue({
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
configMigrations: [],
|
||||
autoEnableProbes: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
runProviderPluginAuthMethodMock.mockImplementation(async (params: AuthMockParams) => {
|
||||
capturedSecret = await params.prompter.text({
|
||||
message: "Paste the API key",
|
||||
sensitive: true,
|
||||
validate: (value) => (value.startsWith("sk-") ? undefined : "Invalid API key"),
|
||||
});
|
||||
const applyToConfig = (cfg: OpenClawConfig): OpenClawConfig => ({
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
model: { primary: "test-provider/model-a" },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
...cfg.models,
|
||||
providers: {
|
||||
...cfg.models?.providers,
|
||||
"test-provider": {
|
||||
baseUrl: "https://api.test-provider.example/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
config: applyToConfig(params.config),
|
||||
applyToConfig,
|
||||
defaultModel: "test-provider/model-a",
|
||||
};
|
||||
});
|
||||
runProviderModelSelectedHookMock.mockResolvedValue(undefined);
|
||||
updateConfigMock.mockImplementation(
|
||||
async (mutator: (cfg: OpenClawConfig) => OpenClawConfig | Promise<OpenClawConfig>) => {
|
||||
savedConfig = await mutator(structuredClone(sourceConfig));
|
||||
return savedConfig;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves config changes made while the setup session is open", async () => {
|
||||
const { handleProviderSetupCommand, submitProviderSetupTextInput } =
|
||||
await import("./runtime.js");
|
||||
const sessionConfig = structuredClone(sourceConfig);
|
||||
|
||||
const chooseProvider = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: "/providers start", cfg: sessionConfig }),
|
||||
);
|
||||
const confirmAuth = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: callbackData(chooseProvider!, 0), cfg: sessionConfig }),
|
||||
);
|
||||
await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: callbackData(confirmAuth!, 0), cfg: sessionConfig }),
|
||||
);
|
||||
const afterSecret = await submitProviderSetupTextInput({
|
||||
channel: "telegram",
|
||||
accountId: "primary",
|
||||
conversationId: "telegram:owner-dm",
|
||||
senderId: "owner",
|
||||
text: "sk-test",
|
||||
});
|
||||
|
||||
sourceConfig = {
|
||||
...sourceConfig,
|
||||
agents: {
|
||||
...sourceConfig.agents,
|
||||
defaults: {
|
||||
...sourceConfig.agents?.defaults,
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: callbackData(afterSecret.reply!, 0), cfg: sessionConfig }),
|
||||
);
|
||||
|
||||
expect(savedConfig?.agents?.defaults?.models?.["anthropic/claude-sonnet-4-6"]).toEqual({});
|
||||
expect(savedConfig?.agents?.defaults?.models?.["test-provider/model-a"]).toEqual({});
|
||||
expect(savedConfig?.models?.providers?.["test-provider"]?.baseUrl).toBe(
|
||||
"https://api.test-provider.example/v1",
|
||||
);
|
||||
expect(savedConfig?.plugins?.entries?.["test-provider"]?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders an owner-only private dashboard", async () => {
|
||||
const { handleProviderSetupCommand } = await import("./runtime.js");
|
||||
|
||||
const reply = await handleProviderSetupCommand(commandParams({ commandBody: "/providers" }));
|
||||
|
||||
expect(reply?.text).toContain("Model providers");
|
||||
expect(reply?.text).toContain("Default: anthropic/claude-sonnet-4-6");
|
||||
expect(reply?.text).toContain("Configured: anthropic");
|
||||
expect(callbackData(reply!, 0)).toMatch(/^\/providers c [a-f0-9]{16}$/u);
|
||||
});
|
||||
|
||||
it("expires unused dashboard callbacks", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { __testing, handleProviderSetupCommand } = await import("./runtime.js");
|
||||
|
||||
await handleProviderSetupCommand(commandParams({ commandBody: "/providers" }));
|
||||
expect(__testing.providerSetupCallbackCount()).toBe(2);
|
||||
|
||||
vi.advanceTimersByTime(10 * 60 * 1000 + 1);
|
||||
await handleProviderSetupCommand(commandParams({ commandBody: "/providers" }));
|
||||
|
||||
expect(__testing.providerSetupCallbackCount()).toBe(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("expires abandoned text prompts before consuming later DMs", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { __testing, handleProviderSetupCommand, submitProviderSetupTextInput } =
|
||||
await import("./runtime.js");
|
||||
|
||||
const chooseProvider = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: "/providers start", cfg: sourceConfig }),
|
||||
);
|
||||
const confirmAuth = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: callbackData(chooseProvider!, 0), cfg: sourceConfig }),
|
||||
);
|
||||
const secretPrompt = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: callbackData(confirmAuth!, 0), cfg: sourceConfig }),
|
||||
);
|
||||
|
||||
expect(secretPrompt?.text).toContain("Paste the API key");
|
||||
|
||||
vi.advanceTimersByTime(10 * 60 * 1000 + 1);
|
||||
const laterDm = await submitProviderSetupTextInput({
|
||||
channel: "telegram",
|
||||
accountId: "primary",
|
||||
conversationId: "telegram:owner-dm",
|
||||
senderId: "owner",
|
||||
text: "normal later dm",
|
||||
});
|
||||
|
||||
expect(laterDm).toEqual({ handled: false });
|
||||
expect(capturedSecret).toBeUndefined();
|
||||
expect(__testing.providerSetupSessionCount()).toBe(0);
|
||||
expect(__testing.providerSetupCallbackCount()).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses unauthorized senders, groups, and configWrites-disabled mutation", async () => {
|
||||
const { handleProviderSetupCommand } = await import("./runtime.js");
|
||||
|
||||
const unauthorized = await handleProviderSetupCommand(
|
||||
commandParams({
|
||||
commandBody: "/providers",
|
||||
senderIsOwner: false,
|
||||
isAuthorizedSender: false,
|
||||
}),
|
||||
);
|
||||
expect(unauthorized?.text).toBe("Provider setup is only available to the Telegram owner.");
|
||||
|
||||
const group = await handleProviderSetupCommand(
|
||||
commandParams({
|
||||
commandBody: "/providers start",
|
||||
isGroup: true,
|
||||
}),
|
||||
);
|
||||
expect(group?.text).toBe("Provider setup is only available in a private Telegram DM.");
|
||||
|
||||
const readOnly = await handleProviderSetupCommand(
|
||||
commandParams({
|
||||
commandBody: "/providers start",
|
||||
cfg: buildConfig(false),
|
||||
}),
|
||||
);
|
||||
expect(readOnly?.text).toContain("read-only");
|
||||
expect(runProviderPluginAuthMethodMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not run setup auth for provider plugins blocked by config policy", async () => {
|
||||
const { handleProviderSetupCommand } = await import("./runtime.js");
|
||||
resolvePluginProvidersMock.mockReturnValue([]);
|
||||
resolvePluginSetupRegistryMock.mockReturnValue({
|
||||
providers: [{ pluginId: "test-provider", provider }],
|
||||
cliBackends: [],
|
||||
configMigrations: [],
|
||||
autoEnableProbes: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
const reply = await handleProviderSetupCommand(
|
||||
commandParams({
|
||||
commandBody: "/providers start",
|
||||
cfg: {
|
||||
...sourceConfig,
|
||||
plugins: { allow: ["other-provider"] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(reply?.text).toContain("No setup-capable model providers are available.");
|
||||
expect(runProviderPluginAuthMethodMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps text prompts open after provider validation errors", async () => {
|
||||
const { __testing, handleProviderSetupCommand, submitProviderSetupTextInput } =
|
||||
await import("./runtime.js");
|
||||
|
||||
const chooseProvider = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: "/providers start", cfg: sourceConfig }),
|
||||
);
|
||||
const confirmAuth = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: callbackData(chooseProvider!, 0), cfg: sourceConfig }),
|
||||
);
|
||||
await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: callbackData(confirmAuth!, 0), cfg: sourceConfig }),
|
||||
);
|
||||
|
||||
const invalid = await submitProviderSetupTextInput({
|
||||
channel: "telegram",
|
||||
accountId: "primary",
|
||||
conversationId: "telegram:owner-dm",
|
||||
senderId: "owner",
|
||||
text: "not-a-key",
|
||||
});
|
||||
|
||||
expect(invalid.handled).toBe(true);
|
||||
expect(invalid.deleteInputMessage).toBe(true);
|
||||
expect(invalid.reply?.text).toContain("Invalid API key");
|
||||
expect(invalid.reply?.text).toContain("Paste the API key");
|
||||
expect(capturedSecret).toBeUndefined();
|
||||
expect(__testing.providerSetupSessionCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("runs API-key setup through private text input and saves after confirmation", async () => {
|
||||
const { __testing, handleProviderSetupCommand, submitProviderSetupTextInput } =
|
||||
await import("./runtime.js");
|
||||
runProviderModelSelectedHookMock.mockImplementationOnce(
|
||||
async ({ config }: { config: OpenClawConfig }) => {
|
||||
const providerConfig = config.models?.providers?.["test-provider"];
|
||||
if (providerConfig) {
|
||||
providerConfig.baseUrl = "https://hooked.test-provider.example/v1";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const chooseProvider = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: "/providers start", cfg: sourceConfig }),
|
||||
);
|
||||
expect(chooseProvider?.text).toBe("Choose provider");
|
||||
const providerCallback = callbackData(chooseProvider!, 0);
|
||||
expect(providerCallback).toMatch(/^\/providers c [a-f0-9]{16}$/u);
|
||||
|
||||
const confirmAuth = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: providerCallback, cfg: sourceConfig }),
|
||||
);
|
||||
expect(confirmAuth?.text).toContain("Continue with Test Provider API key?");
|
||||
const staleDeclineCallback = callbackData(confirmAuth!, 0, 1);
|
||||
|
||||
const secretPrompt = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: callbackData(confirmAuth!, 0), cfg: sourceConfig }),
|
||||
);
|
||||
expect(secretPrompt?.text).toContain("Paste the API key");
|
||||
expect(secretPrompt?.text).toContain("delete the message");
|
||||
const staleDecline = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: staleDeclineCallback, cfg: sourceConfig }),
|
||||
);
|
||||
expect(staleDecline?.text).toContain("expired");
|
||||
|
||||
const afterSecret = await submitProviderSetupTextInput({
|
||||
channel: "telegram",
|
||||
accountId: "primary",
|
||||
conversationId: "telegram:owner-dm",
|
||||
senderId: "owner",
|
||||
text: "sk-test",
|
||||
});
|
||||
expect(afterSecret.handled).toBe(true);
|
||||
expect(afterSecret.deleteInputMessage).toBe(true);
|
||||
expect(capturedSecret).toBe("sk-test");
|
||||
expect(afterSecret.reply?.text).toContain("Set test-provider/model-a as the default model?");
|
||||
|
||||
const saved = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: callbackData(afterSecret.reply!, 0), cfg: sourceConfig }),
|
||||
);
|
||||
expect(saved?.text).toContain("Test Provider is ready.");
|
||||
expect(savedConfig?.agents?.defaults?.model).toEqual({ primary: "test-provider/model-a" });
|
||||
expect(savedConfig?.models?.providers?.["test-provider"]?.baseUrl).toBe(
|
||||
"https://hooked.test-provider.example/v1",
|
||||
);
|
||||
expect(savedConfig?.plugins?.entries?.["test-provider"]?.enabled).toBe(true);
|
||||
expect(runProviderModelSelectedHookMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: savedConfig,
|
||||
model: "test-provider/model-a",
|
||||
workspaceDir: "/tmp/openclaw",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
telegramButtons(saved!)
|
||||
.flat()
|
||||
.map((button) => button.text),
|
||||
).not.toContain("Cancel");
|
||||
|
||||
const complete = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: callbackData(saved!, 0), cfg: sourceConfig }),
|
||||
);
|
||||
expect(complete?.text).toBe("Provider setup complete.");
|
||||
expect(__testing.providerSetupSessionCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the current default model when setup declines the provider default", async () => {
|
||||
const { handleProviderSetupCommand, submitProviderSetupTextInput } =
|
||||
await import("./runtime.js");
|
||||
|
||||
const chooseProvider = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: "/providers start", cfg: sourceConfig }),
|
||||
);
|
||||
const confirmAuth = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: callbackData(chooseProvider!, 0), cfg: sourceConfig }),
|
||||
);
|
||||
const secretPrompt = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: callbackData(confirmAuth!, 0), cfg: sourceConfig }),
|
||||
);
|
||||
expect(secretPrompt?.text).toContain("Paste the API key");
|
||||
|
||||
const afterSecret = await submitProviderSetupTextInput({
|
||||
channel: "telegram",
|
||||
accountId: "primary",
|
||||
conversationId: "telegram:owner-dm",
|
||||
senderId: "owner",
|
||||
text: "sk-test",
|
||||
});
|
||||
|
||||
const saved = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: callbackData(afterSecret.reply!, 0, 1), cfg: sourceConfig }),
|
||||
);
|
||||
|
||||
expect(saved?.text).toContain("Test Provider is ready.");
|
||||
expect(savedConfig?.agents?.defaults?.model).toBe("anthropic/claude-sonnet-4-6");
|
||||
expect(savedConfig?.plugins?.entries?.["test-provider"]?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders OAuth URLs as URL buttons before continuation", async () => {
|
||||
const { handleProviderSetupCommand } = await import("./runtime.js");
|
||||
provider = buildProvider([oauthMethod]);
|
||||
resolvePluginProvidersMock.mockReturnValue([provider]);
|
||||
runProviderPluginAuthMethodMock.mockImplementation(async (params: AuthMockParams) => {
|
||||
await params.openUrl("https://login.example.test/device");
|
||||
return {
|
||||
config: params.config,
|
||||
defaultModel: "test-provider/model-a",
|
||||
};
|
||||
});
|
||||
|
||||
const chooseProvider = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: "/providers start", cfg: sourceConfig }),
|
||||
);
|
||||
const confirmAuth = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: callbackData(chooseProvider!, 0), cfg: sourceConfig }),
|
||||
);
|
||||
const login = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: callbackData(confirmAuth!, 0), cfg: sourceConfig }),
|
||||
);
|
||||
|
||||
expect(login?.text).toContain("https://login.example.test/device");
|
||||
expect(firstUrl(login!)).toBe("https://login.example.test/device");
|
||||
});
|
||||
|
||||
it("expires old callbacks when a concurrent setup starts", async () => {
|
||||
const { __testing, handleProviderSetupCommand } = await import("./runtime.js");
|
||||
|
||||
const first = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: "/providers start", cfg: sourceConfig }),
|
||||
);
|
||||
const firstCallback = callbackData(first!, 0);
|
||||
await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: "/providers start", cfg: sourceConfig }),
|
||||
);
|
||||
|
||||
const expired = await handleProviderSetupCommand(
|
||||
commandParams({ commandBody: firstCallback, cfg: sourceConfig }),
|
||||
);
|
||||
expect(expired?.text).toContain("expired");
|
||||
expect(__testing.providerSetupSessionCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
674
src/provider-setup/runtime.ts
Normal file
674
src/provider-setup/runtime.ts
Normal file
@@ -0,0 +1,674 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js";
|
||||
import { updateConfig } from "../commands/models/shared.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import {
|
||||
applyDefaultModel,
|
||||
pickAuthMethod,
|
||||
restoreConfiguredPrimaryModel,
|
||||
} from "../plugins/provider-auth-choice-helpers.js";
|
||||
import { runProviderPluginAuthMethod } from "../plugins/provider-auth-choice.js";
|
||||
import { runProviderModelSelectedHook } from "../plugins/provider-wizard.js";
|
||||
import { resolvePluginProviders } from "../plugins/providers.runtime.js";
|
||||
import { resolvePluginSetupRegistry } from "../plugins/setup-registry.js";
|
||||
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { WizardCancelledError, type WizardPrompter } from "../wizard/prompts.js";
|
||||
import { WizardSession, type WizardStep } from "../wizard/session.js";
|
||||
|
||||
type ProviderSetupBinding = {
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
conversationId: string;
|
||||
senderId?: string;
|
||||
};
|
||||
|
||||
type ProviderSetupSessionRecord = {
|
||||
session: WizardSession;
|
||||
binding: ProviderSetupBinding;
|
||||
callbackIds: Set<string>;
|
||||
expiresAt: number;
|
||||
lastStep?: WizardStep;
|
||||
};
|
||||
|
||||
type ProviderSetupCallback =
|
||||
| { type: "answer"; sessionId: string; value: unknown }
|
||||
| { type: "cancel"; sessionId: string }
|
||||
| { type: "dashboard" }
|
||||
| { type: "start" };
|
||||
|
||||
type StoredProviderSetupCallback = {
|
||||
callback: ProviderSetupCallback;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type ProviderSetupCommandParams = {
|
||||
cfg: OpenClawConfig;
|
||||
commandBody: string;
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
conversationId?: string;
|
||||
senderId?: string;
|
||||
senderIsOwner: boolean;
|
||||
isAuthorizedSender: boolean;
|
||||
isGroup: boolean;
|
||||
agentId?: string;
|
||||
workspaceDir: string;
|
||||
};
|
||||
|
||||
export type ProviderSetupTextInputResult = {
|
||||
handled: boolean;
|
||||
reply?: ReplyPayload;
|
||||
deleteInputMessage?: boolean;
|
||||
};
|
||||
|
||||
const PROVIDER_SETUP_COMMAND = "/providers";
|
||||
const PROVIDER_SETUP_INTERACTION_TTL_MS = 10 * 60 * 1000;
|
||||
const sessions = new Map<string, ProviderSetupSessionRecord>();
|
||||
const callbacks = new Map<string, StoredProviderSetupCallback>();
|
||||
|
||||
function normalizeCommandTail(commandBody: string): string {
|
||||
const trimmed = commandBody.trim();
|
||||
if (trimmed === PROVIDER_SETUP_COMMAND) {
|
||||
return "";
|
||||
}
|
||||
return trimmed.startsWith(`${PROVIDER_SETUP_COMMAND} `)
|
||||
? trimmed.slice(PROVIDER_SETUP_COMMAND.length + 1).trim()
|
||||
: "";
|
||||
}
|
||||
|
||||
function buildCommandButton(label: string, command: string) {
|
||||
return { text: label, callback_data: command };
|
||||
}
|
||||
|
||||
function buildUrlButton(label: string, url: string) {
|
||||
return { text: label, url };
|
||||
}
|
||||
|
||||
function storeCallback(
|
||||
record: ProviderSetupSessionRecord,
|
||||
callback: ProviderSetupCallback,
|
||||
): string {
|
||||
const id = randomUUID().replaceAll("-", "").slice(0, 16);
|
||||
callbacks.set(id, { callback, expiresAt: record.expiresAt });
|
||||
record.callbackIds.add(id);
|
||||
return `${PROVIDER_SETUP_COMMAND} c ${id}`;
|
||||
}
|
||||
|
||||
function storeLooseCallback(callback: ProviderSetupCallback): string {
|
||||
pruneExpiredProviderSetupState(Date.now());
|
||||
const id = randomUUID().replaceAll("-", "").slice(0, 16);
|
||||
callbacks.set(id, { callback, expiresAt: Date.now() + PROVIDER_SETUP_INTERACTION_TTL_MS });
|
||||
return `${PROVIDER_SETUP_COMMAND} c ${id}`;
|
||||
}
|
||||
|
||||
function clearSessionCallbacks(record: ProviderSetupSessionRecord) {
|
||||
for (const id of record.callbackIds) {
|
||||
callbacks.delete(id);
|
||||
}
|
||||
record.callbackIds.clear();
|
||||
}
|
||||
|
||||
function expireSession(sessionId: string, record: ProviderSetupSessionRecord) {
|
||||
record.session.cancel();
|
||||
sessions.delete(sessionId);
|
||||
clearSessionCallbacks(record);
|
||||
}
|
||||
|
||||
function pruneExpiredProviderSetupState(now: number) {
|
||||
for (const [id, stored] of callbacks) {
|
||||
if (stored.expiresAt <= now) {
|
||||
callbacks.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [sessionId, record] of sessions) {
|
||||
if (record.expiresAt <= now) {
|
||||
expireSession(sessionId, record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSessionExpiry(record: ProviderSetupSessionRecord) {
|
||||
record.expiresAt = Date.now() + PROVIDER_SETUP_INTERACTION_TTL_MS;
|
||||
}
|
||||
|
||||
function buildSessionButton(
|
||||
record: ProviderSetupSessionRecord,
|
||||
label: string,
|
||||
callback: ProviderSetupCallback,
|
||||
) {
|
||||
return buildCommandButton(label, storeCallback(record, callback));
|
||||
}
|
||||
|
||||
function providerSetupChannelData(
|
||||
buttons: Array<Array<{ text: string; callback_data?: string; url?: string }>>,
|
||||
): ReplyPayload["channelData"] {
|
||||
return { telegram: { buttons } };
|
||||
}
|
||||
|
||||
function sameBinding(left: ProviderSetupBinding, right: ProviderSetupBinding): boolean {
|
||||
return (
|
||||
left.channel === right.channel &&
|
||||
left.accountId === right.accountId &&
|
||||
left.conversationId === right.conversationId &&
|
||||
left.senderId === right.senderId
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBinding(params: ProviderSetupCommandParams): ProviderSetupBinding | null {
|
||||
if (!params.conversationId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channel: params.channel,
|
||||
...(params.accountId ? { accountId: params.accountId } : {}),
|
||||
conversationId: params.conversationId,
|
||||
...(params.senderId ? { senderId: params.senderId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function listSetupProviders(params: {
|
||||
config: OpenClawConfig;
|
||||
workspaceDir: string;
|
||||
}): ProviderPlugin[] {
|
||||
const providers = resolvePluginProviders({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
mode: "setup",
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
bundledProviderAllowlistCompat: true,
|
||||
bundledProviderVitestCompat: true,
|
||||
});
|
||||
const setupProviders = resolvePluginSetupRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
}).providers.map((entry) => ({ ...entry.provider, pluginId: entry.pluginId }));
|
||||
const byId = new Map<string, ProviderPlugin>();
|
||||
for (const provider of [...providers, ...setupProviders]) {
|
||||
if (provider.auth.length > 0 && isProviderPluginAllowed(params.config, provider)) {
|
||||
byId.set(provider.id, provider);
|
||||
}
|
||||
}
|
||||
return [...byId.values()].toSorted((left, right) => left.label.localeCompare(right.label));
|
||||
}
|
||||
|
||||
function resolveProvider(providers: readonly ProviderPlugin[], providerId: string): ProviderPlugin {
|
||||
const provider = providers.find((entry) => entry.id === providerId);
|
||||
if (!provider) {
|
||||
throw new Error("Provider no longer available.");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
function resolveAuthMethod(provider: ProviderPlugin, methodId: string): ProviderAuthMethod {
|
||||
const method = pickAuthMethod(provider, methodId);
|
||||
if (!method) {
|
||||
throw new Error("Auth method no longer available.");
|
||||
}
|
||||
return method;
|
||||
}
|
||||
|
||||
function applyProviderPluginEnablement(cfg: OpenClawConfig, provider: ProviderPlugin) {
|
||||
if (!provider.pluginId) {
|
||||
return cfg;
|
||||
}
|
||||
const result = enablePluginInConfig(cfg, provider.pluginId);
|
||||
if (!result.enabled) {
|
||||
throw new Error(`${provider.label} plugin is ${result.reason ?? "disabled"}.`);
|
||||
}
|
||||
return result.config;
|
||||
}
|
||||
|
||||
function isProviderPluginAllowed(cfg: OpenClawConfig, provider: ProviderPlugin): boolean {
|
||||
if (!provider.pluginId) {
|
||||
return true;
|
||||
}
|
||||
return enablePluginInConfig(cfg, provider.pluginId).enabled;
|
||||
}
|
||||
|
||||
function makeProviderSetupRunner(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
agentDir: string;
|
||||
workspaceDir: string;
|
||||
}) {
|
||||
return async (prompter: WizardPrompter) => {
|
||||
const providers = listSetupProviders({
|
||||
config: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
if (providers.length === 0) {
|
||||
await prompter.note("No setup-capable model providers are available.", "Providers");
|
||||
return;
|
||||
}
|
||||
const providerId = await prompter.select({
|
||||
message: "Choose provider",
|
||||
options: providers.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.label,
|
||||
hint: provider.auth.map((method) => method.label).join(", "),
|
||||
})),
|
||||
});
|
||||
const provider = resolveProvider(providers, providerId);
|
||||
const methodId =
|
||||
provider.auth.length === 1
|
||||
? provider.auth[0].id
|
||||
: await prompter.select({
|
||||
message: `Auth method for ${provider.label}`,
|
||||
options: provider.auth.map((method) => ({
|
||||
value: method.id,
|
||||
label: method.label,
|
||||
hint: method.hint,
|
||||
})),
|
||||
});
|
||||
const method = resolveAuthMethod(provider, methodId);
|
||||
const proceed = await prompter.confirm({
|
||||
message: `Continue with ${provider.label} ${method.label}?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!proceed) {
|
||||
throw new WizardCancelledError("cancelled");
|
||||
}
|
||||
const enabledConfig = applyProviderPluginEnablement(params.cfg, provider);
|
||||
const applied = await runProviderPluginAuthMethod({
|
||||
config: enabledConfig,
|
||||
runtime: defaultRuntime,
|
||||
prompter,
|
||||
method,
|
||||
agentDir: params.agentDir,
|
||||
agentId: params.agentId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
emitNotes: true,
|
||||
allowSecretRefPrompt: false,
|
||||
openUrl: async (url) => {
|
||||
await prompter.note(url, "Open provider login URL");
|
||||
},
|
||||
});
|
||||
const applyProviderAuthConfig = (cfg: OpenClawConfig) =>
|
||||
applied.applyToConfig(applyProviderPluginEnablement(cfg, provider));
|
||||
let applyToConfig = async (cfg: OpenClawConfig) =>
|
||||
restoreConfiguredPrimaryModel(applyProviderAuthConfig(cfg), cfg);
|
||||
let defaultModel: string | undefined;
|
||||
if (applied.defaultModel) {
|
||||
const setDefault = await prompter.confirm({
|
||||
message: `Set ${applied.defaultModel} as the default model?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (setDefault) {
|
||||
const selectedDefaultModel = applied.defaultModel;
|
||||
applyToConfig = async (cfg) => {
|
||||
const nextConfig = applyDefaultModel(applyProviderAuthConfig(cfg), selectedDefaultModel);
|
||||
await runProviderModelSelectedHook({
|
||||
config: nextConfig,
|
||||
model: selectedDefaultModel,
|
||||
prompter,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
return nextConfig;
|
||||
};
|
||||
defaultModel = applied.defaultModel;
|
||||
}
|
||||
}
|
||||
await updateConfig((cfg) => applyToConfig(cfg));
|
||||
await prompter.note(
|
||||
[`${provider.label} is ready.`, defaultModel ? `Default model: ${defaultModel}` : null]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"Provider saved",
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function configuredProviderLabels(
|
||||
cfg: OpenClawConfig,
|
||||
providers: readonly ProviderPlugin[],
|
||||
): string[] {
|
||||
const configuredIds = Object.keys(cfg.models?.providers ?? {});
|
||||
const labelById = new Map(providers.map((provider) => [provider.id, provider.label]));
|
||||
return configuredIds.toSorted().map((id) => labelById.get(id) ?? id);
|
||||
}
|
||||
|
||||
function renderDashboard(params: ProviderSetupCommandParams): ReplyPayload {
|
||||
const agentId = params.agentId ?? resolveDefaultAgentId(params.cfg);
|
||||
const current = resolveDefaultModelForAgent({ cfg: params.cfg, agentId });
|
||||
const providers = listSetupProviders({
|
||||
config: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
const providerCount = providers.length;
|
||||
const configured = configuredProviderLabels(params.cfg, providers);
|
||||
return {
|
||||
text: [
|
||||
"Model providers",
|
||||
"",
|
||||
`Default: ${current.provider}/${current.model}`,
|
||||
`Configured: ${configured.length > 0 ? configured.join(", ") : "none"}`,
|
||||
`Setup-capable providers: ${providerCount}`,
|
||||
"",
|
||||
"Choose an action.",
|
||||
].join("\n"),
|
||||
channelData: providerSetupChannelData([
|
||||
[buildCommandButton("Add or update provider", storeLooseCallback({ type: "start" }))],
|
||||
[buildCommandButton("Refresh", storeLooseCallback({ type: "dashboard" }))],
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string | undefined): value is string {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" || url.protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStep(
|
||||
sessionId: string,
|
||||
record: ProviderSetupSessionRecord,
|
||||
step: WizardStep,
|
||||
): ReplyPayload {
|
||||
const title = step.title ? `${step.title}\n\n` : "";
|
||||
if (step.type === "note" || step.type === "progress" || step.type === "action") {
|
||||
const loginUrl =
|
||||
step.title === "Open provider login URL" && isHttpUrl(step.message) ? step.message : null;
|
||||
const terminalSuccess = step.title === "Provider saved";
|
||||
const buttons = [
|
||||
loginUrl ? [buildUrlButton("Open login URL", loginUrl)] : [],
|
||||
[
|
||||
buildSessionButton(record, terminalSuccess ? "Done" : "Continue", {
|
||||
type: "answer",
|
||||
sessionId,
|
||||
value: "ok",
|
||||
}),
|
||||
],
|
||||
terminalSuccess ? [] : [buildSessionButton(record, "Cancel", { type: "cancel", sessionId })],
|
||||
].filter((row) => row.length > 0);
|
||||
return {
|
||||
text: `${title}${step.message ?? ""}`.trim() || "Continue.",
|
||||
channelData: providerSetupChannelData(buttons),
|
||||
};
|
||||
}
|
||||
if (step.type === "confirm") {
|
||||
return {
|
||||
text: `${title}${step.message ?? "Confirm?"}`,
|
||||
channelData: providerSetupChannelData([
|
||||
[
|
||||
buildSessionButton(record, "Yes", { type: "answer", sessionId, value: true }),
|
||||
buildSessionButton(record, "No", { type: "answer", sessionId, value: false }),
|
||||
],
|
||||
[buildSessionButton(record, "Cancel", { type: "cancel", sessionId })],
|
||||
]),
|
||||
};
|
||||
}
|
||||
if (step.type === "select") {
|
||||
const rows =
|
||||
step.options?.map((option) => [
|
||||
buildSessionButton(record, option.label, {
|
||||
type: "answer",
|
||||
sessionId,
|
||||
value: option.value,
|
||||
}),
|
||||
]) ?? [];
|
||||
rows.push([buildSessionButton(record, "Cancel", { type: "cancel", sessionId })]);
|
||||
return {
|
||||
text: `${title}${step.message ?? "Choose one."}`,
|
||||
channelData: providerSetupChannelData(rows),
|
||||
};
|
||||
}
|
||||
if (step.type === "text") {
|
||||
return {
|
||||
text: [
|
||||
`${title}${step.message ?? "Reply with the value."}`,
|
||||
"",
|
||||
step.sensitive
|
||||
? "Reply in this DM. OpenClaw will delete the message after reading it when Telegram allows."
|
||||
: "Reply in this DM.",
|
||||
].join("\n"),
|
||||
channelData: providerSetupChannelData([
|
||||
[buildSessionButton(record, "Cancel", { type: "cancel", sessionId })],
|
||||
]),
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: "This step is not supported in Telegram yet.",
|
||||
channelData: providerSetupChannelData([
|
||||
[buildSessionButton(record, "Cancel", { type: "cancel", sessionId })],
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
async function renderNext(
|
||||
sessionId: string,
|
||||
record: ProviderSetupSessionRecord,
|
||||
): Promise<ReplyPayload> {
|
||||
const result = await record.session.next();
|
||||
if (result.done) {
|
||||
sessions.delete(sessionId);
|
||||
clearSessionCallbacks(record);
|
||||
if (result.status === "done") {
|
||||
return {
|
||||
text: "Provider setup complete.",
|
||||
channelData: providerSetupChannelData([
|
||||
[buildCommandButton("Back to providers", "/providers")],
|
||||
]),
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: result.status === "cancelled" ? "Provider setup cancelled." : "Provider setup failed.",
|
||||
channelData: providerSetupChannelData([
|
||||
[buildCommandButton("Back to providers", "/providers")],
|
||||
]),
|
||||
};
|
||||
}
|
||||
if (!result.step) {
|
||||
sessions.delete(sessionId);
|
||||
clearSessionCallbacks(record);
|
||||
return {
|
||||
text: "Provider setup failed.",
|
||||
channelData: providerSetupChannelData([
|
||||
[buildCommandButton("Back to providers", "/providers")],
|
||||
]),
|
||||
};
|
||||
}
|
||||
record.lastStep = result.step;
|
||||
refreshSessionExpiry(record);
|
||||
return renderStep(sessionId, record, result.step);
|
||||
}
|
||||
|
||||
async function answerSession(sessionId: string, value: unknown): Promise<ReplyPayload> {
|
||||
pruneExpiredProviderSetupState(Date.now());
|
||||
const record = sessions.get(sessionId);
|
||||
if (!record) {
|
||||
return {
|
||||
text: "Provider setup expired. Start again with /providers.",
|
||||
channelData: providerSetupChannelData([[buildCommandButton("Start", "/providers start")]]),
|
||||
};
|
||||
}
|
||||
const stepId = record.lastStep?.id;
|
||||
if (!stepId) {
|
||||
return renderNext(sessionId, record);
|
||||
}
|
||||
clearSessionCallbacks(record);
|
||||
await record.session.answer(stepId, value);
|
||||
return renderNext(sessionId, record);
|
||||
}
|
||||
|
||||
function cancelSession(sessionId: string): ReplyPayload {
|
||||
pruneExpiredProviderSetupState(Date.now());
|
||||
const record = sessions.get(sessionId);
|
||||
if (record) {
|
||||
record.session.cancel();
|
||||
sessions.delete(sessionId);
|
||||
clearSessionCallbacks(record);
|
||||
}
|
||||
return {
|
||||
text: "Provider setup cancelled.",
|
||||
channelData: providerSetupChannelData([
|
||||
[buildCommandButton("Back to providers", "/providers")],
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function cancelExistingSessions(binding: ProviderSetupBinding) {
|
||||
for (const [sessionId, record] of sessions) {
|
||||
if (sameBinding(record.binding, binding)) {
|
||||
record.session.cancel();
|
||||
sessions.delete(sessionId);
|
||||
clearSessionCallbacks(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startSession(params: ProviderSetupCommandParams, binding: ProviderSetupBinding) {
|
||||
cancelExistingSessions(binding);
|
||||
const agentId = params.agentId ?? resolveDefaultAgentId(params.cfg);
|
||||
const id = randomUUID().slice(0, 12);
|
||||
const session = new WizardSession(
|
||||
makeProviderSetupRunner({
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
agentDir: resolveAgentDir(params.cfg, agentId),
|
||||
workspaceDir: params.workspaceDir || resolveDefaultAgentWorkspaceDir(),
|
||||
}),
|
||||
);
|
||||
const record = {
|
||||
session,
|
||||
binding,
|
||||
callbackIds: new Set<string>(),
|
||||
expiresAt: Date.now() + PROVIDER_SETUP_INTERACTION_TTL_MS,
|
||||
} satisfies ProviderSetupSessionRecord;
|
||||
sessions.set(id, record);
|
||||
return renderNext(id, record);
|
||||
}
|
||||
|
||||
async function handleStoredCallback(
|
||||
callbackId: string,
|
||||
params: ProviderSetupCommandParams,
|
||||
binding: ProviderSetupBinding,
|
||||
): Promise<ReplyPayload> {
|
||||
pruneExpiredProviderSetupState(Date.now());
|
||||
const stored = callbacks.get(callbackId);
|
||||
if (!stored) {
|
||||
callbacks.delete(callbackId);
|
||||
return {
|
||||
text: "Provider setup expired. Start again with /providers.",
|
||||
channelData: providerSetupChannelData([[buildCommandButton("Start", "/providers")]]),
|
||||
};
|
||||
}
|
||||
callbacks.delete(callbackId);
|
||||
const { callback } = stored;
|
||||
if (callback.type === "dashboard") {
|
||||
return renderDashboard(params);
|
||||
}
|
||||
if (callback.type === "start") {
|
||||
return startSession(params, binding);
|
||||
}
|
||||
if (callback.type === "cancel") {
|
||||
return cancelSession(callback.sessionId);
|
||||
}
|
||||
return answerSession(callback.sessionId, callback.value);
|
||||
}
|
||||
|
||||
export async function handleProviderSetupCommand(
|
||||
params: ProviderSetupCommandParams,
|
||||
): Promise<ReplyPayload | null> {
|
||||
if (!params.commandBody.trim().startsWith(PROVIDER_SETUP_COMMAND)) {
|
||||
return null;
|
||||
}
|
||||
if (params.channel !== "telegram") {
|
||||
return { text: "Use openclaw configure on the server for provider setup on this channel." };
|
||||
}
|
||||
if (!params.isAuthorizedSender || !params.senderIsOwner) {
|
||||
return { text: "Provider setup is only available to the Telegram owner." };
|
||||
}
|
||||
if (params.isGroup) {
|
||||
return { text: "Provider setup is only available in a private Telegram DM." };
|
||||
}
|
||||
if (
|
||||
!resolveChannelConfigWrites({
|
||||
cfg: params.cfg,
|
||||
channelId: "telegram",
|
||||
accountId: params.accountId,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
text: [
|
||||
"Provider setup is read-only because Telegram config writes are disabled.",
|
||||
"",
|
||||
"Enable config writes on the server, then retry /providers.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
const binding = resolveBinding(params);
|
||||
if (!binding) {
|
||||
return { text: "Provider setup needs a Telegram conversation context." };
|
||||
}
|
||||
const tail = normalizeCommandTail(params.commandBody);
|
||||
const [action, sessionId, rawValue] = tail.split(/\s+/, 3);
|
||||
if (!action) {
|
||||
return renderDashboard(params);
|
||||
}
|
||||
if (action === "start") {
|
||||
return startSession(params, binding);
|
||||
}
|
||||
if (action === "c" && sessionId) {
|
||||
return handleStoredCallback(sessionId, params, binding);
|
||||
}
|
||||
if (action === "cancel" && sessionId) {
|
||||
return cancelSession(sessionId);
|
||||
}
|
||||
if (action === "next" && sessionId) {
|
||||
const value = rawValue === "true" ? true : rawValue === "false" ? false : rawValue;
|
||||
return answerSession(sessionId, value);
|
||||
}
|
||||
return renderDashboard(params);
|
||||
}
|
||||
|
||||
export async function submitProviderSetupTextInput(params: {
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
conversationId: string;
|
||||
senderId?: string;
|
||||
text: string;
|
||||
}): Promise<ProviderSetupTextInputResult> {
|
||||
pruneExpiredProviderSetupState(Date.now());
|
||||
const binding: ProviderSetupBinding = {
|
||||
channel: params.channel,
|
||||
...(params.accountId ? { accountId: params.accountId } : {}),
|
||||
conversationId: params.conversationId,
|
||||
...(params.senderId ? { senderId: params.senderId } : {}),
|
||||
};
|
||||
const entry = [...sessions.entries()].find(([, record]) => {
|
||||
return sameBinding(record.binding, binding) && record.lastStep?.type === "text";
|
||||
});
|
||||
if (!entry) {
|
||||
return { handled: false };
|
||||
}
|
||||
const [sessionId, record] = entry;
|
||||
const sensitive = record.lastStep?.sensitive === true;
|
||||
const reply = await answerSession(sessionId, params.text);
|
||||
return { handled: true, reply, deleteInputMessage: sensitive };
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
clearProviderSetupSessions() {
|
||||
sessions.clear();
|
||||
callbacks.clear();
|
||||
},
|
||||
providerSetupSessionCount() {
|
||||
return sessions.size;
|
||||
},
|
||||
providerSetupCallbackCount() {
|
||||
return callbacks.size;
|
||||
},
|
||||
};
|
||||
@@ -119,27 +119,30 @@ class WizardSessionPrompter implements WizardPrompter {
|
||||
validate?: (value: string) => string | undefined;
|
||||
sensitive?: boolean;
|
||||
}): Promise<string> {
|
||||
const res = await this.prompt({
|
||||
type: "text",
|
||||
message: params.message,
|
||||
initialValue: params.initialValue,
|
||||
placeholder: params.placeholder,
|
||||
sensitive: params.sensitive,
|
||||
executor: "client",
|
||||
});
|
||||
const value =
|
||||
res === null || res === undefined
|
||||
? ""
|
||||
: typeof res === "string"
|
||||
? res
|
||||
: typeof res === "number" || typeof res === "boolean" || typeof res === "bigint"
|
||||
? String(res)
|
||||
: "";
|
||||
const error = params.validate?.(value);
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
let message = params.message;
|
||||
for (;;) {
|
||||
const res = await this.prompt({
|
||||
type: "text",
|
||||
message,
|
||||
initialValue: params.initialValue,
|
||||
placeholder: params.placeholder,
|
||||
sensitive: params.sensitive,
|
||||
executor: "client",
|
||||
});
|
||||
const value =
|
||||
res === null || res === undefined
|
||||
? ""
|
||||
: typeof res === "string"
|
||||
? res
|
||||
: typeof res === "number" || typeof res === "boolean" || typeof res === "bigint"
|
||||
? String(res)
|
||||
: "";
|
||||
const error = params.validate?.(value);
|
||||
if (!error) {
|
||||
return value;
|
||||
}
|
||||
message = `${error}\n\n${params.message}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async confirm(params: { message: string; initialValue?: boolean }): Promise<boolean> {
|
||||
|
||||
Reference in New Issue
Block a user