Skip to content

Commit ca307c3

Browse files
fix: harden Discord channel resolution (openclaw#33142) (thanks @thewilloftheshadow) (openclaw#33142)
1 parent 4abf398 commit ca307c3

File tree

6 files changed

+284
-40
lines changed

6 files changed

+284
-40
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ Docs: https://docs.openclaw.ai
1212
### Fixes
1313

1414
- Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils.
15-
- Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow.
15+
- Discord/audit wildcard warnings: ignore "*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow.
16+
- Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow.
1617
- Exec heartbeat routing: scope exec-triggered heartbeat wakes to agent session keys so unrelated agents are no longer awakened by exec events, while preserving legacy unscoped behavior for non-canonical session keys. (#32724) thanks @altaywtf
1718
- macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (`wss://<peer>.ts.net`) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman.
1819
- Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add `openclaw doctor` warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin.

src/discord/monitor/message-handler.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ export function createDiscordMessageHandler(
8484
if (!ctx) {
8585
return;
8686
}
87-
await processDiscordMessage(ctx);
87+
void processDiscordMessage(ctx).catch((err) => {
88+
params.runtime.error?.(danger(`discord process failed: ${String(err)}`));
89+
});
8890
return;
8991
}
9092
const combinedBaseText = entries
@@ -128,7 +130,9 @@ export function createDiscordMessageHandler(
128130
ctxBatch.MessageSidLast = ids[ids.length - 1];
129131
}
130132
}
131-
await processDiscordMessage(ctx);
133+
void processDiscordMessage(ctx).catch((err) => {
134+
params.runtime.error?.(danger(`discord process failed: ${String(err)}`));
135+
});
132136
},
133137
onError: (err) => {
134138
params.runtime.error?.(danger(`discord debounce flush failed: ${String(err)}`));

src/discord/resolve-channels.test.ts

Lines changed: 190 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,187 @@ describe("resolveDiscordChannelAllowlist", () => {
113113
});
114114
});
115115

116+
it("resolves numeric channel id when guild is specified by name", async () => {
117+
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
118+
const url = urlToString(input);
119+
if (url.endsWith("/users/@me/guilds")) {
120+
return jsonResponse([{ id: "111", name: "My Guild" }]);
121+
}
122+
if (url.endsWith("/guilds/111/channels")) {
123+
return jsonResponse([{ id: "444555666", name: "general", guild_id: "111", type: 0 }]);
124+
}
125+
return new Response("not found", { status: 404 });
126+
});
127+
128+
const res = await resolveDiscordChannelAllowlist({
129+
token: "test",
130+
entries: ["My Guild/444555666"],
131+
fetcher,
132+
});
133+
134+
expect(res[0]?.resolved).toBe(true);
135+
expect(res[0]?.channelId).toBe("444555666");
136+
});
137+
138+
it("marks invalid numeric channelId as unresolved without aborting batch", async () => {
139+
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
140+
const url = urlToString(input);
141+
if (url.endsWith("/users/@me/guilds")) {
142+
return jsonResponse([{ id: "111", name: "Test Server" }]);
143+
}
144+
if (url.endsWith("/guilds/111/channels")) {
145+
return jsonResponse([{ id: "444555666", name: "general", guild_id: "111", type: 0 }]);
146+
}
147+
if (url.endsWith("/channels/999000111")) {
148+
return new Response("not found", { status: 404 });
149+
}
150+
if (url.endsWith("/channels/444555666")) {
151+
return jsonResponse({
152+
id: "444555666",
153+
name: "general",
154+
guild_id: "111",
155+
type: 0,
156+
});
157+
}
158+
return new Response("not found", { status: 404 });
159+
});
160+
161+
const res = await resolveDiscordChannelAllowlist({
162+
token: "test",
163+
entries: ["111/999000111", "111/444555666"],
164+
fetcher,
165+
});
166+
167+
expect(res).toHaveLength(2);
168+
expect(res[0]?.resolved).toBe(false);
169+
expect(res[0]?.channelId).toBe("999000111");
170+
expect(res[0]?.guildId).toBe("111");
171+
expect(res[1]?.resolved).toBe(true);
172+
expect(res[1]?.channelId).toBe("444555666");
173+
});
174+
175+
it("treats 403 channel lookup as unresolved without aborting batch", async () => {
176+
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
177+
const url = urlToString(input);
178+
if (url.endsWith("/users/@me/guilds")) {
179+
return jsonResponse([{ id: "111", name: "Test Server" }]);
180+
}
181+
if (url.endsWith("/guilds/111/channels")) {
182+
return jsonResponse([{ id: "444555666", name: "general", guild_id: "111", type: 0 }]);
183+
}
184+
if (url.endsWith("/channels/777888999")) {
185+
return new Response("Missing Access", { status: 403 });
186+
}
187+
if (url.endsWith("/channels/444555666")) {
188+
return jsonResponse({
189+
id: "444555666",
190+
name: "general",
191+
guild_id: "111",
192+
type: 0,
193+
});
194+
}
195+
return new Response("not found", { status: 404 });
196+
});
197+
198+
const res = await resolveDiscordChannelAllowlist({
199+
token: "test",
200+
entries: ["111/777888999", "111/444555666"],
201+
fetcher,
202+
});
203+
204+
expect(res).toHaveLength(2);
205+
expect(res[0]?.resolved).toBe(false);
206+
expect(res[0]?.channelId).toBe("777888999");
207+
expect(res[0]?.guildId).toBe("111");
208+
expect(res[1]?.resolved).toBe(true);
209+
expect(res[1]?.channelId).toBe("444555666");
210+
});
211+
212+
it("falls back to name matching when numeric channel name is not a valid ID", async () => {
213+
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
214+
const url = urlToString(input);
215+
if (url.endsWith("/users/@me/guilds")) {
216+
return jsonResponse([{ id: "111", name: "Test Server" }]);
217+
}
218+
if (url.endsWith("/channels/2024")) {
219+
return new Response("not found", { status: 404 });
220+
}
221+
if (url.endsWith("/guilds/111/channels")) {
222+
return jsonResponse([
223+
{ id: "c1", name: "2024", guild_id: "111", type: 0 },
224+
{ id: "c2", name: "general", guild_id: "111", type: 0 },
225+
]);
226+
}
227+
return new Response("not found", { status: 404 });
228+
});
229+
230+
const res = await resolveDiscordChannelAllowlist({
231+
token: "test",
232+
entries: ["111/2024"],
233+
fetcher,
234+
});
235+
236+
expect(res[0]?.resolved).toBe(true);
237+
expect(res[0]?.guildId).toBe("111");
238+
expect(res[0]?.channelId).toBe("c1");
239+
expect(res[0]?.channelName).toBe("2024");
240+
});
241+
242+
it("does not fall back to name matching when channel lookup returns 403", async () => {
243+
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
244+
const url = urlToString(input);
245+
if (url.endsWith("/users/@me/guilds")) {
246+
return jsonResponse([{ id: "111", name: "Test Server" }]);
247+
}
248+
if (url.endsWith("/channels/2024")) {
249+
return new Response("Missing Access", { status: 403 });
250+
}
251+
if (url.endsWith("/guilds/111/channels")) {
252+
return jsonResponse([
253+
{ id: "c1", name: "2024", guild_id: "111", type: 0 },
254+
{ id: "c2", name: "general", guild_id: "111", type: 0 },
255+
]);
256+
}
257+
return new Response("not found", { status: 404 });
258+
});
259+
260+
const res = await resolveDiscordChannelAllowlist({
261+
token: "test",
262+
entries: ["111/2024"],
263+
fetcher,
264+
});
265+
266+
expect(res[0]?.resolved).toBe(false);
267+
expect(res[0]?.channelId).toBe("2024");
268+
expect(res[0]?.guildId).toBe("111");
269+
});
270+
271+
it("does not fall back to name matching when channel payload is malformed", async () => {
272+
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
273+
const url = urlToString(input);
274+
if (url.endsWith("/users/@me/guilds")) {
275+
return jsonResponse([{ id: "111", name: "Test Server" }]);
276+
}
277+
if (url.endsWith("/channels/2024")) {
278+
return jsonResponse({ id: "2024", name: "unknown", type: 0 });
279+
}
280+
if (url.endsWith("/guilds/111/channels")) {
281+
return jsonResponse([{ id: "c1", name: "2024", guild_id: "111", type: 0 }]);
282+
}
283+
return new Response("not found", { status: 404 });
284+
});
285+
286+
const res = await resolveDiscordChannelAllowlist({
287+
token: "test",
288+
entries: ["111/2024"],
289+
fetcher,
290+
});
291+
292+
expect(res[0]?.resolved).toBe(false);
293+
expect(res[0]?.channelId).toBe("2024");
294+
expect(res[0]?.guildId).toBe("111");
295+
});
296+
116297
it("resolves guild: prefixed id as guild (not channel)", async () => {
117298
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
118299
const url = urlToString(input);
@@ -153,14 +334,15 @@ describe("resolveDiscordChannelAllowlist", () => {
153334
return new Response("not found", { status: 404 });
154335
});
155336

156-
// Without the guild: prefix, a bare numeric string hits /channels/999 → 404 → throws
157-
await expect(
158-
resolveDiscordChannelAllowlist({
159-
token: "test",
160-
entries: ["999"],
161-
fetcher,
162-
}),
163-
).rejects.toThrow(/404/);
337+
// Without the guild: prefix, a bare numeric string hits /channels/999 → 404 → unresolved
338+
const res = await resolveDiscordChannelAllowlist({
339+
token: "test",
340+
entries: ["999"],
341+
fetcher,
342+
});
343+
expect(res[0]?.resolved).toBe(false);
344+
expect(res[0]?.channelId).toBe("999");
345+
expect(res[0]?.guildId).toBeUndefined();
164346

165347
// With the guild: prefix, it correctly resolves as a guild (never hits /channels/)
166348
const res2 = await resolveDiscordChannelAllowlist({

0 commit comments

Comments
 (0)