From 7bf3dc35c5b3331eddd852daa59ff3cf01b24c46 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 9 Feb 2026 20:18:18 -0600 Subject: [PATCH] Discord: harden forum thread sends (#12380) (thanks @magendary) --- CHANGELOG.md | 1 + src/discord/send.outbound.ts | 95 +++++++++++++++---- .../send.sends-basic-channel-messages.test.ts | 59 ++++++++++++ src/discord/send.shared.ts | 39 ++++---- 4 files changed, 159 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2494dc75e5..240ba066bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - WhatsApp: preserve original filenames for inbound documents. (#12691) Thanks @akramcodez. - Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov. - Telegram: recover proactive sends when stale topic thread IDs are used by retrying without `message_thread_id`. (#11620) +- Discord: auto-create forum/media thread posts on send, with chunked follow-up replies and media handling for forum sends. (#12380) Thanks @magendary, @thewilloftheshadow. - Telegram: render markdown spoilers with `` HTML tags. (#11543) Thanks @ezhikkk. - Telegram: truncate command registration to 100 entries to avoid `BOT_COMMANDS_TOO_MUCH` failures on startup. (#12356) Thanks @arosstale. - Telegram: match DM `allowFrom` against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai. diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts index ea5e945da6..c639e55183 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -12,6 +12,7 @@ import { convertMarkdownTables } from "../markdown/tables.js"; import { resolveDiscordAccount } from "./accounts.js"; import { buildDiscordSendError, + buildDiscordTextChunks, createDiscordClient, normalizeDiscordPollInput, normalizeStickerIds, @@ -81,32 +82,28 @@ export async function sendMessageDiscord( } if (isForumLikeType(channelType)) { + const threadName = deriveForumThreadName(textWithTables); + const chunks = buildDiscordTextChunks(textWithTables, { + maxLinesPerMessage: accountInfo.config.maxLinesPerMessage, + chunkMode, + }); + const starterContent = chunks[0]?.trim() ? chunks[0] : threadName; + const starterEmbeds = opts.embeds?.length ? opts.embeds : undefined; + let threadRes: { id: string; message?: { id: string; channel_id: string } }; try { - const threadName = deriveForumThreadName(textWithTables); - const starterContent = textWithTables.trim() || threadName; - const threadRes = (await request( + threadRes = (await request( () => rest.post(Routes.threads(channelId), { body: { name: threadName, - message: { content: starterContent }, + message: { + content: starterContent, + ...(starterEmbeds ? { embeds: starterEmbeds } : {}), + }, }, }) as Promise<{ id: string; message?: { id: string; channel_id: string } }>, "forum-thread", )) as { id: string; message?: { id: string; channel_id: string } }; - - recordChannelActivity({ - channel: "discord", - accountId: accountInfo.accountId, - direction: "outbound", - }); - const threadId = threadRes.id; - const messageId = threadRes.message?.id ?? threadId; - const resultChannelId = threadRes.message?.channel_id ?? threadId; - return { - messageId: messageId ? String(messageId) : "unknown", - channelId: String(resultChannelId ?? channelId), - }; } catch (err) { throw await buildDiscordSendError(err, { channelId, @@ -115,6 +112,70 @@ export async function sendMessageDiscord( hasMedia: Boolean(opts.mediaUrl), }); } + + const threadId = threadRes.id; + const messageId = threadRes.message?.id ?? threadId; + const resultChannelId = threadRes.message?.channel_id ?? threadId; + const remainingChunks = chunks.slice(1); + + try { + if (opts.mediaUrl) { + const [mediaCaption, ...afterMediaChunks] = remainingChunks; + await sendDiscordMedia( + rest, + threadId, + mediaCaption ?? "", + opts.mediaUrl, + undefined, + request, + accountInfo.config.maxLinesPerMessage, + undefined, + chunkMode, + ); + for (const chunk of afterMediaChunks) { + await sendDiscordText( + rest, + threadId, + chunk, + undefined, + request, + accountInfo.config.maxLinesPerMessage, + undefined, + chunkMode, + ); + } + } else { + for (const chunk of remainingChunks) { + await sendDiscordText( + rest, + threadId, + chunk, + undefined, + request, + accountInfo.config.maxLinesPerMessage, + undefined, + chunkMode, + ); + } + } + } catch (err) { + throw await buildDiscordSendError(err, { + channelId: threadId, + rest, + token, + hasMedia: Boolean(opts.mediaUrl), + }); + } + + recordChannelActivity({ + channel: "discord", + accountId: accountInfo.accountId, + direction: "outbound", + }); + return { + messageId: messageId ? String(messageId) : "unknown", + channelId: String(resultChannelId ?? channelId), + }; } let result: { id: string; channel_id: string } | { id: string | null; channel_id: string }; diff --git a/src/discord/send.sends-basic-channel-messages.test.ts b/src/discord/send.sends-basic-channel-messages.test.ts index d091f9ba70..0d01eff01c 100644 --- a/src/discord/send.sends-basic-channel-messages.test.ts +++ b/src/discord/send.sends-basic-channel-messages.test.ts @@ -101,6 +101,64 @@ describe("sendMessageDiscord", () => { ); }); + it("posts media as a follow-up message in forum channels", async () => { + const { rest, postMock, getMock } = makeRest(); + getMock.mockResolvedValueOnce({ type: ChannelType.GuildForum }); + postMock + .mockResolvedValueOnce({ + id: "thread1", + message: { id: "starter1", channel_id: "thread1" }, + }) + .mockResolvedValueOnce({ id: "media1", channel_id: "thread1" }); + const res = await sendMessageDiscord("channel:forum1", "Topic", { + rest, + token: "t", + mediaUrl: "file:///tmp/photo.jpg", + }); + expect(res).toEqual({ messageId: "starter1", channelId: "thread1" }); + expect(postMock).toHaveBeenNthCalledWith( + 1, + Routes.threads("forum1"), + expect.objectContaining({ + body: { + name: "Topic", + message: { content: "Topic" }, + }, + }), + ); + expect(postMock).toHaveBeenNthCalledWith( + 2, + Routes.channelMessages("thread1"), + expect.objectContaining({ + body: expect.objectContaining({ + files: [expect.objectContaining({ name: "photo.jpg" })], + }), + }), + ); + }); + + it("chunks long forum posts into follow-up messages", async () => { + const { rest, postMock, getMock } = makeRest(); + getMock.mockResolvedValueOnce({ type: ChannelType.GuildForum }); + postMock + .mockResolvedValueOnce({ + id: "thread1", + message: { id: "starter1", channel_id: "thread1" }, + }) + .mockResolvedValueOnce({ id: "msg2", channel_id: "thread1" }); + const longText = "a".repeat(2001); + await sendMessageDiscord("channel:forum1", longText, { + rest, + token: "t", + }); + const firstBody = postMock.mock.calls[0]?.[1]?.body as { + message?: { content?: string }; + }; + const secondBody = postMock.mock.calls[1]?.[1]?.body as { content?: string }; + expect(firstBody?.message?.content).toHaveLength(2000); + expect(secondBody?.content).toBe("a"); + }); + it("starts DM when recipient is a user", async () => { const { rest, postMock } = makeRest(); postMock @@ -145,6 +203,7 @@ describe("sendMessageDiscord", () => { }); postMock.mockRejectedValueOnce(apiError); getMock + .mockResolvedValueOnce({ type: ChannelType.GuildText }) .mockResolvedValueOnce({ id: "789", guild_id: "guild1", diff --git a/src/discord/send.shared.ts b/src/discord/send.shared.ts index ea666913d1..203e8ef29b 100644 --- a/src/discord/send.shared.ts +++ b/src/discord/send.shared.ts @@ -278,6 +278,24 @@ async function resolveChannelId( return { channelId: dmChannel.id, dm: true }; } +export function buildDiscordTextChunks( + text: string, + opts: { maxLinesPerMessage?: number; chunkMode?: ChunkMode; maxChars?: number } = {}, +): string[] { + if (!text) { + return []; + } + const chunks = chunkDiscordTextWithMode(text, { + maxChars: opts.maxChars ?? DISCORD_TEXT_LIMIT, + maxLines: opts.maxLinesPerMessage, + chunkMode: opts.chunkMode, + }); + if (!chunks.length && text) { + chunks.push(text); + } + return chunks; +} + async function sendDiscordText( rest: RequestClient, channelId: string, @@ -292,14 +310,7 @@ async function sendDiscordText( throw new Error("Message must be non-empty for Discord sends"); } const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined; - const chunks = chunkDiscordTextWithMode(text, { - maxChars: DISCORD_TEXT_LIMIT, - maxLines: maxLinesPerMessage, - chunkMode, - }); - if (!chunks.length && text) { - chunks.push(text); - } + const chunks = buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode }); if (chunks.length === 1) { const res = (await request( () => @@ -348,16 +359,7 @@ async function sendDiscordMedia( chunkMode?: ChunkMode, ) { const media = await loadWebMedia(mediaUrl); - const chunks = text - ? chunkDiscordTextWithMode(text, { - maxChars: DISCORD_TEXT_LIMIT, - maxLines: maxLinesPerMessage, - chunkMode, - }) - : []; - if (!chunks.length && text) { - chunks.push(text); - } + const chunks = text ? buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode }) : []; const caption = chunks[0] ?? ""; const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined; const res = (await request( @@ -408,6 +410,7 @@ function formatReactionEmoji(emoji: { id?: string | null; name?: string | null } export { buildDiscordSendError, + buildDiscordTextChunks, buildReactionIdentifier, createDiscordClient, formatReactionEmoji,