mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix(discord): preserve partially created threads
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
export {
|
||||
createThreadDiscord,
|
||||
deleteMessageDiscord,
|
||||
DiscordThreadInitialMessageError,
|
||||
editMessageDiscord,
|
||||
fetchMessageDiscord,
|
||||
listPinsDiscord,
|
||||
|
||||
Reference in New Issue
Block a user