mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)}`));
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.`,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user