mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
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:
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user