refactor: split doctor and gateway test helpers

This commit is contained in:
Peter Steinberger 2026-04-05 18:51:45 +01:00
parent 267ebc3ba5
commit c71ee4d844
No known key found for this signature in database
8 changed files with 466 additions and 411 deletions

View File

@ -1,52 +1 @@
import type { OpenClawConfig } from "../config/config.js";
import { runPluginSetupConfigMigrations } from "../plugins/setup-registry.js";
import {
normalizeLegacyBrowserConfig,
normalizeLegacyCrossContextMessageConfig,
normalizeLegacyMediaProviderOptions,
normalizeLegacyMistralModelMaxTokens,
normalizeLegacyNanoBananaSkill,
normalizeLegacyTalkConfig,
seedMissingDefaultAccountsFromSingleAccountBase,
} from "./doctor/shared/legacy-config-core-normalizers.js";
import { migrateLegacyWebFetchConfig } from "./doctor/shared/legacy-web-fetch-migrate.js";
import { migrateLegacyWebSearchConfig } from "./doctor/shared/legacy-web-search-migrate.js";
import { migrateLegacyXSearchConfig } from "./doctor/shared/legacy-x-search-migrate.js";
export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} {
const changes: string[] = [];
let next = seedMissingDefaultAccountsFromSingleAccountBase(cfg, changes);
next = normalizeLegacyBrowserConfig(next, changes);
const setupMigration = runPluginSetupConfigMigrations({
config: next,
});
if (setupMigration.changes.length > 0) {
next = setupMigration.config;
changes.push(...setupMigration.changes);
}
for (const migrate of [
migrateLegacyWebSearchConfig,
migrateLegacyWebFetchConfig,
migrateLegacyXSearchConfig,
]) {
const migrated = migrate(next);
if (migrated.changes.length === 0) {
continue;
}
next = migrated.config;
changes.push(...migrated.changes);
}
next = normalizeLegacyNanoBananaSkill(next, changes);
next = normalizeLegacyTalkConfig(next, changes);
next = normalizeLegacyCrossContextMessageConfig(next, changes);
next = normalizeLegacyMediaProviderOptions(next, changes);
next = normalizeLegacyMistralModelMaxTokens(next, changes);
return { config: next, changes };
}
export { normalizeCompatibilityConfigValues } from "./doctor/shared/legacy-config-core-migrate.js";

View File

@ -0,0 +1,52 @@
import type { OpenClawConfig } from "../../../config/config.js";
import { runPluginSetupConfigMigrations } from "../../../plugins/setup-registry.js";
import {
normalizeLegacyBrowserConfig,
normalizeLegacyCrossContextMessageConfig,
normalizeLegacyMediaProviderOptions,
normalizeLegacyMistralModelMaxTokens,
normalizeLegacyNanoBananaSkill,
normalizeLegacyTalkConfig,
seedMissingDefaultAccountsFromSingleAccountBase,
} from "./legacy-config-core-normalizers.js";
import { migrateLegacyWebFetchConfig } from "./legacy-web-fetch-migrate.js";
import { migrateLegacyWebSearchConfig } from "./legacy-web-search-migrate.js";
import { migrateLegacyXSearchConfig } from "./legacy-x-search-migrate.js";
export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} {
const changes: string[] = [];
let next = seedMissingDefaultAccountsFromSingleAccountBase(cfg, changes);
next = normalizeLegacyBrowserConfig(next, changes);
const setupMigration = runPluginSetupConfigMigrations({
config: next,
});
if (setupMigration.changes.length > 0) {
next = setupMigration.config;
changes.push(...setupMigration.changes);
}
for (const migrate of [
migrateLegacyWebSearchConfig,
migrateLegacyWebFetchConfig,
migrateLegacyXSearchConfig,
]) {
const migrated = migrate(next);
if (migrated.changes.length === 0) {
continue;
}
next = migrated.config;
changes.push(...migrated.changes);
}
next = normalizeLegacyNanoBananaSkill(next, changes);
next = normalizeLegacyTalkConfig(next, changes);
next = normalizeLegacyCrossContextMessageConfig(next, changes);
next = normalizeLegacyMediaProviderOptions(next, changes);
next = normalizeLegacyMistralModelMaxTokens(next, changes);
return { config: next, changes };
}

View File

@ -14,6 +14,7 @@ import {
} from "../../../config/legacy.shared.js";
import { DEFAULT_GATEWAY_PORT } from "../../../config/paths.js";
import { isBlockedObjectKey } from "../../../config/prototype-keys.js";
import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS } from "./legacy-config-migrations.runtime.tts.js";
import { migrateLegacyXSearchConfig } from "./legacy-x-search-migrate.js";
const AGENT_HEARTBEAT_KEYS = new Set([
@ -34,9 +35,6 @@ const AGENT_HEARTBEAT_KEYS = new Set([
]);
const CHANNEL_HEARTBEAT_KEYS = new Set(["showOk", "showAlerts", "useIndicator"]);
const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const;
const LEGACY_TTS_PLUGIN_IDS = new Set(["voice-call"]);
function sandboxScopeFromPerSession(perSession: boolean): "session" | "shared" {
return perSession ? "session" : "shared";
}
@ -131,14 +129,6 @@ function mergeLegacyIntoDefaults(params: {
params.raw[params.rootKey] = root;
}
function hasLegacyTtsProviderKeys(value: unknown): boolean {
const tts = getRecord(value);
if (!tts) {
return false;
}
return LEGACY_TTS_PROVIDER_KEYS.some((key) => Object.prototype.hasOwnProperty.call(tts, key));
}
function hasLegacySandboxPerSession(value: unknown): boolean {
const sandbox = getRecord(value);
return Boolean(sandbox && Object.prototype.hasOwnProperty.call(sandbox, "perSession"));
@ -150,72 +140,6 @@ function hasLegacyAgentListSandboxPerSession(value: unknown): boolean {
}
return value.some((agent) => hasLegacySandboxPerSession(getRecord(agent)?.sandbox));
}
function hasLegacyPluginEntryTtsProviderKeys(value: unknown): boolean {
const entries = getRecord(value);
if (!entries) {
return false;
}
return Object.entries(entries).some(([pluginId, entryValue]) => {
if (isBlockedObjectKey(pluginId) || !LEGACY_TTS_PLUGIN_IDS.has(pluginId)) {
return false;
}
const entry = getRecord(entryValue);
const config = getRecord(entry?.config);
return hasLegacyTtsProviderKeys(config?.tts);
});
}
function getOrCreateTtsProviders(tts: Record<string, unknown>): Record<string, unknown> {
const providers = getRecord(tts.providers) ?? {};
tts.providers = providers;
return providers;
}
function mergeLegacyTtsProviderConfig(
tts: Record<string, unknown>,
legacyKey: string,
providerId: string,
): boolean {
const legacyValue = getRecord(tts[legacyKey]);
if (!legacyValue) {
return false;
}
const providers = getOrCreateTtsProviders(tts);
const existing = getRecord(providers[providerId]) ?? {};
const merged = structuredClone(existing);
mergeMissing(merged, legacyValue);
providers[providerId] = merged;
delete tts[legacyKey];
return true;
}
function migrateLegacyTtsConfig(
tts: Record<string, unknown> | null | undefined,
pathLabel: string,
changes: string[],
): void {
if (!tts) {
return;
}
const movedOpenAI = mergeLegacyTtsProviderConfig(tts, "openai", "openai");
const movedElevenLabs = mergeLegacyTtsProviderConfig(tts, "elevenlabs", "elevenlabs");
const movedMicrosoft = mergeLegacyTtsProviderConfig(tts, "microsoft", "microsoft");
const movedEdge = mergeLegacyTtsProviderConfig(tts, "edge", "microsoft");
if (movedOpenAI) {
changes.push(`Moved ${pathLabel}.openai → ${pathLabel}.providers.openai.`);
}
if (movedElevenLabs) {
changes.push(`Moved ${pathLabel}.elevenlabs → ${pathLabel}.providers.elevenlabs.`);
}
if (movedMicrosoft) {
changes.push(`Moved ${pathLabel}.microsoft → ${pathLabel}.providers.microsoft.`);
}
if (movedEdge) {
changes.push(`Moved ${pathLabel}.edge → ${pathLabel}.providers.microsoft.`);
}
}
const MEMORY_SEARCH_RULE: LegacyConfigRule = {
path: ["memorySearch"],
message:
@ -242,21 +166,6 @@ const X_SEARCH_RULE: LegacyConfigRule = {
'tools.web.x_search.apiKey moved to the xAI plugin; use plugins.entries.xai.config.webSearch.apiKey instead. Run "openclaw doctor --fix".',
};
const LEGACY_TTS_RULES: LegacyConfigRule[] = [
{
path: ["messages", "tts"],
message:
'messages.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use messages.tts.providers.<provider>. Run "openclaw doctor --fix".',
match: (value) => hasLegacyTtsProviderKeys(value),
},
{
path: ["plugins", "entries"],
message:
'plugins.entries.voice-call.config.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use plugins.entries.voice-call.config.tts.providers.<provider>. Run "openclaw doctor --fix".',
match: (value) => hasLegacyPluginEntryTtsProviderKeys(value),
},
];
const LEGACY_SANDBOX_SCOPE_RULES: LegacyConfigRule[] = [
{
path: ["agents", "defaults", "sandbox"],
@ -447,33 +356,7 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [
changes.push(`Normalized gateway.bind "${escapeControlForLog(bindRaw)}" → "${mapped}".`);
},
}),
defineLegacyConfigMigration({
id: "tts.providers-generic-shape",
describe: "Move legacy bundled TTS config keys into messages.tts.providers",
legacyRules: LEGACY_TTS_RULES,
apply: (raw, changes) => {
const messages = getRecord(raw.messages);
migrateLegacyTtsConfig(getRecord(messages?.tts), "messages.tts", changes);
const plugins = getRecord(raw.plugins);
const pluginEntries = getRecord(plugins?.entries);
if (!pluginEntries) {
return;
}
for (const [pluginId, entryValue] of Object.entries(pluginEntries)) {
if (isBlockedObjectKey(pluginId) || !LEGACY_TTS_PLUGIN_IDS.has(pluginId)) {
continue;
}
const entry = getRecord(entryValue);
const config = getRecord(entry?.config);
migrateLegacyTtsConfig(
getRecord(config?.tts),
`plugins.entries.${pluginId}.config.tts`,
changes,
);
}
},
}),
...LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS,
defineLegacyConfigMigration({
id: "heartbeat->agents.defaults.heartbeat",
describe: "Move top-level heartbeat to agents.defaults.heartbeat/channels.defaults.heartbeat",

View File

@ -0,0 +1,130 @@
import {
defineLegacyConfigMigration,
getRecord,
mergeMissing,
type LegacyConfigMigrationSpec,
type LegacyConfigRule,
} from "../../../config/legacy.shared.js";
import { isBlockedObjectKey } from "../../../config/prototype-keys.js";
const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const;
const LEGACY_TTS_PLUGIN_IDS = new Set(["voice-call"]);
function hasLegacyTtsProviderKeys(value: unknown): boolean {
const tts = getRecord(value);
if (!tts) {
return false;
}
return LEGACY_TTS_PROVIDER_KEYS.some((key) => Object.prototype.hasOwnProperty.call(tts, key));
}
function hasLegacyPluginEntryTtsProviderKeys(value: unknown): boolean {
const entries = getRecord(value);
if (!entries) {
return false;
}
return Object.entries(entries).some(([pluginId, entryValue]) => {
if (isBlockedObjectKey(pluginId) || !LEGACY_TTS_PLUGIN_IDS.has(pluginId)) {
return false;
}
const entry = getRecord(entryValue);
const config = getRecord(entry?.config);
return hasLegacyTtsProviderKeys(config?.tts);
});
}
function getOrCreateTtsProviders(tts: Record<string, unknown>): Record<string, unknown> {
const providers = getRecord(tts.providers) ?? {};
tts.providers = providers;
return providers;
}
function mergeLegacyTtsProviderConfig(
tts: Record<string, unknown>,
legacyKey: string,
providerId: string,
): boolean {
const legacyValue = getRecord(tts[legacyKey]);
if (!legacyValue) {
return false;
}
const providers = getOrCreateTtsProviders(tts);
const existing = getRecord(providers[providerId]) ?? {};
const merged = structuredClone(existing);
mergeMissing(merged, legacyValue);
providers[providerId] = merged;
delete tts[legacyKey];
return true;
}
function migrateLegacyTtsConfig(
tts: Record<string, unknown> | null | undefined,
pathLabel: string,
changes: string[],
): void {
if (!tts) {
return;
}
const movedOpenAI = mergeLegacyTtsProviderConfig(tts, "openai", "openai");
const movedElevenLabs = mergeLegacyTtsProviderConfig(tts, "elevenlabs", "elevenlabs");
const movedMicrosoft = mergeLegacyTtsProviderConfig(tts, "microsoft", "microsoft");
const movedEdge = mergeLegacyTtsProviderConfig(tts, "edge", "microsoft");
if (movedOpenAI) {
changes.push(`Moved ${pathLabel}.openai → ${pathLabel}.providers.openai.`);
}
if (movedElevenLabs) {
changes.push(`Moved ${pathLabel}.elevenlabs → ${pathLabel}.providers.elevenlabs.`);
}
if (movedMicrosoft) {
changes.push(`Moved ${pathLabel}.microsoft → ${pathLabel}.providers.microsoft.`);
}
if (movedEdge) {
changes.push(`Moved ${pathLabel}.edge → ${pathLabel}.providers.microsoft.`);
}
}
const LEGACY_TTS_RULES: LegacyConfigRule[] = [
{
path: ["messages", "tts"],
message:
'messages.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use messages.tts.providers.<provider>. Run "openclaw doctor --fix".',
match: (value) => hasLegacyTtsProviderKeys(value),
},
{
path: ["plugins", "entries"],
message:
'plugins.entries.voice-call.config.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use plugins.entries.voice-call.config.tts.providers.<provider>. Run "openclaw doctor --fix".',
match: (value) => hasLegacyPluginEntryTtsProviderKeys(value),
},
];
export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS: LegacyConfigMigrationSpec[] = [
defineLegacyConfigMigration({
id: "tts.providers-generic-shape",
describe: "Move legacy bundled TTS config keys into messages.tts.providers",
legacyRules: LEGACY_TTS_RULES,
apply: (raw, changes) => {
const messages = getRecord(raw.messages);
migrateLegacyTtsConfig(getRecord(messages?.tts), "messages.tts", changes);
const plugins = getRecord(raw.plugins);
const pluginEntries = getRecord(plugins?.entries);
if (!pluginEntries) {
return;
}
for (const [pluginId, entryValue] of Object.entries(pluginEntries)) {
if (isBlockedObjectKey(pluginId) || !LEGACY_TTS_PLUGIN_IDS.has(pluginId)) {
continue;
}
const entry = getRecord(entryValue);
const config = getRecord(entry?.config);
migrateLegacyTtsConfig(
getRecord(config?.tts),
`plugins.entries.${pluginId}.config.tts`,
changes,
);
}
},
}),
];

View File

@ -0,0 +1,122 @@
import type { ChannelPlugin, ChannelOutboundAdapter } from "../channels/plugins/types.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
type StubChannelOptions = {
id: ChannelPlugin["id"];
label: string;
summary?: Record<string, unknown>;
};
const createStubOutboundAdapter = (channelId: ChannelPlugin["id"]): ChannelOutboundAdapter => ({
deliveryMode: "direct",
sendText: async () => ({
channel: channelId,
messageId: `${channelId}-msg`,
}),
sendMedia: async () => ({
channel: channelId,
messageId: `${channelId}-msg`,
}),
});
const createStubChannelPlugin = (params: StubChannelOptions): ChannelPlugin => ({
id: params.id,
meta: {
id: params.id,
label: params.label,
selectionLabel: params.label,
docsPath: `/channels/${params.id}`,
blurb: "test stub.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
resolveAccount: () => ({}),
isConfigured: async () => false,
},
status: {
buildChannelSummary: async () => ({
configured: false,
...(params.summary ? params.summary : {}),
}),
},
outbound: createStubOutboundAdapter(params.id),
messaging: {
normalizeTarget: (raw) => raw,
},
gateway: {
logoutAccount: async () => ({
cleared: false,
envToken: false,
loggedOut: false,
}),
},
});
export function createDefaultGatewayTestChannels() {
return [
{
pluginId: "whatsapp",
source: "test" as const,
plugin: createStubChannelPlugin({ id: "whatsapp", label: "WhatsApp" }),
},
{
pluginId: "telegram",
source: "test" as const,
plugin: createStubChannelPlugin({
id: "telegram",
label: "Telegram",
summary: { tokenSource: "none", lastProbeAt: null },
}),
},
{
pluginId: "discord",
source: "test" as const,
plugin: createStubChannelPlugin({ id: "discord", label: "Discord" }),
},
{
pluginId: "slack",
source: "test" as const,
plugin: createStubChannelPlugin({ id: "slack", label: "Slack" }),
},
{
pluginId: "signal",
source: "test" as const,
plugin: createStubChannelPlugin({
id: "signal",
label: "Signal",
summary: { lastProbeAt: null },
}),
},
{
pluginId: "imessage",
source: "test" as const,
plugin: createStubChannelPlugin({ id: "imessage", label: "iMessage" }),
},
{
pluginId: "msteams",
source: "test" as const,
plugin: createStubChannelPlugin({ id: "msteams", label: "Microsoft Teams" }),
},
{
pluginId: "matrix",
source: "test" as const,
plugin: createStubChannelPlugin({ id: "matrix", label: "Matrix" }),
},
{
pluginId: "zalo",
source: "test" as const,
plugin: createStubChannelPlugin({ id: "zalo", label: "Zalo" }),
},
{
pluginId: "zalouser",
source: "test" as const,
plugin: createStubChannelPlugin({ id: "zalouser", label: "Zalo Personal" }),
},
{
pluginId: "bluebubbles",
source: "test" as const,
plugin: createStubChannelPlugin({ id: "bluebubbles", label: "BlueBubbles" }),
},
];
}

View File

@ -6,7 +6,6 @@ import path from "node:path";
import { Mock, vi } from "vitest";
import type { MsgContext } from "../auto-reply/templating.js";
import type { GetReplyOptions, ReplyPayload } from "../auto-reply/types.js";
import type { ChannelPlugin, ChannelOutboundAdapter } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import type { AgentBinding } from "../config/types.agents.js";
@ -14,20 +13,14 @@ import type { HooksConfig } from "../config/types.hooks.js";
import type { TailscaleWhoisIdentity } from "../infra/tailscale.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import type { SpeechProviderPlugin } from "../plugins/types.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { createDefaultGatewayTestChannels } from "./test-helpers.channels.js";
import { createDefaultGatewayTestSpeechProviders } from "./test-helpers.speech.js";
function buildBundledPluginModuleId(pluginId: string, artifactBasename: string): string {
return ["..", "..", "extensions", pluginId, artifactBasename].join("/");
}
type StubChannelOptions = {
id: ChannelPlugin["id"];
label: string;
summary?: Record<string, unknown>;
};
type GetReplyFromConfigFn = (
ctx: MsgContext,
opts?: GetReplyOptions,
@ -39,244 +32,15 @@ type SendWhatsAppFn = (...args: unknown[]) => Promise<{ messageId: string; toJid
type RunBtwSideQuestionFn = (...args: unknown[]) => Promise<unknown>;
type DispatchInboundMessageFn = (...args: unknown[]) => Promise<unknown>;
const createStubOutboundAdapter = (channelId: ChannelPlugin["id"]): ChannelOutboundAdapter => ({
deliveryMode: "direct",
sendText: async () => ({
channel: channelId,
messageId: `${channelId}-msg`,
}),
sendMedia: async () => ({
channel: channelId,
messageId: `${channelId}-msg`,
}),
});
const createStubChannelPlugin = (params: StubChannelOptions): ChannelPlugin => ({
id: params.id,
meta: {
id: params.id,
label: params.label,
selectionLabel: params.label,
docsPath: `/channels/${params.id}`,
blurb: "test stub.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
resolveAccount: () => ({}),
isConfigured: async () => false,
},
status: {
buildChannelSummary: async () => ({
configured: false,
...(params.summary ? params.summary : {}),
}),
},
outbound: createStubOutboundAdapter(params.id),
messaging: {
normalizeTarget: (raw) => raw,
},
gateway: {
logoutAccount: async () => ({
cleared: false,
envToken: false,
loggedOut: false,
}),
},
});
type StubSpeechProviderOptions = {
id: SpeechProviderPlugin["id"];
label: string;
aliases?: string[];
voices?: string[];
resolveTalkOverrides?: SpeechProviderPlugin["resolveTalkOverrides"];
synthesize?: SpeechProviderPlugin["synthesize"];
};
function trimString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
async function fetchStubSpeechAudio(
url: string,
init: RequestInit,
providerId: string,
): Promise<Buffer> {
const withTimeout = async <T>(label: string, run: Promise<T>): Promise<T> =>
await Promise.race([
run,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`${providerId} stub ${label} timed out`)), 5_000),
),
]);
const response = await withTimeout("fetch", globalThis.fetch(url, init));
const arrayBuffer = await withTimeout("read", response.arrayBuffer());
return Buffer.from(arrayBuffer);
}
const createStubSpeechProvider = (params: StubSpeechProviderOptions): SpeechProviderPlugin => ({
id: params.id,
label: params.label,
aliases: params.aliases,
voices: params.voices,
resolveTalkOverrides: params.resolveTalkOverrides,
isConfigured: () => true,
synthesize:
params.synthesize ??
(async () => ({
audioBuffer: Buffer.from(`${params.id}-audio`, "utf8"),
outputFormat: "mp3",
fileExtension: ".mp3",
voiceCompatible: true,
})),
listVoices: async () =>
(params.voices ?? []).map((voiceId) => ({
id: voiceId,
name: voiceId,
})),
});
const createStubPluginRegistry = (): PluginRegistry => ({
plugins: [],
tools: [],
hooks: [],
typedHooks: [],
channels: [
{
pluginId: "whatsapp",
source: "test",
plugin: createStubChannelPlugin({ id: "whatsapp", label: "WhatsApp" }),
},
{
pluginId: "telegram",
source: "test",
plugin: createStubChannelPlugin({
id: "telegram",
label: "Telegram",
summary: { tokenSource: "none", lastProbeAt: null },
}),
},
{
pluginId: "discord",
source: "test",
plugin: createStubChannelPlugin({ id: "discord", label: "Discord" }),
},
{
pluginId: "slack",
source: "test",
plugin: createStubChannelPlugin({ id: "slack", label: "Slack" }),
},
{
pluginId: "signal",
source: "test",
plugin: createStubChannelPlugin({
id: "signal",
label: "Signal",
summary: { lastProbeAt: null },
}),
},
{
pluginId: "imessage",
source: "test",
plugin: createStubChannelPlugin({ id: "imessage", label: "iMessage" }),
},
{
pluginId: "msteams",
source: "test",
plugin: createStubChannelPlugin({ id: "msteams", label: "Microsoft Teams" }),
},
{
pluginId: "matrix",
source: "test",
plugin: createStubChannelPlugin({ id: "matrix", label: "Matrix" }),
},
{
pluginId: "zalo",
source: "test",
plugin: createStubChannelPlugin({ id: "zalo", label: "Zalo" }),
},
{
pluginId: "zalouser",
source: "test",
plugin: createStubChannelPlugin({ id: "zalouser", label: "Zalo Personal" }),
},
{
pluginId: "bluebubbles",
source: "test",
plugin: createStubChannelPlugin({ id: "bluebubbles", label: "BlueBubbles" }),
},
],
channels: createDefaultGatewayTestChannels(),
channelSetups: [],
providers: [],
speechProviders: [
{
pluginId: "openai",
source: "test",
provider: createStubSpeechProvider({
id: "openai",
label: "OpenAI",
voices: ["alloy", "nova"],
resolveTalkOverrides: ({ params }) => ({
...(trimString(params.voiceId) == null ? {} : { voice: trimString(params.voiceId) }),
...(trimString(params.modelId) == null ? {} : { model: trimString(params.modelId) }),
...(asNumber(params.speed) == null ? {} : { speed: asNumber(params.speed) }),
}),
synthesize: async (req) => {
const config = req.providerConfig as Record<string, unknown>;
const overrides = (req.providerOverrides ?? {}) as Record<string, unknown>;
const body = JSON.stringify({
input: req.text,
model: trimString(overrides.model) ?? trimString(config.modelId) ?? "gpt-4o-mini-tts",
voice: trimString(overrides.voice) ?? trimString(config.voiceId) ?? "alloy",
...(asNumber(overrides.speed) == null ? {} : { speed: asNumber(overrides.speed) }),
});
const audioBuffer = await fetchStubSpeechAudio(
"https://api.openai.com/v1/audio/speech",
{
method: "POST",
headers: { "content-type": "application/json" },
body,
},
"openai",
);
return {
audioBuffer,
outputFormat: "mp3",
fileExtension: ".mp3",
voiceCompatible: false,
};
},
}),
},
{
pluginId: "acme-speech",
source: "test",
provider: createStubSpeechProvider({
id: "acme-speech",
label: "Acme Speech",
voices: ["stub-default-voice", "stub-alt-voice"],
resolveTalkOverrides: ({ params }) => ({
...(trimString(params.voiceId) == null ? {} : { voiceId: trimString(params.voiceId) }),
...(trimString(params.modelId) == null ? {} : { modelId: trimString(params.modelId) }),
...(trimString(params.outputFormat) == null
? {}
: { outputFormat: trimString(params.outputFormat) }),
...(asNumber(params.latencyTier) == null
? {}
: { latencyTier: asNumber(params.latencyTier) }),
}),
}),
},
],
speechProviders: createDefaultGatewayTestSpeechProviders(),
realtimeTranscriptionProviders: [],
realtimeVoiceProviders: [],
mediaUnderstandingProviders: [],

View File

@ -0,0 +1,128 @@
import type { SpeechProviderPlugin } from "../plugins/types.js";
import {
TALK_TEST_PROVIDER_ID,
TALK_TEST_PROVIDER_LABEL,
} from "../test-utils/talk-test-provider.js";
type StubSpeechProviderOptions = {
id: SpeechProviderPlugin["id"];
label: string;
aliases?: string[];
voices?: string[];
resolveTalkOverrides?: SpeechProviderPlugin["resolveTalkOverrides"];
synthesize?: SpeechProviderPlugin["synthesize"];
};
function trimString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
async function fetchStubSpeechAudio(
url: string,
init: RequestInit,
providerId: string,
): Promise<Buffer> {
const withTimeout = async <T>(label: string, run: Promise<T>): Promise<T> =>
await Promise.race([
run,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`${providerId} stub ${label} timed out`)), 5_000),
),
]);
const response = await withTimeout("fetch", globalThis.fetch(url, init));
const arrayBuffer = await withTimeout("read", response.arrayBuffer());
return Buffer.from(arrayBuffer);
}
const createStubSpeechProvider = (params: StubSpeechProviderOptions): SpeechProviderPlugin => ({
id: params.id,
label: params.label,
aliases: params.aliases,
voices: params.voices,
resolveTalkOverrides: params.resolveTalkOverrides,
isConfigured: () => true,
synthesize:
params.synthesize ??
(async () => ({
audioBuffer: Buffer.from(`${params.id}-audio`, "utf8"),
outputFormat: "mp3",
fileExtension: ".mp3",
voiceCompatible: true,
})),
listVoices: async () =>
(params.voices ?? []).map((voiceId) => ({
id: voiceId,
name: voiceId,
})),
});
export function createDefaultGatewayTestSpeechProviders() {
return [
{
pluginId: "openai",
source: "test" as const,
provider: createStubSpeechProvider({
id: "openai",
label: "OpenAI",
voices: ["alloy", "nova"],
resolveTalkOverrides: ({ params }) => ({
...(trimString(params.voiceId) == null ? {} : { voice: trimString(params.voiceId) }),
...(trimString(params.modelId) == null ? {} : { model: trimString(params.modelId) }),
...(asNumber(params.speed) == null ? {} : { speed: asNumber(params.speed) }),
}),
synthesize: async (req) => {
const config = req.providerConfig as Record<string, unknown>;
const overrides = (req.providerOverrides ?? {}) as Record<string, unknown>;
const body = JSON.stringify({
input: req.text,
model: trimString(overrides.model) ?? trimString(config.modelId) ?? "gpt-4o-mini-tts",
voice: trimString(overrides.voice) ?? trimString(config.voiceId) ?? "alloy",
...(asNumber(overrides.speed) == null ? {} : { speed: asNumber(overrides.speed) }),
});
const audioBuffer = await fetchStubSpeechAudio(
"https://api.openai.com/v1/audio/speech",
{
method: "POST",
headers: { "content-type": "application/json" },
body,
},
"openai",
);
return {
audioBuffer,
outputFormat: "mp3",
fileExtension: ".mp3",
voiceCompatible: false,
};
},
}),
},
{
pluginId: TALK_TEST_PROVIDER_ID,
source: "test" as const,
provider: createStubSpeechProvider({
id: TALK_TEST_PROVIDER_ID,
label: TALK_TEST_PROVIDER_LABEL,
voices: ["stub-default-voice", "stub-alt-voice"],
resolveTalkOverrides: ({ params }) => ({
...(trimString(params.voiceId) == null ? {} : { voiceId: trimString(params.voiceId) }),
...(trimString(params.modelId) == null ? {} : { modelId: trimString(params.modelId) }),
...(trimString(params.outputFormat) == null
? {}
: { outputFormat: trimString(params.outputFormat) }),
...(asNumber(params.latencyTier) == null
? {}
: { latencyTier: asNumber(params.latencyTier) }),
}),
}),
},
];
}

View File

@ -0,0 +1,27 @@
import type { OpenClawConfig } from "../config/config.js";
export const TALK_TEST_PROVIDER_ID = "acme-speech";
export const TALK_TEST_PROVIDER_LABEL = "Acme Speech";
export const TALK_TEST_PROVIDER_API_KEY_PATH = `talk.providers.${TALK_TEST_PROVIDER_ID}.apiKey`;
export const TALK_TEST_PROVIDER_API_KEY_PATH_SEGMENTS = [
"talk",
"providers",
TALK_TEST_PROVIDER_ID,
"apiKey",
] as const;
export function buildTalkTestProviderConfig(apiKey: unknown): OpenClawConfig {
return {
talk: {
providers: {
[TALK_TEST_PROVIDER_ID]: {
apiKey,
},
},
},
} as OpenClawConfig;
}
export function readTalkTestProviderApiKey(config: OpenClawConfig): unknown {
return config.talk?.providers?.[TALK_TEST_PROVIDER_ID]?.apiKey;
}