Skip to content

Commit 605e894

Browse files
authored
fix(discord): avoid blocking startup on probe (#77129)
* fix(discord): avoid blocking startup on probe * fix(discord): clear degraded probe status * test(plugin-sdk): isolate jiti loader override * test(plugin-sdk): fix circular facade fixture path * fix(plugins): preserve sdk aliases for native loads * fix(plugins): route sdk alias loads through transform
1 parent fa68929 commit 605e894

8 files changed

Lines changed: 211 additions & 54 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai
6464
- Agents/Pi: suppress persistence for synthetic mid-turn overflow continuation prompts, so transcript-retry recovery does not write the "continue from transcript" prompt as a new user turn. Thanks @vincentkoc.
6565
- Agents/tools: strip reasoning text from visible rich presentation titles, blocks, buttons, and select labels before message-tool sends, so structured channel payloads cannot leak hidden planning. Thanks @vincentkoc.
6666
- Telegram: keep reply-dispatch lazy provider runtime chunks behind stable dist names and delete `/reasoning stream` previews after final delivery so package updates and live reasoning drafts do not leave Telegram turns broken or noisy. Thanks @BunsDev.
67+
- Discord: start the gateway monitor without waiting for the startup bot/application probe, so WSL2 hosts with a slow `/users/@me` REST path still bring the channel online while status enrichment finishes asynchronously. Fixes #77103. Thanks @Suited78.
6768
- Exec approvals: detect `env -S` split-string command-carrier risks when `-S`/`-s` is combined with other env short options, so approval explanations do not miss split payloads hidden behind `env -iS...`. Thanks @vincentkoc.
6869
- Google Meet: log the concrete agent-mode TTS provider, model, voice, output format, and sample rate after speech synthesis, so Meet logs show which voice backend spoke each reply.
6970
- Voice Call: mark realtime calls completed when the realtime provider closes normally, so Twilio/OpenAI/Google realtime stop events do not leave active call records behind. Thanks @vincentkoc.

extensions/discord/src/channel.test.ts

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ describe("discordPlugin outbound", () => {
379379
expect(runtimeProbeDiscord).not.toHaveBeenCalled();
380380
});
381381

382-
it("uses direct Discord startup helpers before monitoring", async () => {
382+
it("uses direct Discord startup helpers for async startup enrichment", async () => {
383383
const runtimeProbeDiscord = vi.fn(async () => {
384384
throw new Error("runtime Discord probe should not be used");
385385
});
@@ -407,9 +407,11 @@ describe("discordPlugin outbound", () => {
407407
const cfg = createCfg();
408408
await startDiscordAccount(cfg);
409409

410-
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 2500, {
411-
includeApplication: true,
412-
});
410+
await vi.waitFor(() =>
411+
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 2500, {
412+
includeApplication: true,
413+
}),
414+
);
413415
expect(monitorDiscordProviderMock).toHaveBeenCalledWith(
414416
expect.objectContaining({
415417
token: "discord-token",
@@ -421,6 +423,98 @@ describe("discordPlugin outbound", () => {
421423
expect(runtimeMonitorDiscordProvider).not.toHaveBeenCalled();
422424
});
423425

426+
it("does not block Discord monitor startup on the startup probe", async () => {
427+
let resolveProbe!: (value: {
428+
ok: true;
429+
bot: { username: string };
430+
application: { intents: { messageContent: "limited" } };
431+
elapsedMs: number;
432+
}) => void;
433+
probeDiscordMock.mockReturnValue(
434+
new Promise((resolve) => {
435+
resolveProbe = resolve;
436+
}),
437+
);
438+
monitorDiscordProviderMock.mockResolvedValue(undefined);
439+
440+
const cfg = createCfg();
441+
const statusPatches: Array<Record<string, unknown>> = [];
442+
const ctx = createStartAccountContext({
443+
account: resolveAccount(cfg),
444+
cfg,
445+
statusPatchSink: (next) => statusPatches.push({ ...next }),
446+
});
447+
448+
await discordPlugin.gateway!.startAccount!(ctx);
449+
450+
expect(monitorDiscordProviderMock).toHaveBeenCalledWith(
451+
expect.objectContaining({
452+
token: "discord-token",
453+
accountId: "default",
454+
}),
455+
);
456+
await vi.waitFor(() =>
457+
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 2500, {
458+
includeApplication: true,
459+
}),
460+
);
461+
expect(statusPatches.some((patch) => "bot" in patch || "application" in patch)).toBe(false);
462+
463+
resolveProbe({
464+
ok: true,
465+
bot: { username: "AsyncBob" },
466+
application: { intents: { messageContent: "limited" } },
467+
elapsedMs: 1,
468+
});
469+
470+
await vi.waitFor(() =>
471+
expect(
472+
statusPatches.some(
473+
(patch) =>
474+
(patch.bot as { username?: string } | undefined)?.username === "AsyncBob" &&
475+
Boolean(patch.application),
476+
),
477+
).toBe(true),
478+
);
479+
});
480+
481+
it("clears stale Discord probe metadata when the async startup probe degrades", async () => {
482+
probeDiscordMock.mockResolvedValue({
483+
ok: false,
484+
status: 401,
485+
error: "getMe failed (401)",
486+
elapsedMs: 1,
487+
});
488+
monitorDiscordProviderMock.mockResolvedValue(undefined);
489+
490+
const cfg = createCfg();
491+
const statusPatches: Array<Record<string, unknown>> = [];
492+
const ctx = createStartAccountContext({
493+
account: resolveAccount(cfg),
494+
cfg,
495+
statusPatchSink: (next) => statusPatches.push({ ...next }),
496+
});
497+
ctx.setStatus({
498+
accountId: "default",
499+
bot: { username: "OldBot" },
500+
application: { intents: { messageContent: "enabled" } },
501+
});
502+
503+
await discordPlugin.gateway!.startAccount!(ctx);
504+
505+
await vi.waitFor(() =>
506+
expect(
507+
statusPatches.some(
508+
(patch) =>
509+
"bot" in patch &&
510+
"application" in patch &&
511+
patch.bot === undefined &&
512+
patch.application === undefined,
513+
),
514+
).toBe(true),
515+
);
516+
});
517+
424518
it("stagger starts later accounts in multi-bot setups", async () => {
425519
probeDiscordMock.mockResolvedValue({
426520
ok: true,

extensions/discord/src/channel.ts

Lines changed: 63 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,61 @@ import { parseDiscordTarget } from "./target-parsing.js";
8282
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
8383
const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000;
8484

85+
function startDiscordStartupProbe(params: {
86+
accountId: string;
87+
token: string;
88+
abortSignal: AbortSignal;
89+
setStatus: (patch: { accountId: string; bot?: unknown; application?: unknown }) => void;
90+
log?: {
91+
warn?: (msg: string) => void;
92+
info?: (msg: string) => void;
93+
debug?: (msg: string) => void;
94+
};
95+
}): void {
96+
void (async () => {
97+
try {
98+
const probe = await (
99+
await loadDiscordProbeRuntime()
100+
).probeDiscord(params.token, 2500, {
101+
includeApplication: true,
102+
});
103+
if (params.abortSignal.aborted) {
104+
return;
105+
}
106+
params.setStatus({
107+
accountId: params.accountId,
108+
bot: probe.bot,
109+
application: probe.application,
110+
});
111+
if (probe.ok) {
112+
const username = probe.bot?.username?.trim();
113+
if (username) {
114+
params.log?.info?.(`[${params.accountId}] Discord bot probe resolved @${username}`);
115+
}
116+
} else if (getDiscordRuntime().logging.shouldLogVerbose()) {
117+
params.log?.debug?.(
118+
`[${params.accountId}] bot probe degraded: ${probe.error ?? `status ${probe.status ?? "unknown"}`}`,
119+
);
120+
}
121+
122+
const messageContent = probe.application?.intents?.messageContent;
123+
if (messageContent === "disabled") {
124+
params.log?.warn?.(
125+
`[${params.accountId}] Discord Message Content Intent is disabled; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`,
126+
);
127+
} else if (messageContent === "limited") {
128+
params.log?.info?.(
129+
`[${params.accountId}] Discord Message Content Intent is limited; bots under 100 servers can use it without verification.`,
130+
);
131+
}
132+
} catch (err) {
133+
if (getDiscordRuntime().logging.shouldLogVerbose()) {
134+
params.log?.debug?.(`[${params.accountId}] bot probe failed: ${String(err)}`);
135+
}
136+
}
137+
})();
138+
}
139+
85140
function shouldTreatDiscordDeliveredTextAsVisible(params: {
86141
kind: "tool" | "block" | "final";
87142
text?: string;
@@ -551,38 +606,14 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
551606
}
552607
}
553608
const token = account.token.trim();
554-
let discordBotLabel = "";
555-
try {
556-
const probe = await (
557-
await loadDiscordProbeRuntime()
558-
).probeDiscord(token, 2500, {
559-
includeApplication: true,
560-
});
561-
const username = probe.ok ? probe.bot?.username?.trim() : null;
562-
if (username) {
563-
discordBotLabel = ` (@${username})`;
564-
}
565-
ctx.setStatus({
566-
accountId: account.accountId,
567-
bot: probe.bot,
568-
application: probe.application,
569-
});
570-
const messageContent = probe.application?.intents?.messageContent;
571-
if (messageContent === "disabled") {
572-
ctx.log?.warn(
573-
`[${account.accountId}] Discord Message Content Intent is disabled; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`,
574-
);
575-
} else if (messageContent === "limited") {
576-
ctx.log?.info(
577-
`[${account.accountId}] Discord Message Content Intent is limited; bots under 100 servers can use it without verification.`,
578-
);
579-
}
580-
} catch (err) {
581-
if (getDiscordRuntime().logging.shouldLogVerbose()) {
582-
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
583-
}
584-
}
585-
ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
609+
startDiscordStartupProbe({
610+
accountId: account.accountId,
611+
token,
612+
abortSignal: ctx.abortSignal,
613+
setStatus: ctx.setStatus,
614+
log: ctx.log,
615+
});
616+
ctx.log?.info(`[${account.accountId}] starting provider`);
586617
return (await loadDiscordProviderRuntime()).monitorDiscordProvider({
587618
token,
588619
accountId: account.accountId,

src/plugin-sdk/channel-entry-contract.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,9 @@ describe("loadBundledEntryExportSync", () => {
421421
});
422422

423423
it("can disable source-tree fallback for dist bundled entry checks", () => {
424+
stubPluginModuleLoaderJitiFactory(
425+
vi.fn(() => vi.fn(() => ({ sentinel: 42 }))) as unknown as PluginModuleLoaderFactory,
426+
);
424427
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-entry-contract-"));
425428
tempDirs.push(tempRoot);
426429

src/plugin-sdk/facade-loader.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ function createCircularPluginFixture(prefix: string): TrustedBundledPluginFixtur
140140
);
141141
fs.writeFileSync(
142142
path.join(pluginRoot, "helper.js"),
143-
['import { marker } from "../facade.mjs";', "export const circularMarker = marker;", ""].join(
143+
['import { marker } from "./facade.mjs";', "export const circularMarker = marker;", ""].join(
144144
"\n",
145145
),
146146
"utf8",

src/plugins/loader.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,14 @@ import {
9999
restoreMemoryPluginState,
100100
} from "./memory-state.js";
101101
import { unwrapDefaultModuleExport } from "./module-export.js";
102-
import { tryNativeRequireJavaScriptModule } from "./native-module-require.js";
103102
import {
104103
fingerprintPluginDiscoveryContext,
105104
resolvePluginDiscoveryContext,
106105
} from "./plugin-control-plane-context.js";
107106
import { withProfile } from "./plugin-load-profile.js";
108107
import {
109108
createPluginModuleLoaderCache,
110-
getCachedPluginSourceModuleLoader,
109+
getCachedPluginModuleLoader,
111110
type PluginModuleLoaderCache,
112111
} from "./plugin-module-loader-cache.js";
113112
import type { PluginOrigin } from "./plugin-origin.types.js";
@@ -480,8 +479,8 @@ function runPluginRegisterSync(
480479

481480
function createPluginModuleLoader(options: Pick<PluginLoadOptions, "pluginSdkResolution">) {
482481
const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache();
483-
const loadSourceModule = (modulePath: string) => {
484-
return getCachedPluginSourceModuleLoader({
482+
const createLoaderForModule = (modulePath: string) => {
483+
return getCachedPluginModuleLoader({
485484
cache: moduleLoaders,
486485
modulePath,
487486
importerUrl: import.meta.url,
@@ -495,18 +494,8 @@ function createPluginModuleLoader(options: Pick<PluginLoadOptions, "pluginSdkRes
495494
pluginSdkResolution: options.pluginSdkResolution,
496495
});
497496
};
498-
return (modulePath: string): unknown => {
499-
if (shouldPreferNativeModuleLoad(modulePath)) {
500-
const native = tryNativeRequireJavaScriptModule(modulePath, { allowWindows: true });
501-
if (native.ok) {
502-
return native.moduleExport;
503-
}
504-
}
505-
// Source .ts runtime shims import sibling ".js" specifiers that only exist
506-
// after build. Jiti remains the dev/source fallback because it rewrites those
507-
// imports against the source graph and applies SDK aliases.
508-
return loadSourceModule(modulePath)(toSafeImportPath(modulePath));
509-
};
497+
return (modulePath: string): unknown =>
498+
createLoaderForModule(modulePath)(toSafeImportPath(modulePath));
510499
}
511500

512501
function resolveCanonicalDistRuntimeSource(source: string): string {

src/plugins/plugin-module-loader-cache.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from "node:fs";
12
import { createRequire } from "node:module";
23
import type { createJiti } from "jiti";
34
import { toSafeImportPath } from "../shared/import-specifier.js";
@@ -47,6 +48,8 @@ export type PluginModuleLoaderStatsSnapshot = {
4748
const DEFAULT_PLUGIN_MODULE_LOADER_CACHE_ENTRIES = 128;
4849
const MAX_TRACKED_SOURCE_TRANSFORM_TARGETS = 24;
4950
const JITI_FACTORY_OVERRIDE_KEY = Symbol.for("openclaw.pluginModuleLoaderJitiFactoryOverride");
51+
const PLUGIN_SDK_IMPORT_SPECIFIER_PATTERN =
52+
/(?:\bfrom\s*["']|\bimport\s*\(\s*["']|\brequire\s*\(\s*["'])(?:openclaw|@openclaw)\/plugin-sdk(?:\/[^"']*)?["']/u;
5053
const requireForJiti = createRequire(import.meta.url);
5154
let createJitiLoaderFactory: PluginModuleLoaderFactory | undefined;
5255
const pluginModuleLoaderStats = {
@@ -213,6 +216,29 @@ function createLazySourceTransformLoader(params: {
213216
};
214217
}
215218

219+
function shouldForceSourceTransformForPluginSdkAlias(params: {
220+
target: string;
221+
aliasMap: Record<string, string>;
222+
}): boolean {
223+
if (
224+
!params.aliasMap["openclaw/plugin-sdk"] &&
225+
!params.aliasMap["@openclaw/plugin-sdk"] &&
226+
!Object.keys(params.aliasMap).some(
227+
(key) => key.startsWith("openclaw/plugin-sdk/") || key.startsWith("@openclaw/plugin-sdk/"),
228+
)
229+
) {
230+
return false;
231+
}
232+
if (!/\.[cm]?js$/iu.test(params.target)) {
233+
return false;
234+
}
235+
try {
236+
return PLUGIN_SDK_IMPORT_SPECIFIER_PATTERN.test(fs.readFileSync(params.target, "utf-8"));
237+
} catch {
238+
return false;
239+
}
240+
}
241+
216242
function createPluginModuleLoader(params: {
217243
loaderFilename: string;
218244
aliasMap: Record<string, string>;
@@ -242,8 +268,20 @@ function createPluginModuleLoader(params: {
242268
// for TS / TSX sources and for the small set of require(esm) /
243269
// async-module fallbacks `tryNativeRequireJavaScriptModule` declines to
244270
// handle.
271+
const getLoadWithAliasTransform = createLazySourceTransformLoader({
272+
...params,
273+
tryNative: false,
274+
});
245275
return ((target: string, ...rest: unknown[]) => {
246276
pluginModuleLoaderStats.calls += 1;
277+
if (shouldForceSourceTransformForPluginSdkAlias({ target, aliasMap: params.aliasMap })) {
278+
pluginModuleLoaderStats.sourceTransformForced += 1;
279+
recordSourceTransformTarget(target);
280+
return (getLoadWithAliasTransform() as (t: string, ...a: unknown[]) => unknown)(
281+
target,
282+
...rest,
283+
);
284+
}
247285
const native = tryNativeRequireJavaScriptModule(target, { allowWindows: true });
248286
if (native.ok) {
249287
pluginModuleLoaderStats.nativeHits += 1;

src/plugins/plugin-sdk-dist-alias.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string): void
1010
const relative = `./${path.relative(path.dirname(targetPath), sourcePath).split(path.sep).join("/")}`;
1111
const content = [
1212
`export * from ${JSON.stringify(relative)};`,
13-
`export { default } from ${JSON.stringify(relative)};`,
13+
`import * as moduleExports from ${JSON.stringify(relative)};`,
14+
`export default moduleExports.default ?? moduleExports;`,
1415
"",
1516
].join("\n");
1617
try {

0 commit comments

Comments
 (0)