fix: harden Discord channel resolution (#33142) (thanks @thewilloftheshadow) (#33142)

This commit is contained in:
Shadow
2026-03-03 09:31:26 -06:00
committed by GitHub
parent 4abf398a17
commit ca307c3fdf
6 changed files with 284 additions and 40 deletions

View File

@@ -12,7 +12,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- 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.
- 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.
- 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.
- 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.
- 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
- 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.
- 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.

View File

@@ -84,7 +84,9 @@ export function createDiscordMessageHandler(
if (!ctx) {
return;
}
await processDiscordMessage(ctx);
void processDiscordMessage(ctx).catch((err) => {
params.runtime.error?.(danger(`discord process failed: ${String(err)}`));
});
return;
}
const combinedBaseText = entries
@@ -128,7 +130,9 @@ export function createDiscordMessageHandler(
ctxBatch.MessageSidLast = ids[ids.length - 1];
}
}
await processDiscordMessage(ctx);
void processDiscordMessage(ctx).catch((err) => {
params.runtime.error?.(danger(`discord process failed: ${String(err)}`));
});
},
onError: (err) => {
params.runtime.error?.(danger(`discord debounce flush failed: ${String(err)}`));

View File

@@ -113,6 +113,187 @@ describe("resolveDiscordChannelAllowlist", () => {
});
});
it("resolves numeric channel id when guild is specified by name", async () => {
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "111", name: "My Guild" }]);
}
if (url.endsWith("/guilds/111/channels")) {
return jsonResponse([{ id: "444555666", name: "general", guild_id: "111", type: 0 }]);
}
return new Response("not found", { status: 404 });
});
const res = await resolveDiscordChannelAllowlist({
token: "test",
entries: ["My Guild/444555666"],
fetcher,
});
expect(res[0]?.resolved).toBe(true);
expect(res[0]?.channelId).toBe("444555666");
});
it("marks invalid numeric channelId as unresolved without aborting batch", async () => {
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "111", name: "Test Server" }]);
}
if (url.endsWith("/guilds/111/channels")) {
return jsonResponse([{ id: "444555666", name: "general", guild_id: "111", type: 0 }]);
}
if (url.endsWith("/channels/999000111")) {
return new Response("not found", { status: 404 });
}
if (url.endsWith("/channels/444555666")) {
return jsonResponse({
id: "444555666",
name: "general",
guild_id: "111",
type: 0,
});
}
return new Response("not found", { status: 404 });
});
const res = await resolveDiscordChannelAllowlist({
token: "test",
entries: ["111/999000111", "111/444555666"],
fetcher,
});
expect(res).toHaveLength(2);
expect(res[0]?.resolved).toBe(false);
expect(res[0]?.channelId).toBe("999000111");
expect(res[0]?.guildId).toBe("111");
expect(res[1]?.resolved).toBe(true);
expect(res[1]?.channelId).toBe("444555666");
});
it("treats 403 channel lookup as unresolved without aborting batch", async () => {
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "111", name: "Test Server" }]);
}
if (url.endsWith("/guilds/111/channels")) {
return jsonResponse([{ id: "444555666", name: "general", guild_id: "111", type: 0 }]);
}
if (url.endsWith("/channels/777888999")) {
return new Response("Missing Access", { status: 403 });
}
if (url.endsWith("/channels/444555666")) {
return jsonResponse({
id: "444555666",
name: "general",
guild_id: "111",
type: 0,
});
}
return new Response("not found", { status: 404 });
});
const res = await resolveDiscordChannelAllowlist({
token: "test",
entries: ["111/777888999", "111/444555666"],
fetcher,
});
expect(res).toHaveLength(2);
expect(res[0]?.resolved).toBe(false);
expect(res[0]?.channelId).toBe("777888999");
expect(res[0]?.guildId).toBe("111");
expect(res[1]?.resolved).toBe(true);
expect(res[1]?.channelId).toBe("444555666");
});
it("falls back to name matching when numeric channel name is not a valid ID", async () => {
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "111", name: "Test Server" }]);
}
if (url.endsWith("/channels/2024")) {
return new Response("not found", { status: 404 });
}
if (url.endsWith("/guilds/111/channels")) {
return jsonResponse([
{ id: "c1", name: "2024", guild_id: "111", type: 0 },
{ id: "c2", name: "general", guild_id: "111", type: 0 },
]);
}
return new Response("not found", { status: 404 });
});
const res = await resolveDiscordChannelAllowlist({
token: "test",
entries: ["111/2024"],
fetcher,
});
expect(res[0]?.resolved).toBe(true);
expect(res[0]?.guildId).toBe("111");
expect(res[0]?.channelId).toBe("c1");
expect(res[0]?.channelName).toBe("2024");
});
it("does not fall back to name matching when channel lookup returns 403", async () => {
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "111", name: "Test Server" }]);
}
if (url.endsWith("/channels/2024")) {
return new Response("Missing Access", { status: 403 });
}
if (url.endsWith("/guilds/111/channels")) {
return jsonResponse([
{ id: "c1", name: "2024", guild_id: "111", type: 0 },
{ id: "c2", name: "general", guild_id: "111", type: 0 },
]);
}
return new Response("not found", { status: 404 });
});
const res = await resolveDiscordChannelAllowlist({
token: "test",
entries: ["111/2024"],
fetcher,
});
expect(res[0]?.resolved).toBe(false);
expect(res[0]?.channelId).toBe("2024");
expect(res[0]?.guildId).toBe("111");
});
it("does not fall back to name matching when channel payload is malformed", async () => {
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "111", name: "Test Server" }]);
}
if (url.endsWith("/channels/2024")) {
return jsonResponse({ id: "2024", name: "unknown", type: 0 });
}
if (url.endsWith("/guilds/111/channels")) {
return jsonResponse([{ id: "c1", name: "2024", guild_id: "111", type: 0 }]);
}
return new Response("not found", { status: 404 });
});
const res = await resolveDiscordChannelAllowlist({
token: "test",
entries: ["111/2024"],
fetcher,
});
expect(res[0]?.resolved).toBe(false);
expect(res[0]?.channelId).toBe("2024");
expect(res[0]?.guildId).toBe("111");
});
it("resolves guild: prefixed id as guild (not channel)", async () => {
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
@@ -153,14 +334,15 @@ describe("resolveDiscordChannelAllowlist", () => {
return new Response("not found", { status: 404 });
});
// Without the guild: prefix, a bare numeric string hits /channels/999 → 404 → throws
await expect(
resolveDiscordChannelAllowlist({
token: "test",
entries: ["999"],
fetcher,
}),
).rejects.toThrow(/404/);
// Without the guild: prefix, a bare numeric string hits /channels/999 → 404 → unresolved
const res = await resolveDiscordChannelAllowlist({
token: "test",
entries: ["999"],
fetcher,
});
expect(res[0]?.resolved).toBe(false);
expect(res[0]?.channelId).toBe("999");
expect(res[0]?.guildId).toBeUndefined();
// With the guild: prefix, it correctly resolves as a guild (never hits /channels/)
const res2 = await resolveDiscordChannelAllowlist({

View File

@@ -1,4 +1,4 @@
import { fetchDiscord } from "./api.js";
import { DiscordApiError, fetchDiscord } from "./api.js";
import { listGuilds, type DiscordGuildSummary } from "./guilds.js";
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
import { normalizeDiscordToken } from "./token.js";
@@ -95,20 +95,40 @@ async function listGuildChannels(
.filter((channel) => Boolean(channel.id) && Boolean(channel.name));
}
type FetchChannelResult =
| { status: "found"; channel: DiscordChannelSummary }
| { status: "not-found" }
| { status: "forbidden" }
| { status: "invalid" };
async function fetchChannel(
token: string,
fetcher: typeof fetch,
channelId: string,
): Promise<DiscordChannelSummary | null> {
const raw = await fetchDiscord<DiscordChannelPayload>(`/channels/${channelId}`, token, fetcher);
): Promise<FetchChannelResult> {
let raw: DiscordChannelPayload;
try {
raw = await fetchDiscord<DiscordChannelPayload>(`/channels/${channelId}`, token, fetcher);
} catch (err) {
if (err instanceof DiscordApiError && err.status === 403) {
return { status: "forbidden" };
}
if (err instanceof DiscordApiError && err.status === 404) {
return { status: "not-found" };
}
throw err;
}
if (!raw || typeof raw.guild_id !== "string" || typeof raw.id !== "string") {
return null;
return { status: "invalid" };
}
return {
id: raw.id,
name: typeof raw.name === "string" ? raw.name : "",
guildId: raw.guild_id,
type: raw.type,
status: "found",
channel: {
id: raw.id,
name: typeof raw.name === "string" ? raw.name : "",
guildId: raw.guild_id,
type: raw.type,
},
};
}
@@ -167,12 +187,11 @@ export async function resolveDiscordChannelAllowlist(params: {
for (const input of params.entries) {
const parsed = parseDiscordChannelInput(input);
if (parsed.guildOnly) {
const guildById = parsed.guildId
? guilds.find((entry) => entry.id === parsed.guildId)
: undefined;
const guild =
parsed.guildId && guilds.find((entry) => entry.id === parsed.guildId)
? guilds.find((entry) => entry.id === parsed.guildId)
: parsed.guild
? resolveGuildByName(guilds, parsed.guild)
: undefined;
guildById ?? (parsed.guild ? resolveGuildByName(guilds, parsed.guild) : undefined);
if (guild) {
results.push({
input,
@@ -192,8 +211,10 @@ export async function resolveDiscordChannelAllowlist(params: {
}
if (parsed.channelId) {
const channel = await fetchChannel(token, fetcher, parsed.channelId);
if (channel?.guildId) {
const channelId = parsed.channelId;
const result = await fetchChannel(token, fetcher, channelId);
if (result.status === "found") {
const channel = result.channel;
if (parsed.guildId && parsed.guildId !== channel.guildId) {
const expectedGuild = guilds.find((entry) => entry.id === parsed.guildId);
const actualGuild = guilds.find((entry) => entry.id === channel.guildId);
@@ -202,7 +223,7 @@ export async function resolveDiscordChannelAllowlist(params: {
resolved: false,
guildId: parsed.guildId,
guildName: expectedGuild?.name,
channelId: parsed.channelId,
channelId,
channelName: channel.name,
note: actualGuild?.name
? `channel belongs to guild ${actualGuild.name}`
@@ -220,23 +241,47 @@ export async function resolveDiscordChannelAllowlist(params: {
channelName: channel.name,
archived: channel.archived,
});
} else {
results.push({
input,
resolved: false,
channelId: parsed.channelId,
});
continue;
}
if (result.status === "not-found" && parsed.guildId) {
const guild = guilds.find((entry) => entry.id === parsed.guildId);
if (guild) {
const channels = await getChannels(guild.id);
const matches = channels.filter(
(channel) => normalizeDiscordSlug(channel.name) === normalizeDiscordSlug(channelId),
);
const match = preferActiveMatch(matches);
if (match) {
results.push({
input,
resolved: true,
guildId: guild.id,
guildName: guild.name,
channelId: match.id,
channelName: match.name,
archived: match.archived,
});
continue;
}
}
}
results.push({
input,
resolved: false,
guildId: parsed.guildId,
channelId,
});
continue;
}
if (parsed.guildId || parsed.guild) {
const guildById = parsed.guildId
? guilds.find((entry) => entry.id === parsed.guildId)
: undefined;
const guild =
parsed.guildId && guilds.find((entry) => entry.id === parsed.guildId)
? guilds.find((entry) => entry.id === parsed.guildId)
: parsed.guild
? resolveGuildByName(guilds, parsed.guild)
: undefined;
guildById ?? (parsed.guild ? resolveGuildByName(guilds, parsed.guild) : undefined);
const channelQuery = parsed.channel?.trim();
if (!guild || !channelQuery) {
results.push({
@@ -249,9 +294,18 @@ export async function resolveDiscordChannelAllowlist(params: {
continue;
}
const channels = await getChannels(guild.id);
const matches = channels.filter(
(channel) => normalizeDiscordSlug(channel.name) === normalizeDiscordSlug(channelQuery),
const normalizedChannelQuery = normalizeDiscordSlug(channelQuery);
const isNumericId = /^\d+$/.test(channelQuery);
let matches = channels.filter((channel) =>
isNumericId
? channel.id === channelQuery
: normalizeDiscordSlug(channel.name) === normalizedChannelQuery,
);
if (isNumericId && matches.length === 0) {
matches = channels.filter(
(channel) => normalizeDiscordSlug(channel.name) === normalizedChannelQuery,
);
}
const match = preferActiveMatch(matches);
if (match) {
results.push({

View File

@@ -59,6 +59,7 @@ function normalizeReactionEmoji(raw: string) {
function parseRecipient(raw: string): DiscordRecipient {
const target = parseDiscordTarget(raw, {
defaultKind: "channel",
ambiguousMessage: `Ambiguous Discord recipient "${raw.trim()}". Use "user:${raw.trim()}" for DMs or "channel:${raw.trim()}" for channel messages.`,
});
if (!target) {
@@ -86,6 +87,7 @@ export async function parseAndResolveRecipient(
// First try to resolve using directory lookup (handles usernames)
const trimmed = raw.trim();
const parseOptions = {
defaultKind: "channel" as const,
ambiguousMessage: `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
};

View File

@@ -386,6 +386,7 @@ const PERMANENT_ERROR_PATTERNS: readonly RegExp[] = [
/chat_id is empty/i,
/recipient is not a valid/i,
/outbound not configured for channel/i,
/ambiguous discord recipient/i,
];
export function isPermanentDeliveryError(error: string): boolean {