Compare commits
2 Commits
v2026.2.15
...
fix/discor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65a74ac73 | ||
|
|
bf09ecbadd |
@@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Sessions/Agents: pass `agentId` through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman.
|
||||
- Signal/Install: auto-install `signal-cli` via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary `Exec format error` failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.
|
||||
- Discord: avoid misrouting numeric guild allowlist entries to `/channels/<guildId>` by prefixing guild-only inputs with `guild:` during resolution. (#12326) Thanks @headswim.
|
||||
- Discord: reuse an auto-created thread when multiple agents race to create it, so replies stay in the shared thread. (#7517) Thanks @rohanpatriot.
|
||||
- Config: preserve `${VAR}` env references when writing config files so `openclaw config set/apply/patch` does not persist secrets to disk. Thanks @thewilloftheshadow.
|
||||
- Process/Exec: avoid shell execution for `.exe` commands on Windows so env overrides work reliably in `runCommandWithTimeout`. Thanks @thewilloftheshadow.
|
||||
- Web tools/web_fetch: prefer `text/markdown` responses for Cloudflare Markdown for Agents, add `cf-markdown` extraction for markdown bodies, and redact fetched URLs in `x-markdown-tokens` debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42.
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Client } from "@buape/carbon";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
|
||||
import {
|
||||
maybeCreateDiscordAutoThread,
|
||||
resolveDiscordAutoThreadContext,
|
||||
resolveDiscordAutoThreadReplyPlan,
|
||||
resolveDiscordReplyDeliveryPlan,
|
||||
@@ -112,6 +113,74 @@ describe("resolveDiscordReplyDeliveryPlan", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeCreateDiscordAutoThread", () => {
|
||||
it("returns existing thread ID when creation fails due to race condition", async () => {
|
||||
let getCalls = 0;
|
||||
const client = {
|
||||
rest: {
|
||||
post: async () => {
|
||||
throw new Error("A thread has already been created on this message");
|
||||
},
|
||||
get: async () => {
|
||||
getCalls += 1;
|
||||
return { thread: { id: "existing-thread" } };
|
||||
},
|
||||
},
|
||||
} as unknown as Client;
|
||||
|
||||
const result = await maybeCreateDiscordAutoThread({
|
||||
client,
|
||||
message: {
|
||||
id: "m1",
|
||||
channelId: "parent",
|
||||
} as unknown as import("./listeners.js").DiscordMessageEvent["message"],
|
||||
isGuildMessage: true,
|
||||
channelConfig: {
|
||||
autoThread: true,
|
||||
} as unknown as import("./allow-list.js").DiscordChannelConfigResolved,
|
||||
threadChannel: null,
|
||||
baseText: "hello",
|
||||
combinedBody: "hello",
|
||||
});
|
||||
|
||||
expect(result).toBe("existing-thread");
|
||||
expect(getCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("returns undefined when creation fails with non-race error", async () => {
|
||||
let getCalls = 0;
|
||||
const client = {
|
||||
rest: {
|
||||
post: async () => {
|
||||
throw new Error("Some other error");
|
||||
},
|
||||
get: async () => {
|
||||
getCalls += 1;
|
||||
return { thread: null };
|
||||
},
|
||||
},
|
||||
} as unknown as Client;
|
||||
|
||||
const result = await maybeCreateDiscordAutoThread({
|
||||
client,
|
||||
message: {
|
||||
id: "m1",
|
||||
channelId: "parent",
|
||||
} as unknown as import("./listeners.js").DiscordMessageEvent["message"],
|
||||
isGuildMessage: true,
|
||||
channelConfig: {
|
||||
autoThread: true,
|
||||
} as unknown as import("./allow-list.js").DiscordChannelConfigResolved,
|
||||
threadChannel: null,
|
||||
baseText: "hello",
|
||||
combinedBody: "hello",
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(getCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiscordAutoThreadReplyPlan", () => {
|
||||
it("switches delivery + session context to the created thread", async () => {
|
||||
const client = {
|
||||
|
||||
@@ -226,6 +226,57 @@ export function sanitizeDiscordThreadName(rawName: string, fallbackId: string):
|
||||
return truncateUtf16Safe(base, 100) || `Thread ${fallbackId}`;
|
||||
}
|
||||
|
||||
const DISCORD_THREAD_ALREADY_EXISTS_HINTS = [
|
||||
"thread has already been created on this message",
|
||||
"thread already exists",
|
||||
];
|
||||
|
||||
function extractDiscordErrorMessage(err: unknown): string {
|
||||
if (!err) {
|
||||
return "";
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
if (err instanceof Error && typeof err.message === "string") {
|
||||
return err.message;
|
||||
}
|
||||
if (typeof err === "object" && err !== null && "message" in err) {
|
||||
const message = (err as { message?: unknown }).message;
|
||||
if (typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
if (typeof err === "number" || typeof err === "boolean") {
|
||||
return err.toString();
|
||||
}
|
||||
if (typeof err === "bigint") {
|
||||
return err.toString();
|
||||
}
|
||||
if (typeof err === "symbol") {
|
||||
return err.description ?? "symbol";
|
||||
}
|
||||
if (typeof err === "function") {
|
||||
return err.name ? `function ${err.name}` : "function";
|
||||
}
|
||||
if (typeof err === "object") {
|
||||
try {
|
||||
return JSON.stringify(err) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function isDiscordThreadAlreadyExistsError(err: unknown): boolean {
|
||||
const message = extractDiscordErrorMessage(err).trim().toLowerCase();
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
return DISCORD_THREAD_ALREADY_EXISTS_HINTS.some((hint) => message.includes(hint));
|
||||
}
|
||||
|
||||
type DiscordReplyDeliveryPlan = {
|
||||
deliverTarget: string;
|
||||
replyTarget: string;
|
||||
@@ -358,8 +409,29 @@ export async function maybeCreateDiscordAutoThread(params: {
|
||||
return createdId || undefined;
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`discord: autoThread failed for ${params.message.channelId}/${params.message.id}: ${String(err)}`,
|
||||
`discord: autoThread creation failed for ${params.message.channelId}/${params.message.id}: ${String(err)}`,
|
||||
);
|
||||
if (!isDiscordThreadAlreadyExistsError(err)) {
|
||||
return undefined;
|
||||
}
|
||||
// Race condition: another agent may have already created a thread on this
|
||||
// message. Re-fetch the message to check for an existing thread.
|
||||
try {
|
||||
const msg = (await params.client.rest.get(
|
||||
Routes.channelMessage(params.message.channelId, params.message.id),
|
||||
)) as { thread?: { id?: string } };
|
||||
const existingThreadId = msg?.thread?.id ? String(msg.thread.id) : "";
|
||||
if (existingThreadId) {
|
||||
logVerbose(
|
||||
`discord: autoThread reusing existing thread ${existingThreadId} on ${params.message.channelId}/${params.message.id}`,
|
||||
);
|
||||
return existingThreadId;
|
||||
}
|
||||
} catch (refetchErr) {
|
||||
logVerbose(
|
||||
`discord: autoThread refetch failed for ${params.message.channelId}/${params.message.id}: ${String(refetchErr)}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user