diff --git a/CHANGELOG.md b/CHANGELOG.md index 834adc17273..e3b8fd6e3f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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://.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. diff --git a/src/discord/monitor/message-handler.ts b/src/discord/monitor/message-handler.ts index 0aca2c76a75..f4fea9d43be 100644 --- a/src/discord/monitor/message-handler.ts +++ b/src/discord/monitor/message-handler.ts @@ -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)}`)); diff --git a/src/discord/resolve-channels.test.ts b/src/discord/resolve-channels.test.ts index 39b46a53f33..191156b7d97 100644 --- a/src/discord/resolve-channels.test.ts +++ b/src/discord/resolve-channels.test.ts @@ -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({ diff --git a/src/discord/resolve-channels.ts b/src/discord/resolve-channels.ts index f474321a274..ba7fbcdf8d5 100644 --- a/src/discord/resolve-channels.ts +++ b/src/discord/resolve-channels.ts @@ -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 { - const raw = await fetchDiscord(`/channels/${channelId}`, token, fetcher); +): Promise { + let raw: DiscordChannelPayload; + try { + raw = await fetchDiscord(`/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({ diff --git a/src/discord/send.shared.ts b/src/discord/send.shared.ts index 8847baa18b1..6f2843d2a77 100644 --- a/src/discord/send.shared.ts +++ b/src/discord/send.shared.ts @@ -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.`, }; diff --git a/src/infra/outbound/delivery-queue.ts b/src/infra/outbound/delivery-queue.ts index 1e954ea8e39..e84527b461e 100644 --- a/src/infra/outbound/delivery-queue.ts +++ b/src/infra/outbound/delivery-queue.ts @@ -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 {