diff --git a/extensions/discord/src/actions/runtime.messaging.send.ts b/extensions/discord/src/actions/runtime.messaging.send.ts index ef5ba51e77b1..dea82c14fb45 100644 --- a/extensions/discord/src/actions/runtime.messaging.send.ts +++ b/extensions/discord/src/actions/runtime.messaging.send.ts @@ -351,12 +351,14 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction return jsonResult({ ok: true, thread }); } catch (error) { if (error instanceof DiscordThreadInitialMessageError) { + const initialMessageDelivery = error.initialMessageDelivery; return jsonResult({ ok: true, partial: true, thread: error.thread, - warning: "Discord thread was created, but sending the initial message failed.", + warning: `${error.initialMessageWarning}.`, initialMessageError: error.initialMessageError, + ...(initialMessageDelivery ? { initialMessageDelivery } : {}), }); } throw error; diff --git a/extensions/discord/src/actions/runtime.test.ts b/extensions/discord/src/actions/runtime.test.ts index 06e3e1167fcc..24edd208826e 100644 --- a/extensions/discord/src/actions/runtime.test.ts +++ b/extensions/discord/src/actions/runtime.test.ts @@ -2527,6 +2527,95 @@ describe("handleDiscordMessagingAction", () => { initialMessageError: "missing access", }); }); + + it("returns delivery progress when Discord only delivers part of the initial content", async () => { + const thread = { id: "T1", name: "thread", type: 11 }; + createThreadDiscord.mockRejectedValueOnce( + new DiscordThreadInitialMessageError( + thread as ConstructorParameters[0], + new Error("missing access"), + { + starterMessageDelivered: true, + deliveredChunkCount: 1, + deliveredMessageIds: ["starter1"], + failedChunkDelivery: "unknown", + failedChunkIndex: 1, + totalChunkCount: 2, + }, + ), + ); + + 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 delivery of the remaining initial content could not be confirmed.", + initialMessageError: "missing access", + initialMessageDelivery: { + starterMessageDelivered: true, + deliveredChunkCount: 1, + deliveredMessageIds: ["starter1"], + failedChunkDelivery: "unknown", + failedChunkIndex: 1, + totalChunkCount: 2, + }, + }); + }); + + it("reports unconfirmed delivery when the first initial content chunk is ambiguous", async () => { + const thread = { id: "T1", name: "thread", type: 11 }; + createThreadDiscord.mockRejectedValueOnce( + new DiscordThreadInitialMessageError( + thread as ConstructorParameters[0], + new Error("response lost"), + { + starterMessageDelivered: false, + deliveredChunkCount: 0, + deliveredMessageIds: [], + failedChunkDelivery: "unknown", + failedChunkIndex: 0, + totalChunkCount: 1, + }, + ), + ); + + 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 initial message delivery could not be confirmed.", + initialMessageError: "response lost", + initialMessageDelivery: { + starterMessageDelivered: false, + deliveredChunkCount: 0, + deliveredMessageIds: [], + failedChunkDelivery: "unknown", + failedChunkIndex: 0, + totalChunkCount: 1, + }, + }); + }); }); describe("handleDiscordGuildAction", () => { diff --git a/extensions/discord/src/send.creates-thread.test.ts b/extensions/discord/src/send.creates-thread.test.ts index 32c057dd163c..9fe29c6fed12 100644 --- a/extensions/discord/src/send.creates-thread.test.ts +++ b/extensions/discord/src/send.creates-thread.test.ts @@ -4,6 +4,7 @@ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { RateLimitError } from "./internal/discord.js"; +import { hasDiscordMessageCreateAmbiguity } from "./retry.js"; import { makeDiscordRest } from "./send.test-harness.js"; vi.mock("openclaw/plugin-sdk/web-media", async () => { @@ -302,6 +303,110 @@ describe("sendMessageDiscord", () => { }); }); + it("keeps forum starter messages within Discord's content limit", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildForum }); + postMock.mockResolvedValue({ id: "t1" }); + const content = "a".repeat(2001); + + await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); + + expect(postMock).toHaveBeenCalledTimes(2); + expect(requestBody(postMock as unknown as MockCallSource, 0)).toEqual({ + name: "thread", + message: { content: "a".repeat(2000) }, + }); + expect(requestPath(postMock as unknown as MockCallSource, 1)).toBe( + Routes.channelMessages("t1"), + ); + expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ + content: "a", + enforce_nonce: true, + }); + }); + + it("keeps sub-limit multi-line forum content in one starter message", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildForum }); + postMock.mockResolvedValue({ id: "t1" }); + const content = Array.from({ length: 18 }, (_, index) => `line ${index + 1}`).join("\n"); + + await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); + + expect(postMock).toHaveBeenCalledTimes(1); + expect(requestBody(postMock as unknown as MockCallSource)).toEqual({ + name: "thread", + message: { content }, + }); + }); + + it("reports a delivered forum starter when a continuation chunk fails", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildForum }); + postMock + .mockResolvedValueOnce({ id: "t1", message: { id: "starter1", channel_id: "t1" } }) + .mockRejectedValueOnce(Object.assign(new Error("missing access"), { status: 403 })); + + let thrown: unknown; + try { + await createThreadDiscord( + "chan1", + { name: "thread", content: "a".repeat(2001) }, + discordClientOpts(rest), + ); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); + expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ + starterMessageDelivered: true, + deliveredChunkCount: 1, + deliveredMessageIds: ["starter1"], + failedChunkDelivery: "not_delivered", + failedChunkIndex: 1, + totalChunkCount: 2, + }); + }); + + it("reports an exhausted ambiguous forum continuation as unknown delivery", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildForum }); + const ambiguous = Object.assign(new Error("response lost"), { status: 502 }); + postMock + .mockResolvedValueOnce({ id: "t1", message: { id: "starter1", channel_id: "t1" } }) + .mockRejectedValue(ambiguous); + + let thrown: unknown; + try { + await createThreadDiscord( + "chan1", + { name: "thread", content: "a".repeat(2001) }, + { + ...discordClientOpts(rest), + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }, + ); + } catch (error) { + thrown = error; + } + + expect(postMock).toHaveBeenCalledTimes(3); + expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); + expect(hasDiscordMessageCreateAmbiguity(thrown)).toBe(true); + expect(requireRecord(thrown, "thread initial message error").message).toContain( + "delivery of the remaining initial content could not be confirmed", + ); + expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ + starterMessageDelivered: true, + deliveredChunkCount: 1, + deliveredMessageIds: ["starter1"], + failedChunkDelivery: "unknown", + failedChunkIndex: 1, + totalChunkCount: 2, + }); + }); + it("inherits default_auto_archive_duration for forum threads", async () => { const { rest, getMock, postMock } = makeDiscordRest(); getMock.mockResolvedValue({ @@ -457,11 +562,146 @@ describe("sendMessageDiscord", () => { expect(requestPath(postMock as unknown as MockCallSource, 1)).toBe( Routes.channelMessages("t1"), ); - expect(requestBody(postMock as unknown as MockCallSource, 1)).toEqual({ + expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ content: "Hello thread!", + enforce_nonce: true, }); }); + it("chunks long initial messages for non-forum threads", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildText }); + postMock.mockResolvedValue({ id: "t1" }); + const content = "a".repeat(2001); + + await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); + + expect(postMock).toHaveBeenCalledTimes(3); + expect(requestPath(postMock as unknown as MockCallSource, 1)).toBe( + Routes.channelMessages("t1"), + ); + expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ + content: "a".repeat(2000), + enforce_nonce: true, + }); + expect(requestPath(postMock as unknown as MockCallSource, 2)).toBe( + Routes.channelMessages("t1"), + ); + expect(requestBody(postMock as unknown as MockCallSource, 2)).toMatchObject({ + content: "a", + enforce_nonce: true, + }); + }); + + it("keeps sub-limit multi-line non-forum content in one initial message", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildText }); + postMock.mockResolvedValue({ id: "t1", channel_id: "t1" }); + const content = Array.from({ length: 18 }, (_, index) => `line ${index + 1}`).join("\n"); + + await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); + + expect(postMock).toHaveBeenCalledTimes(2); + expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ content }); + }); + + it("reports delivered non-forum chunks when a later chunk fails", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildText }); + postMock + .mockResolvedValueOnce({ id: "t1", name: "thread", type: ChannelType.PublicThread }) + .mockResolvedValueOnce({ id: "msg1", channel_id: "t1" }) + .mockRejectedValueOnce(Object.assign(new Error("missing access"), { status: 403 })); + + let thrown: unknown; + try { + await createThreadDiscord( + "chan1", + { name: "thread", content: "a".repeat(4001) }, + discordClientOpts(rest), + ); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); + expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ + starterMessageDelivered: false, + deliveredChunkCount: 1, + deliveredMessageIds: ["msg1"], + failedChunkDelivery: "not_delivered", + failedChunkIndex: 1, + totalChunkCount: 3, + }); + }); + + it("reports an exhausted ambiguous non-forum chunk as unknown delivery", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildText }); + const ambiguous = Object.assign(new Error("response lost"), { status: 502 }); + postMock + .mockResolvedValueOnce({ id: "t1", name: "thread", type: ChannelType.PublicThread }) + .mockResolvedValueOnce({ id: "msg1", channel_id: "t1" }) + .mockRejectedValue(ambiguous); + + let thrown: unknown; + try { + await createThreadDiscord( + "chan1", + { name: "thread", content: "a".repeat(4001) }, + { + ...discordClientOpts(rest), + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }, + ); + } catch (error) { + thrown = error; + } + + expect(postMock).toHaveBeenCalledTimes(4); + expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); + expect(hasDiscordMessageCreateAmbiguity(thrown)).toBe(true); + expect(requireRecord(thrown, "thread initial message error").message).toContain( + "delivery of the remaining initial content could not be confirmed", + ); + expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ + starterMessageDelivered: false, + deliveredChunkCount: 1, + deliveredMessageIds: ["msg1"], + failedChunkDelivery: "unknown", + failedChunkIndex: 1, + totalChunkCount: 3, + }); + }); + + it("retries continuation sends with a stable nonce per chunk", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildText }); + postMock + .mockResolvedValueOnce({ id: "t1", name: "thread", type: ChannelType.PublicThread }) + .mockRejectedValueOnce(Object.assign(new Error("bad gateway"), { status: 502 })) + .mockResolvedValueOnce({ id: "msg1", channel_id: "t1" }) + .mockResolvedValueOnce({ id: "msg2", channel_id: "t1" }); + + await createThreadDiscord( + "chan1", + { name: "thread", content: "a".repeat(2001) }, + { + ...discordClientOpts(rest), + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }, + ); + + expect(postMock).toHaveBeenCalledTimes(4); + const firstAttempt = requestBody(postMock as unknown as MockCallSource, 1); + const retryAttempt = requestBody(postMock as unknown as MockCallSource, 2); + const nextChunk = requestBody(postMock as unknown as MockCallSource, 3); + expect(firstAttempt.enforce_nonce).toBe(true); + expect(retryAttempt.nonce).toBe(firstAttempt.nonce); + expect(nextChunk.enforce_nonce).toBe(true); + expect(nextChunk.nonce).not.toBe(firstAttempt.nonce); + }); + it("keeps created non-forum thread details when initial message send fails", async () => { const { rest, getMock, postMock } = makeDiscordRest(); getMock.mockResolvedValue({ type: ChannelType.GuildText }); @@ -483,6 +723,7 @@ describe("sendMessageDiscord", () => { expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); const error = requireRecord(thrown, "thread initial message error"); expect(error.name).toBe("DiscordThreadInitialMessageError"); + expect(error.message).toContain("initial message delivery could not be confirmed"); expect(error.initialMessageError).toBe("missing access"); expect(error.thread).toEqual({ id: "t1", name: "thread", type: ChannelType.PublicThread }); }); @@ -507,8 +748,9 @@ describe("sendMessageDiscord", () => { expect(requestPath(postMock as unknown as MockCallSource, 1)).toBe( Routes.channelMessages("t1"), ); - expect(requestBody(postMock as unknown as MockCallSource, 1)).toEqual({ + expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ content: "Discussion here", + enforce_nonce: true, }); }); diff --git a/extensions/discord/src/send.messages.ts b/extensions/discord/src/send.messages.ts index 63699b2708f7..d5a9d459b39e 100644 --- a/extensions/discord/src/send.messages.ts +++ b/extensions/discord/src/send.messages.ts @@ -3,7 +3,6 @@ import type { APIChannel, APIMessage } from "discord-api-types/v10"; import { ChannelType } from "discord-api-types/v10"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { - createChannelMessage, createThread, deleteChannelMessage, editChannelMessage, @@ -18,7 +17,17 @@ import { unpinChannelMessage, } from "./internal/discord.js"; import { parseDiscordRetryAfterBodySeconds } from "./retry-after.js"; -import { resolveDiscordRest } from "./send.shared.js"; +import { + classifyDiscordDeliveryFailure, + recordDiscordMessageCreateAmbiguity, + type DiscordRetryRunner, +} from "./retry.js"; +import { + buildDiscordTextChunks, + createDiscordClient, + resolveDiscordRest, + sendDiscordText, +} from "./send.shared.js"; import type { DiscordMessageEdit, DiscordMessageQuery, @@ -28,6 +37,29 @@ import type { DiscordThreadList, } from "./send.types.js"; +const DISCORD_THREAD_TRANSPORT_ONLY_MAX_LINES = Number.MAX_SAFE_INTEGER; + +type DiscordThreadInitialMessageDelivery = Readonly<{ + starterMessageDelivered: boolean; + deliveredChunkCount: number; + deliveredMessageIds: readonly string[]; + failedChunkDelivery: "not_delivered" | "unknown"; + failedChunkIndex: number; + totalChunkCount: number; +}>; + +function resolveDiscordThreadStarterMessageId(thread: APIChannel): string { + const starterMessage = "message" in thread ? thread.message : undefined; + if ( + starterMessage && + typeof starterMessage === "object" && + "id" in starterMessage && + typeof starterMessage.id === "string" + ) { + return starterMessage.id; + } + return thread.id; +} function assertDiscordResponseArray(value: unknown, label: string): T[] { if (!Array.isArray(value)) { throw new Error(`Unexpected Discord response for ${label}: expected array.`); @@ -49,17 +81,43 @@ function resolveDefaultThreadAutoArchiveDuration(channel?: APIChannel): number | return channel.default_auto_archive_duration; } +function describeDiscordThreadInitialMessageFailure( + delivery?: DiscordThreadInitialMessageDelivery, +): string { + if (delivery?.failedChunkDelivery === "unknown") { + return delivery.deliveredChunkCount > 0 + ? "Discord thread was created, but delivery of the remaining initial content could not be confirmed" + : "Discord thread was created, but initial message delivery could not be confirmed"; + } + return delivery && delivery.deliveredChunkCount > 0 + ? "Discord thread was created, but its initial content was only partially delivered" + : "Discord thread was created, but sending the initial message failed"; +} + export class DiscordThreadInitialMessageError extends Error { + readonly initialMessageDelivery?: DiscordThreadInitialMessageDelivery; readonly initialMessageError: string; + readonly initialMessageWarning: string; readonly thread: APIChannel; - constructor(thread: APIChannel, error: unknown) { + constructor( + thread: APIChannel, + error: unknown, + initialMessageDelivery?: DiscordThreadInitialMessageDelivery, + ) { const initialMessageError = formatErrorMessage(error); - super( - `Discord thread was created, but sending the initial message failed: ${initialMessageError}`, - ); + const initialMessageWarning = + describeDiscordThreadInitialMessageFailure(initialMessageDelivery); + super(`${initialMessageWarning}: ${initialMessageError}`, { cause: error }); this.name = "DiscordThreadInitialMessageError"; + this.initialMessageDelivery = initialMessageDelivery + ? { + ...initialMessageDelivery, + deliveredMessageIds: [...initialMessageDelivery.deliveredMessageIds], + } + : undefined; this.initialMessageError = initialMessageError; + this.initialMessageWarning = initialMessageWarning; this.thread = thread; } } @@ -161,7 +219,7 @@ export async function createThreadDiscord( payload: DiscordThreadCreate, opts: DiscordReactOpts, ) { - const rest = resolveDiscordRest(opts); + const { rest, request } = createDiscordClient(opts); const body: Record = { name: payload.name }; if (!payload.messageId && payload.type !== undefined) { body.type = payload.type; @@ -183,8 +241,18 @@ export async function createThreadDiscord( } const isForumLike = channel?.type === ChannelType.GuildForum || channel?.type === ChannelType.GuildMedia; + const initialMessageContent = isForumLike + ? payload.content?.trim() + ? payload.content + : payload.name + : payload.content?.trim() + ? payload.content + : ""; + const initialMessageChunks = buildDiscordTextChunks(initialMessageContent, { + maxLinesPerMessage: DISCORD_THREAD_TRANSPORT_ONLY_MAX_LINES, + }); if (isForumLike) { - const starterContent = payload.content?.trim() ? payload.content : payload.name; + const starterContent = initialMessageChunks[0] ?? payload.name; body.message = { content: starterContent }; if (payload.appliedTags?.length) { body.applied_tags = payload.appliedTags; @@ -198,15 +266,56 @@ export async function createThreadDiscord( } const thread = await createThread(rest, channelId, { body }, payload.messageId); - // 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) { - try { - await createChannelMessage(rest, thread.id, { - body: { content: payload.content }, - }); - } catch (error) { - throw new DiscordThreadInitialMessageError(thread, error); + // Forum creation accepts exactly one starter message, so keep the first chunk in the + // create request and deliver any remainder after Discord returns the new thread. + const followupChunks = isForumLike ? initialMessageChunks.slice(1) : initialMessageChunks; + if (followupChunks.length && "id" in thread) { + const deliveredMessageIds = isForumLike ? [resolveDiscordThreadStarterMessageId(thread)] : []; + let deliveredChunkCount = isForumLike ? 1 : 0; + const firstFollowupChunkIndex = isForumLike ? 1 : 0; + for (const [followupIndex, content] of followupChunks.entries()) { + let chunkMayHaveDelivered = false; + const trackedRequest: DiscordRetryRunner = (fn, label, options) => + request( + async () => { + try { + return await fn(); + } catch (error) { + chunkMayHaveDelivered ||= classifyDiscordDeliveryFailure(error) === "ambiguous"; + throw error; + } + }, + label, + options, + ); + try { + const result = await sendDiscordText({ + rest, + request: trackedRequest, + channelId: thread.id, + text: content, + maxLinesPerMessage: DISCORD_THREAD_TRANSPORT_ONLY_MAX_LINES, + }); + deliveredMessageIds.push(...result.platformMessageIds); + deliveredChunkCount += 1; + } catch (error) { + const finalFailure = classifyDiscordDeliveryFailure(error); + const failedChunkDelivery = + chunkMayHaveDelivered || finalFailure === "ambiguous" || finalFailure === "unknown" + ? "unknown" + : "not_delivered"; + if (failedChunkDelivery === "unknown") { + recordDiscordMessageCreateAmbiguity(error); + } + throw new DiscordThreadInitialMessageError(thread, error, { + starterMessageDelivered: isForumLike, + deliveredChunkCount, + deliveredMessageIds, + failedChunkDelivery, + failedChunkIndex: firstFollowupChunkIndex + followupIndex, + totalChunkCount: initialMessageChunks.length, + }); + } } }