Skip to content

Commit e4b5027

Browse files
committed
refactor(plugins): move extension seams into extensions
1 parent c19321e commit e4b5027

234 files changed

Lines changed: 7727 additions & 5494 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/.generated/plugin-sdk-api-baseline.json

Lines changed: 484 additions & 142 deletions
Large diffs are not rendered by default.

docs/.generated/plugin-sdk-api-baseline.jsonl

Lines changed: 156 additions & 118 deletions
Large diffs are not rendered by default.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export {
2+
createAnthropicBetaHeadersWrapper,
3+
createAnthropicFastModeWrapper,
4+
createAnthropicServiceTierWrapper,
5+
resolveAnthropicBetas,
6+
resolveAnthropicFastMode,
7+
resolveAnthropicServiceTier,
8+
} from "./stream-wrappers.js";

extensions/discord/contract-api.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export { createThreadBindingManager } from "./src/monitor/thread-bindings.manager.js";
2+
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
3+
export {
4+
collectRuntimeConfigAssignments,
5+
secretTargetRegistryEntries,
6+
} from "./src/secret-config-contract.js";
7+
export {
8+
unsupportedSecretRefSurfacePatterns,
9+
collectUnsupportedSecretRefConfigCandidates,
10+
} from "./src/security-contract.js";
11+
export { deriveLegacySessionChatType } from "./src/session-contract.js";
12+
export type {
13+
DiscordInteractiveHandlerContext,
14+
DiscordInteractiveHandlerRegistration,
15+
} from "./src/interactive-dispatch.js";
16+
export { collectDiscordSecurityAuditFindings } from "./src/security-audit.js";
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import type {
2+
ChannelDoctorConfigMutation,
3+
ChannelDoctorLegacyConfigRule,
4+
} from "openclaw/plugin-sdk/channel-contract";
5+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
6+
import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js";
7+
8+
function asObjectRecord(value: unknown): Record<string, unknown> | null {
9+
return value && typeof value === "object" && !Array.isArray(value)
10+
? (value as Record<string, unknown>)
11+
: null;
12+
}
13+
14+
function normalizeDiscordStreamingAliases(params: {
15+
entry: Record<string, unknown>;
16+
pathPrefix: string;
17+
changes: string[];
18+
}): { entry: Record<string, unknown>; changed: boolean } {
19+
let updated = params.entry;
20+
const hadLegacyStreamMode = updated.streamMode !== undefined;
21+
const beforeStreaming = updated.streaming;
22+
const resolved = resolveDiscordPreviewStreamMode(updated);
23+
const shouldNormalize =
24+
hadLegacyStreamMode ||
25+
typeof beforeStreaming === "boolean" ||
26+
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
27+
if (!shouldNormalize) {
28+
return { entry: updated, changed: false };
29+
}
30+
31+
let changed = false;
32+
if (beforeStreaming !== resolved) {
33+
updated = { ...updated, streaming: resolved };
34+
changed = true;
35+
}
36+
if (hadLegacyStreamMode) {
37+
const { streamMode: _ignored, ...rest } = updated;
38+
updated = rest;
39+
changed = true;
40+
params.changes.push(
41+
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
42+
);
43+
}
44+
if (typeof beforeStreaming === "boolean") {
45+
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
46+
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
47+
params.changes.push(
48+
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
49+
);
50+
}
51+
if (
52+
params.pathPrefix.startsWith("channels.discord") &&
53+
resolved === "off" &&
54+
hadLegacyStreamMode
55+
) {
56+
params.changes.push(
57+
`${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming="partial" to opt in explicitly.`,
58+
);
59+
}
60+
return { entry: updated, changed };
61+
}
62+
63+
function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
64+
const entry = asObjectRecord(value);
65+
if (!entry) {
66+
return false;
67+
}
68+
return (
69+
entry.streamMode !== undefined ||
70+
typeof entry.streaming === "boolean" ||
71+
(typeof entry.streaming === "string" &&
72+
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
73+
);
74+
}
75+
76+
function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean {
77+
const accounts = asObjectRecord(value);
78+
if (!accounts) {
79+
return false;
80+
}
81+
return Object.values(accounts).some((account) => hasLegacyDiscordStreamingAliases(account));
82+
}
83+
84+
const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const;
85+
86+
function hasLegacyTtsProviderKeys(value: unknown): boolean {
87+
const tts = asObjectRecord(value);
88+
if (!tts) {
89+
return false;
90+
}
91+
return LEGACY_TTS_PROVIDER_KEYS.some((key) => Object.prototype.hasOwnProperty.call(tts, key));
92+
}
93+
94+
function hasLegacyDiscordAccountTtsProviderKeys(value: unknown): boolean {
95+
const accounts = asObjectRecord(value);
96+
if (!accounts) {
97+
return false;
98+
}
99+
return Object.values(accounts).some((accountValue) => {
100+
const account = asObjectRecord(accountValue);
101+
const voice = asObjectRecord(account?.voice);
102+
return hasLegacyTtsProviderKeys(voice?.tts);
103+
});
104+
}
105+
106+
function mergeMissing(target: Record<string, unknown>, source: Record<string, unknown>) {
107+
for (const [key, value] of Object.entries(source)) {
108+
if (value === undefined) {
109+
continue;
110+
}
111+
const existing = target[key];
112+
if (existing === undefined) {
113+
target[key] = value;
114+
continue;
115+
}
116+
if (
117+
existing &&
118+
typeof existing === "object" &&
119+
!Array.isArray(existing) &&
120+
value &&
121+
typeof value === "object" &&
122+
!Array.isArray(value)
123+
) {
124+
mergeMissing(existing as Record<string, unknown>, value as Record<string, unknown>);
125+
}
126+
}
127+
}
128+
129+
function getOrCreateTtsProviders(tts: Record<string, unknown>): Record<string, unknown> {
130+
const providers = asObjectRecord(tts.providers) ?? {};
131+
tts.providers = providers;
132+
return providers;
133+
}
134+
135+
function mergeLegacyTtsProviderConfig(
136+
tts: Record<string, unknown>,
137+
legacyKey: string,
138+
providerId: string,
139+
): boolean {
140+
const legacyValue = asObjectRecord(tts[legacyKey]);
141+
if (!legacyValue) {
142+
return false;
143+
}
144+
const providers = getOrCreateTtsProviders(tts);
145+
const existing = asObjectRecord(providers[providerId]) ?? {};
146+
const merged = structuredClone(existing);
147+
mergeMissing(merged, legacyValue);
148+
providers[providerId] = merged;
149+
delete tts[legacyKey];
150+
return true;
151+
}
152+
153+
function migrateLegacyTtsConfig(
154+
tts: Record<string, unknown> | null,
155+
pathLabel: string,
156+
changes: string[],
157+
): boolean {
158+
if (!tts) {
159+
return false;
160+
}
161+
let changed = false;
162+
if (mergeLegacyTtsProviderConfig(tts, "openai", "openai")) {
163+
changes.push(`Moved ${pathLabel}.openai → ${pathLabel}.providers.openai.`);
164+
changed = true;
165+
}
166+
if (mergeLegacyTtsProviderConfig(tts, "elevenlabs", "elevenlabs")) {
167+
changes.push(`Moved ${pathLabel}.elevenlabs → ${pathLabel}.providers.elevenlabs.`);
168+
changed = true;
169+
}
170+
if (mergeLegacyTtsProviderConfig(tts, "microsoft", "microsoft")) {
171+
changes.push(`Moved ${pathLabel}.microsoft → ${pathLabel}.providers.microsoft.`);
172+
changed = true;
173+
}
174+
if (mergeLegacyTtsProviderConfig(tts, "edge", "microsoft")) {
175+
changes.push(`Moved ${pathLabel}.edge → ${pathLabel}.providers.microsoft.`);
176+
changed = true;
177+
}
178+
return changed;
179+
}
180+
181+
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
182+
{
183+
path: ["channels", "discord"],
184+
message:
185+
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
186+
match: hasLegacyDiscordStreamingAliases,
187+
},
188+
{
189+
path: ["channels", "discord", "accounts"],
190+
message:
191+
"channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming.",
192+
match: hasLegacyDiscordAccountStreamingAliases,
193+
},
194+
{
195+
path: ["channels", "discord", "voice", "tts"],
196+
message:
197+
"channels.discord.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.voice.tts.providers.<provider> (auto-migrated on load).",
198+
match: hasLegacyTtsProviderKeys,
199+
},
200+
{
201+
path: ["channels", "discord", "accounts"],
202+
message:
203+
"channels.discord.accounts.<id>.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.accounts.<id>.voice.tts.providers.<provider> (auto-migrated on load).",
204+
match: hasLegacyDiscordAccountTtsProviderKeys,
205+
},
206+
];
207+
208+
export function normalizeCompatibilityConfig({
209+
cfg,
210+
}: {
211+
cfg: OpenClawConfig;
212+
}): ChannelDoctorConfigMutation {
213+
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.discord);
214+
if (!rawEntry) {
215+
return { config: cfg, changes: [] };
216+
}
217+
218+
const changes: string[] = [];
219+
let updated = rawEntry;
220+
let changed = false;
221+
222+
const streaming = normalizeDiscordStreamingAliases({
223+
entry: updated,
224+
pathPrefix: "channels.discord",
225+
changes,
226+
});
227+
updated = streaming.entry;
228+
changed = changed || streaming.changed;
229+
230+
const rawAccounts = asObjectRecord(updated.accounts);
231+
if (rawAccounts) {
232+
let accountsChanged = false;
233+
const accounts = { ...rawAccounts };
234+
for (const [accountId, rawAccount] of Object.entries(rawAccounts)) {
235+
const account = asObjectRecord(rawAccount);
236+
if (!account) {
237+
continue;
238+
}
239+
const accountStreaming = normalizeDiscordStreamingAliases({
240+
entry: account,
241+
pathPrefix: `channels.discord.accounts.${accountId}`,
242+
changes,
243+
});
244+
if (accountStreaming.changed) {
245+
accounts[accountId] = accountStreaming.entry;
246+
accountsChanged = true;
247+
}
248+
const accountVoice = asObjectRecord(accountStreaming.entry.voice);
249+
if (
250+
accountVoice &&
251+
migrateLegacyTtsConfig(
252+
asObjectRecord(accountVoice.tts),
253+
`channels.discord.accounts.${accountId}.voice.tts`,
254+
changes,
255+
)
256+
) {
257+
accounts[accountId] = {
258+
...accountStreaming.entry,
259+
voice: accountVoice,
260+
};
261+
accountsChanged = true;
262+
}
263+
}
264+
if (accountsChanged) {
265+
updated = { ...updated, accounts };
266+
changed = true;
267+
}
268+
}
269+
270+
const voice = asObjectRecord(updated.voice);
271+
if (
272+
voice &&
273+
migrateLegacyTtsConfig(asObjectRecord(voice.tts), "channels.discord.voice.tts", changes)
274+
) {
275+
updated = { ...updated, voice };
276+
changed = true;
277+
}
278+
279+
if (!changed) {
280+
return { config: cfg, changes: [] };
281+
}
282+
return {
283+
config: {
284+
...cfg,
285+
channels: {
286+
...cfg.channels,
287+
discord: updated,
288+
} as OpenClawConfig["channels"],
289+
},
290+
changes,
291+
};
292+
}

extensions/discord/src/doctor.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
import {
22
type ChannelDoctorAdapter,
33
type ChannelDoctorConfigMutation,
4+
type ChannelDoctorLegacyConfigRule,
45
} from "openclaw/plugin-sdk/channel-contract";
5-
import {
6-
resolveDiscordPreviewStreamMode,
7-
type OpenClawConfig,
8-
} from "openclaw/plugin-sdk/config-runtime";
9-
import {
10-
collectProviderDangerousNameMatchingScopes,
11-
isDiscordMutableAllowEntry,
12-
} from "openclaw/plugin-sdk/runtime";
6+
import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
7+
import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime";
8+
import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js";
9+
import { isDiscordMutableAllowEntry } from "./security-audit.js";
1310

1411
type DiscordNumericIdHit = { path: string; entry: number; safe: boolean };
1512

@@ -517,11 +514,48 @@ function collectDiscordMutableAllowlistWarnings(cfg: OpenClawConfig): string[] {
517514
];
518515
}
519516

517+
function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
518+
const entry = asObjectRecord(value);
519+
if (!entry) {
520+
return false;
521+
}
522+
return (
523+
entry.streamMode !== undefined ||
524+
typeof entry.streaming === "boolean" ||
525+
(typeof entry.streaming === "string" &&
526+
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
527+
);
528+
}
529+
530+
function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean {
531+
const accounts = asObjectRecord(value);
532+
if (!accounts) {
533+
return false;
534+
}
535+
return Object.values(accounts).some((account) => hasLegacyDiscordStreamingAliases(account));
536+
}
537+
538+
const DISCORD_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
539+
{
540+
path: ["channels", "discord"],
541+
message:
542+
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
543+
match: hasLegacyDiscordStreamingAliases,
544+
},
545+
{
546+
path: ["channels", "discord", "accounts"],
547+
message:
548+
"channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming.",
549+
match: hasLegacyDiscordAccountStreamingAliases,
550+
},
551+
];
552+
520553
export const discordDoctor: ChannelDoctorAdapter = {
521554
dmAllowFromMode: "topOrNested",
522555
groupModel: "route",
523556
groupAllowFromFallbackToAllowFrom: false,
524557
warnOnEmptyGroupSenderAllowlist: false,
558+
legacyConfigRules: DISCORD_LEGACY_CONFIG_RULES,
525559
normalizeCompatibilityConfig: ({ cfg }) => normalizeDiscordCompatibilityConfig(cfg),
526560
collectPreviewWarnings: ({ cfg, doctorFixCommand }) =>
527561
collectDiscordNumericIdWarnings({

0 commit comments

Comments
 (0)