fix(discord): chunk long thread initial messages (#117354)

Discord rejected over-limit starter messages and could leave ordinary threads empty after creation.

Split initial content through the existing Discord chunking and nonce-retry path while preserving confirmed-versus-ambiguous partial-delivery results.

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>
This commit is contained in:
xingzhou
2026-08-13 13:07:14 +08:00
committed by GitHub
parent 6ad5d1104c
commit 329ed12fcf
4 changed files with 462 additions and 20 deletions
@@ -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;
@@ -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<typeof DiscordThreadInitialMessageError>[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<typeof DiscordThreadInitialMessageError>[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", () => {
@@ -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,
});
});
+126 -17
View File
@@ -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<T>(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<string, unknown> = { 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,
});
}
}
}