diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index a5041b28b2f..72b1df6c7f2 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -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"; diff --git a/src/commands/doctor/shared/legacy-config-core-migrate.ts b/src/commands/doctor/shared/legacy-config-core-migrate.ts new file mode 100644 index 00000000000..b9540b9efd8 --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-core-migrate.ts @@ -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 }; +} diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.ts index ed65b6238b0..bb9ee2e63d5 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.ts @@ -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): Record { - const providers = getRecord(tts.providers) ?? {}; - tts.providers = providers; - return providers; -} - -function mergeLegacyTtsProviderConfig( - tts: Record, - 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 | 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. keys (openai/elevenlabs/microsoft/edge) are legacy; use messages.tts.providers.. Run "openclaw doctor --fix".', - match: (value) => hasLegacyTtsProviderKeys(value), - }, - { - path: ["plugins", "entries"], - message: - 'plugins.entries.voice-call.config.tts. keys (openai/elevenlabs/microsoft/edge) are legacy; use plugins.entries.voice-call.config.tts.providers.. 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", diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts new file mode 100644 index 00000000000..8877f3f098f --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts @@ -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): Record { + const providers = getRecord(tts.providers) ?? {}; + tts.providers = providers; + return providers; +} + +function mergeLegacyTtsProviderConfig( + tts: Record, + 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 | 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. keys (openai/elevenlabs/microsoft/edge) are legacy; use messages.tts.providers.. Run "openclaw doctor --fix".', + match: (value) => hasLegacyTtsProviderKeys(value), + }, + { + path: ["plugins", "entries"], + message: + 'plugins.entries.voice-call.config.tts. keys (openai/elevenlabs/microsoft/edge) are legacy; use plugins.entries.voice-call.config.tts.providers.. 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, + ); + } + }, + }), +]; diff --git a/src/gateway/test-helpers.channels.ts b/src/gateway/test-helpers.channels.ts new file mode 100644 index 00000000000..d4f9c287f8c --- /dev/null +++ b/src/gateway/test-helpers.channels.ts @@ -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; +}; + +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" }), + }, + ]; +} diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index ce5100592bd..21b9baf60d6 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -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; -}; - type GetReplyFromConfigFn = ( ctx: MsgContext, opts?: GetReplyOptions, @@ -39,244 +32,15 @@ type SendWhatsAppFn = (...args: unknown[]) => Promise<{ messageId: string; toJid type RunBtwSideQuestionFn = (...args: unknown[]) => Promise; type DispatchInboundMessageFn = (...args: unknown[]) => Promise; -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 { - const withTimeout = async (label: string, run: Promise): Promise => - await Promise.race([ - run, - new Promise((_, 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; - const overrides = (req.providerOverrides ?? {}) as Record; - 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: [], diff --git a/src/gateway/test-helpers.speech.ts b/src/gateway/test-helpers.speech.ts new file mode 100644 index 00000000000..537dccf0b69 --- /dev/null +++ b/src/gateway/test-helpers.speech.ts @@ -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 { + const withTimeout = async (label: string, run: Promise): Promise => + await Promise.race([ + run, + new Promise((_, 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; + const overrides = (req.providerOverrides ?? {}) as Record; + 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) }), + }), + }), + }, + ]; +} diff --git a/src/test-utils/talk-test-provider.ts b/src/test-utils/talk-test-provider.ts new file mode 100644 index 00000000000..126287ee70c --- /dev/null +++ b/src/test-utils/talk-test-provider.ts @@ -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; +}