mirror of https://github.com/openclaw/openclaw.git
refactor: split doctor and gateway test helpers
This commit is contained in:
parent
267ebc3ba5
commit
c71ee4d844
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
@ -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" }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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) }),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue