diff --git a/CHANGELOG.md b/CHANGELOG.md index 31ad71576a0..5416dc1bd4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/discord/src/actions/runtime.messaging.send.ts b/extensions/discord/src/actions/runtime.messaging.send.ts index 7f479f6fb57..9d34723bcbe 100644 --- a/extensions/discord/src/actions/runtime.messaging.send.ts +++ b/extensions/discord/src/actions/runtime.messaging.send.ts @@ -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")) { diff --git a/extensions/discord/src/actions/runtime.test.ts b/extensions/discord/src/actions/runtime.test.ts index 49edd7fe9cf..97cc5e3461e 100644 --- a/extensions/discord/src/actions/runtime.test.ts +++ b/extensions/discord/src/actions/runtime.test.ts @@ -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[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", () => { diff --git a/extensions/discord/src/send.creates-thread.test.ts b/extensions/discord/src/send.creates-thread.test.ts index 19faac5be0f..c6784d50691 100644 --- a/extensions/discord/src/send.creates-thread.test.ts +++ b/extensions/discord/src/send.creates-thread.test.ts @@ -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" }); diff --git a/extensions/discord/src/send.messages.ts b/extensions/discord/src/send.messages.ts index bb4a995163f..234b8936593 100644 --- a/extensions/discord/src/send.messages.ts +++ b/extensions/discord/src/send.messages.ts @@ -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; diff --git a/extensions/discord/src/send.ts b/extensions/discord/src/send.ts index 0483f9fd655..e9844b0880d 100644 --- a/extensions/discord/src/send.ts +++ b/extensions/discord/src/send.ts @@ -29,6 +29,7 @@ export { export { createThreadDiscord, deleteMessageDiscord, + DiscordThreadInitialMessageError, editMessageDiscord, fetchMessageDiscord, listPinsDiscord,