fix(discord): preserve partially created threads

This commit is contained in:
Peter Steinberger
2026-05-02 05:22:18 +01:00
parent 3ce8746b27
commit 2808840fb5
6 changed files with 106 additions and 10 deletions

View File

@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/logging: keep deferred channel startup logs on the subsystem logger, so Slack, Discord, Telegram, and voice-call startup messages keep timestamped prefixes. Thanks @vincentkoc.
- Discord/threads: return the created thread as partial success when the follow-up initial message fails, so agents do not retry thread creation and create empty duplicate threads. Fixes #48450. Thanks @dahifi.
- Discord/components: consume every button or select in a non-reusable component message after the first authorized click, so single-use panels cannot fire sibling callbacks. Fixes #54227. Thanks @fujiwarakasei.
- Discord/reactions: skip reaction listener registration when DMs and group DMs are disabled and every configured guild has `reactionNotifications: "off"`, avoiding needless reaction-event queue work. Fixes #47516. Thanks @x4v13r1120.
- CLI sessions: preserve explicit manual-attach reuse bindings so trusted CLI sessions are not invalidated on the first turn when auth, prompt, or MCP fingerprints drift. Fixes #75849. Thanks @alfredjbclaw.

View File

@@ -7,6 +7,7 @@ import {
readStringParam,
resolvePollMaxSelections,
} from "../runtime-api.js";
import { DiscordThreadInitialMessageError } from "../send.js";
import type { DiscordSendComponents, DiscordSendEmbeds } from "../send.shared.js";
import { discordMessagingActionRuntime } from "./runtime.messaging.runtime.js";
import type { DiscordMessagingActionContext } from "./runtime.messaging.shared.js";
@@ -171,12 +172,25 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction
content,
appliedTags: appliedTags ?? undefined,
};
const thread = await discordMessagingActionRuntime.createThreadDiscord(
channelId,
payload,
ctx.withOpts(),
);
return jsonResult({ ok: true, thread });
try {
const thread = await discordMessagingActionRuntime.createThreadDiscord(
channelId,
payload,
ctx.withOpts(),
);
return jsonResult({ ok: true, thread });
} catch (error) {
if (error instanceof DiscordThreadInitialMessageError) {
return jsonResult({
ok: true,
partial: true,
thread: error.thread,
warning: "Discord thread was created, but sending the initial message failed.",
initialMessageError: error.initialMessageError,
});
}
throw error;
}
}
case "threadList": {
if (!ctx.isActionEnabled("threads")) {

View File

@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-types";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearPresences, setPresence } from "../monitor/presence-cache.js";
import { DiscordThreadInitialMessageError } from "../send.js";
import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js";
import { discordGuildActionRuntime, handleDiscordGuildAction } from "./runtime.guild.js";
import { handleDiscordAction } from "./runtime.js";
@@ -571,6 +572,34 @@ describe("handleDiscordMessagingAction", () => {
{ cfg: DISCORD_TEST_CFG },
);
});
it("returns partial success when Discord creates the thread but initial message send fails", async () => {
const thread = { id: "T1", name: "thread", type: 11 };
createThreadDiscord.mockRejectedValueOnce(
new DiscordThreadInitialMessageError(
thread as ConstructorParameters<typeof DiscordThreadInitialMessageError>[0],
new Error("missing access"),
),
);
const result = await handleMessagingAction(
"threadCreate",
{
channelId: "C1",
name: "thread",
content: "Initial post",
},
enableAllActions,
);
expect(result.details).toEqual({
ok: true,
partial: true,
thread,
warning: "Discord thread was created, but sending the initial message failed.",
initialMessageError: "missing access",
});
});
});
describe("handleDiscordGuildAction", () => {

View File

@@ -12,6 +12,7 @@ vi.mock("openclaw/plugin-sdk/web-media", async () => {
let addRoleDiscord: typeof import("./send.js").addRoleDiscord;
let banMemberDiscord: typeof import("./send.js").banMemberDiscord;
let createThreadDiscord: typeof import("./send.js").createThreadDiscord;
let DiscordThreadInitialMessageError: typeof import("./send.js").DiscordThreadInitialMessageError;
let listGuildEmojisDiscord: typeof import("./send.js").listGuildEmojisDiscord;
let listThreadsDiscord: typeof import("./send.js").listThreadsDiscord;
let reactMessageDiscord: typeof import("./send.js").reactMessageDiscord;
@@ -60,6 +61,7 @@ beforeAll(async () => {
addRoleDiscord,
banMemberDiscord,
createThreadDiscord,
DiscordThreadInitialMessageError,
listGuildEmojisDiscord,
listThreadsDiscord,
reactMessageDiscord,
@@ -235,6 +237,32 @@ describe("sendMessageDiscord", () => {
);
});
it("keeps created non-forum thread details when initial message send fails", async () => {
const { rest, getMock, postMock } = makeDiscordRest();
getMock.mockResolvedValue({ type: ChannelType.GuildText });
postMock
.mockResolvedValueOnce({ id: "t1", name: "thread", type: ChannelType.PublicThread })
.mockRejectedValueOnce(new Error("missing access"));
let thrown: unknown;
try {
await createThreadDiscord(
"chan1",
{ name: "thread", content: "Hello thread!" },
discordClientOpts(rest),
);
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError);
expect(thrown).toMatchObject({
name: "DiscordThreadInitialMessageError",
initialMessageError: "missing access",
thread: { id: "t1", name: "thread", type: ChannelType.PublicThread },
});
});
it("sends initial message for message-attached threads with content", async () => {
const { rest, getMock, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "t1" });

View File

@@ -1,4 +1,4 @@
import type { APIMessage } from "discord-api-types/v10";
import type { APIChannel, APIMessage } from "discord-api-types/v10";
import { ChannelType } from "discord-api-types/v10";
import {
createChannelMessage,
@@ -25,6 +25,25 @@ import type {
DiscordThreadList,
} from "./send.types.js";
function formatDiscordThreadInitialMessageError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export class DiscordThreadInitialMessageError extends Error {
readonly initialMessageError: string;
readonly thread: APIChannel;
constructor(thread: APIChannel, error: unknown) {
const initialMessageError = formatDiscordThreadInitialMessageError(error);
super(
`Discord thread was created, but sending the initial message failed: ${initialMessageError}`,
);
this.name = "DiscordThreadInitialMessageError";
this.initialMessageError = initialMessageError;
this.thread = thread;
}
}
export async function readMessagesDiscord(
channelId: string,
query: DiscordMessageQuery = {},
@@ -154,9 +173,13 @@ export async function createThreadDiscord(
// For non-forum channels, send the initial message separately after thread creation.
// Forum channels handle this via the `message` field in the request body.
if (!isForumLike && payload.content?.trim() && "id" in thread) {
await createChannelMessage(rest, thread.id, {
body: { content: payload.content },
});
try {
await createChannelMessage(rest, thread.id, {
body: { content: payload.content },
});
} catch (error) {
throw new DiscordThreadInitialMessageError(thread, error);
}
}
return thread;

View File

@@ -29,6 +29,7 @@ export {
export {
createThreadDiscord,
deleteMessageDiscord,
DiscordThreadInitialMessageError,
editMessageDiscord,
fetchMessageDiscord,
listPinsDiscord,