Skip to content

Commit 8142e67

Browse files
authored
fix(plugins): start configured speech providers (#76540)
* fix(plugins): start configured speech providers * fix(plugins): mirror tts provider selection
1 parent d0dc72b commit 8142e67

4 files changed

Lines changed: 347 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
5050
- Config/doctor: cap `.clobbered.*` forensic snapshots per config path and serialize snapshot writes so repeated `doctor --fix` recovery loops cannot flood the config directory. Fixes #76454; carries forward #65649. Thanks @JUSTICEESSIELP, @rsnow, and @vincentkoc.
5151
- Feishu: suppress duplicate text when replies send native voice media while preserving captions for ordinary audio files and falling back to text plus attachment links when voice uploads fail.
5252
- Feishu: send the skipped reply text when `audioAsVoice` falls back to a generic file attachment after transcode failure, so voice-intent replies do not lose their caption.
53+
- TTS/plugins: activate the configured speech provider plugin during Gateway startup, so Microsoft and Local CLI voice replies work immediately after selecting them instead of staying invisible in the startup plugin set. Fixes #76481. Thanks @amknight.
5354
- Feishu: keep packaged Feishu startup from bundling the Lark SDK's ESM `__dirname` path by loading the SDK as a plugin-local runtime dependency. Fixes #76291 and #76494. (#76392) Thanks @zqchris.
5455
- Plugins/npm: build package-local runtime dist files for publishable plugins and stop listing root-package-excluded plugin sidecars in the core package metadata, so npm plugin installs such as `@openclaw/diffs` and `@openclaw/discord` no longer publish source-only runtime payloads. Fixes #76426. Thanks @PrinceOfEgypt.
5556
- Channels/secrets: resolve SecretRef-backed channel credentials through external plugin secret contracts after the plugin split, covering runtime startup, target discovery, webhook auth, disabled-account enumeration, and late-bound web_search config. Fixes #76371. (#76449) Thanks @joshavant and @neeravmakwana.

src/plugins/channel-plugin-ids.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,24 @@ function createManifestRegistryFixture(): PluginManifestRegistry {
131131
providers: ["demo-provider"],
132132
cliBackends: ["demo-cli"],
133133
},
134+
{
135+
id: "microsoft",
136+
channels: [],
137+
origin: "bundled",
138+
enabledByDefault: true,
139+
providers: [],
140+
cliBackends: [],
141+
contracts: { speechProviders: ["microsoft"] },
142+
},
143+
{
144+
id: "tts-local-cli",
145+
channels: [],
146+
origin: "bundled",
147+
enabledByDefault: true,
148+
providers: [],
149+
cliBackends: [],
150+
contracts: { speechProviders: ["tts-local-cli", "cli"] },
151+
},
134152
{
135153
id: "anthropic",
136154
channels: [],
@@ -575,6 +593,70 @@ describe("resolveGatewayStartupPluginIds", () => {
575593
}),
576594
["demo-channel", "browser", "memory-core"],
577595
],
596+
[
597+
"includes configured bundled speech providers at startup",
598+
{
599+
channels: {},
600+
messages: { tts: { provider: "microsoft" } },
601+
} as OpenClawConfig,
602+
["browser", "microsoft", "memory-core"],
603+
],
604+
[
605+
"includes bundled speech providers configured by provider block",
606+
{
607+
channels: {},
608+
messages: { tts: { providers: { "tts-local-cli": { command: "say" } } } },
609+
} as OpenClawConfig,
610+
["browser", "tts-local-cli", "memory-core"],
611+
],
612+
[
613+
"maps legacy edge TTS selection to the Microsoft speech plugin",
614+
{
615+
channels: {},
616+
messages: { tts: { provider: "edge" } },
617+
} as OpenClawConfig,
618+
["browser", "microsoft", "memory-core"],
619+
],
620+
[
621+
"includes active persona speech providers at startup",
622+
{
623+
channels: {},
624+
messages: {
625+
tts: {
626+
persona: "narrator",
627+
personas: {
628+
narrator: {
629+
label: "Narrator",
630+
provider: "microsoft",
631+
},
632+
},
633+
},
634+
},
635+
} as OpenClawConfig,
636+
["browser", "microsoft", "memory-core"],
637+
],
638+
[
639+
"honors disabled speech provider config blocks at startup",
640+
{
641+
channels: {},
642+
messages: {
643+
tts: {
644+
provider: "microsoft",
645+
providers: { microsoft: { enabled: false } },
646+
},
647+
},
648+
} as OpenClawConfig,
649+
["browser", "memory-core"],
650+
],
651+
[
652+
"honors explicit plugin disablement for configured speech providers",
653+
{
654+
channels: {},
655+
messages: { tts: { provider: "microsoft" } },
656+
plugins: { entries: { microsoft: { enabled: false } } },
657+
} as OpenClawConfig,
658+
["browser", "memory-core"],
659+
],
578660
[
579661
"includes explicitly enabled non-channel sidecars in startup scope",
580662
createStartupConfig({

src/plugins/gateway-startup-plugin-ids.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
1414
import { hasExplicitChannelConfig } from "./channel-presence-policy.js";
1515
import { collectPluginConfigContractMatches } from "./config-contracts.js";
1616
import { resolveEffectivePluginActivationState } from "./config-state.js";
17+
import {
18+
collectConfiguredSpeechProviderIds,
19+
normalizeConfiguredSpeechProviderIdForStartup,
20+
} from "./gateway-startup-speech-providers.js";
1721
import type { InstalledPluginIndexRecord } from "./installed-plugin-index.js";
1822
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
1923
import {
@@ -161,6 +165,64 @@ function hasConfiguredActivationPath(params: {
161165
);
162166
}
163167

168+
function manifestOwnsConfiguredSpeechProvider(params: {
169+
manifest: PluginManifestRecord | undefined;
170+
configuredSpeechProviderIds: ReadonlySet<string>;
171+
}): boolean {
172+
if (params.configuredSpeechProviderIds.size === 0) {
173+
return false;
174+
}
175+
return (params.manifest?.contracts?.speechProviders ?? []).some((providerId) => {
176+
const normalized = normalizeConfiguredSpeechProviderIdForStartup(providerId);
177+
return normalized ? params.configuredSpeechProviderIds.has(normalized) : false;
178+
});
179+
}
180+
181+
function canStartConfiguredSpeechProviderPlugin(params: {
182+
plugin: InstalledPluginIndexRecord;
183+
manifest: PluginManifestRecord | undefined;
184+
config: OpenClawConfig;
185+
pluginsConfig: ReturnType<typeof normalizePluginsConfigWithRegistry>;
186+
activationSource: {
187+
plugins: ReturnType<typeof normalizePluginsConfigWithRegistry>;
188+
rootConfig?: OpenClawConfig;
189+
};
190+
configuredSpeechProviderIds: ReadonlySet<string>;
191+
}): boolean {
192+
if (
193+
!manifestOwnsConfiguredSpeechProvider({
194+
manifest: params.manifest,
195+
configuredSpeechProviderIds: params.configuredSpeechProviderIds,
196+
})
197+
) {
198+
return false;
199+
}
200+
if (
201+
params.pluginsConfig.deny.includes(params.plugin.pluginId) ||
202+
params.activationSource.plugins.deny.includes(params.plugin.pluginId)
203+
) {
204+
return false;
205+
}
206+
if (
207+
params.pluginsConfig.entries[params.plugin.pluginId]?.enabled === false ||
208+
params.activationSource.plugins.entries[params.plugin.pluginId]?.enabled === false
209+
) {
210+
return false;
211+
}
212+
if (params.plugin.origin === "bundled") {
213+
return true;
214+
}
215+
const activationState = resolveEffectivePluginActivationState({
216+
id: params.plugin.pluginId,
217+
origin: params.plugin.origin,
218+
config: params.pluginsConfig,
219+
rootConfig: params.config,
220+
enabledByDefault: params.plugin.enabledByDefault,
221+
activationSource: params.activationSource,
222+
});
223+
return activationState.enabled && activationState.explicitlyEnabled;
224+
}
225+
164226
function canStartConfiguredRootPlugin(params: {
165227
plugin: InstalledPluginIndexRecord;
166228
manifest: PluginManifestRecord | undefined;
@@ -341,6 +403,7 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: {
341403
);
342404
const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config);
343405
const manifestLookup = createManifestRegistryLookup(params.manifestRegistry);
406+
const configuredSpeechProviderIds = collectConfiguredSpeechProviderIds(activationSourceConfig);
344407
const memorySlotStartupPluginId = resolveMemorySlotStartupPluginId({
345408
activationSourceConfig,
346409
activationSourcePlugins,
@@ -390,6 +453,18 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: {
390453
) {
391454
return true;
392455
}
456+
if (
457+
canStartConfiguredSpeechProviderPlugin({
458+
plugin,
459+
manifest,
460+
config: params.config,
461+
pluginsConfig,
462+
activationSource,
463+
configuredSpeechProviderIds,
464+
})
465+
) {
466+
return true;
467+
}
393468
if (
394469
!shouldConsiderForGatewayStartup({
395470
plugin,
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
3+
4+
const TTS_PROVIDER_CONFIG_RESERVED_KEYS = new Set([
5+
"auto",
6+
"enabled",
7+
"maxTextLength",
8+
"mode",
9+
"modelOverrides",
10+
"persona",
11+
"personas",
12+
"prefsPath",
13+
"provider",
14+
"providers",
15+
"summaryModel",
16+
"timeoutMs",
17+
]);
18+
19+
function isRecord(value: unknown): value is Record<string, unknown> {
20+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
21+
}
22+
23+
function isConfigActivationValueEnabled(value: unknown): boolean {
24+
if (value === false) {
25+
return false;
26+
}
27+
if (isRecord(value) && value.enabled === false) {
28+
return false;
29+
}
30+
return true;
31+
}
32+
33+
export function normalizeConfiguredSpeechProviderIdForStartup(value: unknown): string | undefined {
34+
if (typeof value !== "string") {
35+
return undefined;
36+
}
37+
const normalized = normalizeOptionalLowercaseString(value);
38+
if (!normalized) {
39+
return undefined;
40+
}
41+
return normalized === "edge" ? "microsoft" : normalized;
42+
}
43+
44+
function resolveProviderConfigActivation(
45+
ttsConfig: Record<string, unknown>,
46+
providerId: string,
47+
): boolean | undefined {
48+
let fromProviders: boolean | undefined;
49+
if (isRecord(ttsConfig.providers)) {
50+
for (const [key, providerConfig] of Object.entries(ttsConfig.providers)) {
51+
if (normalizeConfiguredSpeechProviderIdForStartup(key) === providerId) {
52+
fromProviders = isConfigActivationValueEnabled(providerConfig);
53+
}
54+
}
55+
}
56+
if (fromProviders !== undefined) {
57+
return fromProviders;
58+
}
59+
60+
for (const [key, providerConfig] of Object.entries(ttsConfig)) {
61+
if (TTS_PROVIDER_CONFIG_RESERVED_KEYS.has(key) || !isRecord(providerConfig)) {
62+
continue;
63+
}
64+
if (normalizeConfiguredSpeechProviderIdForStartup(key) === providerId) {
65+
return isConfigActivationValueEnabled(providerConfig);
66+
}
67+
}
68+
return undefined;
69+
}
70+
71+
function addProviderIfEnabled(
72+
target: Set<string>,
73+
ttsConfig: Record<string, unknown>,
74+
providerId: unknown,
75+
): void {
76+
const normalized = normalizeConfiguredSpeechProviderIdForStartup(providerId);
77+
if (!normalized) {
78+
return;
79+
}
80+
if (resolveProviderConfigActivation(ttsConfig, normalized) !== false) {
81+
target.add(normalized);
82+
}
83+
}
84+
85+
function findActivePersona(
86+
ttsConfig: Record<string, unknown>,
87+
): Record<string, unknown> | undefined {
88+
const personaId = normalizeOptionalLowercaseString(
89+
typeof ttsConfig.persona === "string" ? ttsConfig.persona : undefined,
90+
);
91+
if (!personaId || !isRecord(ttsConfig.personas)) {
92+
return undefined;
93+
}
94+
for (const [id, persona] of Object.entries(ttsConfig.personas)) {
95+
if (normalizeOptionalLowercaseString(id) === personaId && isRecord(persona)) {
96+
return persona;
97+
}
98+
}
99+
return undefined;
100+
}
101+
102+
function addActivePersonaProvider(target: Set<string>, ttsConfig: Record<string, unknown>): void {
103+
const persona = findActivePersona(ttsConfig);
104+
if (!persona) {
105+
return;
106+
}
107+
const provider = normalizeConfiguredSpeechProviderIdForStartup(persona.provider);
108+
if (!provider) {
109+
return;
110+
}
111+
const rootActivation = resolveProviderConfigActivation(ttsConfig, provider);
112+
const personaActivation = resolveProviderConfigActivation(persona, provider);
113+
if ((personaActivation ?? rootActivation) !== false) {
114+
target.add(provider);
115+
}
116+
}
117+
118+
function addConfiguredTtsProviderIds(target: Set<string>, value: unknown): void {
119+
if (!isRecord(value)) {
120+
return;
121+
}
122+
addProviderIfEnabled(target, value, value.provider);
123+
addActivePersonaProvider(target, value);
124+
125+
if (isRecord(value.providers)) {
126+
for (const [providerId, providerConfig] of Object.entries(value.providers)) {
127+
if (isConfigActivationValueEnabled(providerConfig)) {
128+
addProviderIfEnabled(target, value, providerId);
129+
}
130+
}
131+
}
132+
for (const [key, providerConfig] of Object.entries(value)) {
133+
if (TTS_PROVIDER_CONFIG_RESERVED_KEYS.has(key) || !isRecord(providerConfig)) {
134+
continue;
135+
}
136+
if (isConfigActivationValueEnabled(providerConfig)) {
137+
addProviderIfEnabled(target, value, key);
138+
}
139+
}
140+
}
141+
142+
export function collectConfiguredSpeechProviderIds(config: OpenClawConfig): ReadonlySet<string> {
143+
const configured = new Set<string>();
144+
addConfiguredTtsProviderIds(configured, config.messages?.tts);
145+
146+
const agents = config.agents;
147+
if (isRecord(agents) && Array.isArray(agents.list)) {
148+
for (const agent of agents.list) {
149+
if (isRecord(agent)) {
150+
addConfiguredTtsProviderIds(configured, agent.tts);
151+
}
152+
}
153+
}
154+
155+
const channels = config.channels;
156+
if (isRecord(channels)) {
157+
for (const channelConfig of Object.values(channels)) {
158+
if (!isRecord(channelConfig)) {
159+
continue;
160+
}
161+
addConfiguredTtsProviderIds(configured, channelConfig.tts);
162+
if (isRecord(channelConfig.voice)) {
163+
addConfiguredTtsProviderIds(configured, channelConfig.voice.tts);
164+
}
165+
if (isRecord(channelConfig.accounts)) {
166+
for (const accountConfig of Object.values(channelConfig.accounts)) {
167+
if (!isRecord(accountConfig)) {
168+
continue;
169+
}
170+
addConfiguredTtsProviderIds(configured, accountConfig.tts);
171+
if (isRecord(accountConfig.voice)) {
172+
addConfiguredTtsProviderIds(configured, accountConfig.voice.tts);
173+
}
174+
}
175+
}
176+
}
177+
}
178+
179+
const pluginEntries = config.plugins?.entries;
180+
if (isRecord(pluginEntries)) {
181+
for (const entry of Object.values(pluginEntries)) {
182+
if (isRecord(entry) && isRecord(entry.config)) {
183+
addConfiguredTtsProviderIds(configured, entry.config.tts);
184+
}
185+
}
186+
}
187+
188+
return configured;
189+
}

0 commit comments

Comments
 (0)