From ead3bb645f2a381b80d31c6e1c8408c1747bcbd0 Mon Sep 17 00:00:00 2001 From: magendary <30611068+magendary@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:26:42 -0800 Subject: [PATCH] discord: auto-create thread when sending to Forum/Media channels (#12380) * discord: auto-create thread when sending to Forum/Media channels * Discord: harden forum thread sends (#12380) (thanks @magendary) * fix: clean up discord send exports (#12380) (thanks @magendary) --------- Co-authored-by: Shadow --- CHANGELOG.md | 1 + README.md | 94 +++++++------ src/discord/send.outbound.ts | 129 +++++++++++++++++- .../send.sends-basic-channel-messages.test.ts | 90 +++++++++++- src/discord/send.shared.ts | 38 +++--- 5 files changed, 286 insertions(+), 66 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/README.md b/README.md index dad4a20309..b1a3b407a0 100644 --- a/README.md +++ b/README.md @@ -497,49 +497,53 @@ Special thanks to Adam Doppelt for lobster.bot. Thanks to all clawtributors:

- steipete joshp123 cpojer Mariano Belinky plum-dawg bohdanpodvirnyi sebslight iHildy jaydenfyi joaohlisboa - mneves75 MatthieuBizien Glucksberg MaudeBot gumadeiras tyler6204 rahthakor vrknetha vignesh07 radek-paclt - abdelsfane Tobias Bischoff christianklotz czekaj ethanpalm mukhtharcm maxsumrall xadenryan VACInc rodrigouroz - juanpablodlc conroywhitney hsrvc magimetal zerone0x Takhoffman meaningfool mudrii patelhiren NicholasSpisak - jonisjongithub abhisekbasu1 jamesgroat BunsDev claude JustYannicc Hyaxia dantelex SocialNerd42069 daveonkels - google-labs-jules[bot] lc0rp adam91holt mousberg hougangdev shakkernerd coygeek mteam88 hirefrank M00N7682 - joeynyc orlyjamie dbhurley Eng. Juan Combetto TSavo aerolalit julianengel bradleypriest benithors lsh411 - gut-puncture rohannagpal timolins f-trycua benostein elliotsecops nachx639 pvoo sreekaransrinath gupsammy - cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b thewilloftheshadow leszekszpunar scald pycckuu andranik-sahakyan - davidguttman sleontenko denysvitali clawdinator[bot] TinyTb sircrumpet peschee nicolasstanley davidiach nonggialiang - ironbyte-rgb rafaelreis-r dominicnunez lploc94 ratulsarna sfo2001 lutr0 kiranjd danielz1z Iranb - AdeboyeDN Alg0rix obviyus papago2355 emanuelst evanotero KristijanJovanovski jlowin rdev rhuanssauro - joshrad-dev osolmaz adityashaw2 CashWilliams sheeek ryancontent jasonsschin artuskg onutc pauloportella - HirokiKobayashi-R ThanhNguyxn 18-RAJAT kimitaka yuting0624 neooriginal manuelhettich minghinmatthewlam unisone baccula - manikv12 myfunc travisirby fujiwara-tofu-shop buddyh connorshea bjesuiter kyleok slonce70 mcinteerj - badlogic dependabot[bot] amitbiswal007 John-Rood timkrase uos-status gerardward2007 roshanasingh4 tosh-hamburg azade-c - dlauer grp06 JonUleis shivamraut101 cheeeee robbyczgw-cla YuriNachos Josh Phillips Wangnov kaizen403 - pookNast Whoaa512 chriseidhof ngutman therealZpoint-bot wangai-studio ysqander Yurii Chukhlib aj47 kennyklee - superman32432432 Hisleren shatner antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr GHesericsu HeimdallStrategy - imfing jalehman jarvis-medmatic kkarimi Lukavyi mahmoudashraf93 pkrmf RandyVentures robhparker Ryan Lisse - Yeom-JinHo doodlewind dougvk erikpr1994 fal3 Ghost hyf0-agent jonasjancarik Keith the Silly Goose L36 Server - Marc mitschabaude-bot mkbehr neist sibbl zats abhijeet117 chrisrodz Friederike Seiler gabriel-trigo - iamadig itsjling Jonathan D. Rhyne (DJ-D) Joshua Mitchell kelvinCB Kit koala73 manmal mattqdev mitsuhiko - ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain spiceoogway suminhthanh svkozak wes-davis 24601 - ameno- bonald bravostation Chris Taylor dguido Django Navarro evalexpr henrino3 humanwritten j2h4u - larlyssa odysseus0 oswalpalash pcty-nextgen-service-account pi0 rmorse Roopak Nijhara Syhids Ubuntu xiaose - Aaron Konyer aaronveklabs aldoeliacim andreabadesso Andrii BinaryMuse bqcfjwhz85-arch cash-echo-bot Clawd ClawdFx - damaozi danballance Elarwei001 EnzeD erik-agens Evizero fcatuhe gildo hclsys itsjaydesu - ivancasco ivanrvpereira Jarvis jayhickey jeffersonwarrior jeffersonwarrior jverdi lailoo longmaba Marco Marandiz - MarvinCui mattezell mjrussell odnxe optimikelabs p6l-richard philipp-spiess Pocket Clawd robaxelsen Sash Catanzarite - Suksham-sharma T5-AndyML tewatia thejhinvirtuoso travisp VAC william arzt yudshj zknicker 0oAstro - abhaymundhara aduk059 aisling404 akramcodez alejandro maza Alex-Alaniz alexanderatallah alexstyl AlexZhangji andrewting19 - anpoirier araa47 arthyn Asleep123 Ayush Ojha Ayush10 bguidolim bolismauro caelum0x championswimmer - chenyuan99 Chloe-VP Clawdbot Maintainers conhecendoia dasilva333 David-Marsh-Photo deepsoumya617 Developer Dimitrios Ploutarchos Drake Thomsen - dvrshil dxd5001 dylanneve1 Felix Krause foeken frankekn fredheir ganghyun kim grrowl gtsifrikas - HassanFleyah HazAT hrdwdmrbl hugobarauna iamEvanYT ichbinlucaskim Jamie Openshaw Jane Jarvis Deploy Jefferson Nunn - jogi47 kentaro Kevin Lin kira-ariaki kitze Kiwitwitter levifig Lloyd loganaden longjos - loukotal louzhixian mac mimi martinpucik Matt mini mcaxtr mertcicekci0 Miles mrdbstn MSch - Mustafa Tag Eldeen mylukin nathanbosse ndraiman nexty5870 Noctivoro Omar-Khaleel ozgur-polat ppamment prathamdby - ptn1411 rafelbev reeltimeapps RLTCmpe Rony Kelner ryancnelson Samrat Jha senoldogann Seredeep sergical - shiv19 shiyuanhai Shrinija17 siraht snopoke stephenchen2025 techboss testingabc321 The Admiral thesash - Vibe Kanban vincentkoc voidserf Vultr-Clawd Admin Wimmie wolfred wstock wytheme YangHuang2280 yazinsai - yevhen YiWang24 ymat19 Zach Knickerbocker zackerthescar 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade - carlulsoe ddyo Erik jiulingyun latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin - Randy Torres rhjoh Rolf Fredheim ronak-guliani William Stock + steipete joshp123 cpojer Mariano Belinky sebslight Takhoffman quotentiroler bohdanpodvirnyi tyler6204 iHildy + jaydenfyi gumadeiras joaohlisboa mneves75 MatthieuBizien Glucksberg MaudeBot rahthakor vrknetha vignesh07 + radek-paclt abdelsfane Tobias Bischoff christianklotz czekaj ethanpalm mukhtharcm maxsumrall rodrigouroz xadenryan + VACInc juanpablodlc conroywhitney hsrvc magimetal zerone0x advaitpaliwal meaningfool patelhiren NicholasSpisak + jonisjongithub abhisekbasu1 theonejvo jamesgroat BunsDev claude JustYannicc Hyaxia dantelex SocialNerd42069 + daveonkels Yida-Dev google-labs-jules[bot] riccardogiorato lc0rp adam91holt mousberg clawdinator[bot] hougangdev shakkernerd + coygeek mteam88 hirefrank M00N7682 joeynyc orlyjamie dbhurley Eng. Juan Combetto TSavo aerolalit + julianengel bradleypriest benithors lsh411 gut-puncture rohannagpal timolins f-trycua benostein elliotsecops + nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat thewilloftheshadow petter-b + leszekszpunar scald pycckuu AnonO6 andranik-sahakyan davidguttman jarvis89757 sleontenko denysvitali TinyTb + sircrumpet peschee nicolasstanley davidiach nonggia.liang ironbyte-rgb dominicnunez lploc94 ratulsarna sfo2001 + lutr0 kiranjd danielz1z Iranb cdorsey AdeboyeDN obviyus Alg0rix papago2355 peetzweg/ + emanuelst evanotero KristijanJovanovski jlowin rdev rhuanssauro joshrad-dev osolmaz adityashaw2 shadril238 + CashWilliams sheeek ryan jasonsschin artuskg onutc pauloportella HirokiKobayashi-R ThanhNguyxn 18-RAJAT + kimitaka yuting0624 neooriginal manuelhettich unisone baccula manikv12 sbking travisirby fujiwara-tofu-shop + buddyh connorshea bjesuiter kyleok mcinteerj slonce70 calvin-hpnet gitpds ide-rea badlogic + grp06 dependabot[bot] amitbiswal007 John-Rood timkrase gerardward2007 roshanasingh4 tosh-hamburg azade-c dlauer + ezhikkk JonUleis shivamraut101 cheeeee jabezborja robbyczgw-cla YuriNachos Josh Phillips Wangnov kaizen403 + patrickshao Whoaa512 chriseidhof ngutman wangai-studio ysqander Yurii Chukhlib aj47 kennyklee superman32432432 + Hisleren antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr doodlewind GHesericsu HeimdallStrategy imfing + jalehman jarvis-medmatic kkarimi Lukavyi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse Yeom-JinHo dougvk + erikpr1994 fal3 Ghost hyf0-agent jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr + neist orenyomtov sibbl zats abhijeet117 chrisrodz Friederike Seiler gabriel-trigo hudson-rivera iamadig + itsjling Jonathan D. Rhyne (DJ-D) Joshua Mitchell kelvinCB Kit koala73 lailoo manmal mattqdev mcaxtr + mitsuhiko ogulcancelik petradonka rubyrunsstuff rybnikov siddhantjain suminhthanh svkozak wes-davis 24601 + ameno- bonald bravostation Chris Taylor damaozi dguido Django Navarro evalexpr henrino3 humanwritten + j2h4u larlyssa liuxiaopai-ai odysseus0 oswalpalash pcty-nextgen-service-account pi0 rmorse Roopak Nijhara Syhids + tmchow Ubuntu xiaose Aaron Konyer aaronveklabs akramcodez aldoeliacim andreabadesso Andrii BinaryMuse + bqcfjwhz85-arch cash-echo-bot Clawd ClawdFx danballance danielcadenhead Elarwei001 EnzeD erik-agens Evizero + fcatuhe gildo hclsys itsjaydesu ivancasco ivanrvpereira Jarvis jayhickey jeffersonwarrior jeffersonwarrior + jverdi longmaba Marco Marandiz MarvinCui mattezell mjrussell odnxe optimikelabs p6l-richard philipp-spiess + Pocket Clawd RayBB robaxelsen Sash Catanzarite Suksham-sharma T5-AndyML thejhinvirtuoso travisp VAC william arzt + yudshj zknicker 0oAstro Abdul535 abhaymundhara aduk059 aisling404 alejandro maza Alex-Alaniz alexanderatallah + alexstyl AlexZhangji andrewting19 anpoirier araa47 arthyn Asleep123 Ayush Ojha Ayush10 bguidolim + bolismauro caelum0x championswimmer chenyuan99 Chloe-VP Claude Code Clawdbot Maintainers conhecendoia dasilva333 David-Marsh-Photo + deepsoumya617 Developer Dimitrios Ploutarchos Drake Thomsen dvrshil dxd5001 dylanneve1 Felix Krause foeken frankekn + fredheir Fronut ganghyun kim grrowl gtsifrikas HassanFleyah HazAT hrdwdmrbl hugobarauna iamEvanYT + ichbinlucaskim Jamie Openshaw Jane Jarvis Deploy Jefferson Nunn jogi47 kentaro Kevin Lin kira-ariaki kitze + Kiwitwitter kossoy levifig liuy Lloyd loganaden longjos loukotal mac mimi markusbkoch + martinpucik Matt mini mertcicekci0 Miles minghinmatthewlam mrdbstn MSch mudrii Mustafa Tag Eldeen myfunc + mylukin nathanbosse ndraiman nexty5870 Noctivoro Omar-Khaleel ozgur-polat pasogott plum-dawg pookNast + ppamment prathamdby ptn1411 rafaelreis-r rafelbev reeltimeapps RLTCmpe robhparker rohansachinpatil Rony Kelner + ryancnelson Samrat Jha seans-openclawbot senoldogann Seredeep sergical shatner shiv19 shiyuanhai Shrinija17 + siraht snopoke spiceoogway stephenchen2025 succ985 Suvink techboss testingabc321 tewatia The Admiral + therealZpoint-bot thesash uos-status vcastellm Vibe Kanban vincentkoc void Vultr-Clawd Admin Wimmie wolfred + wstock wytheme YangHuang2280 yazinsai yevhen YiWang24 ymat19 Zach Knickerbocker zackerthescar zhixian + 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik jiulingyun latitudeki5223 + Manuel Maly minghinmatthewlam Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin rafaelreis-r Randy Torres rhjoh Rolf Fredheim + ronak-guliani William Stock

diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts index f994c02a87..c639e55183 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -1,5 +1,6 @@ import type { RequestClient } from "@buape/carbon"; -import { Routes } from "discord-api-types/v10"; +import type { APIChannel } from "discord-api-types/v10"; +import { ChannelType, Routes } from "discord-api-types/v10"; import type { RetryConfig } from "../infra/retry.js"; import type { PollInput } from "../polls.js"; import type { DiscordSendResult } from "./send.types.js"; @@ -11,6 +12,7 @@ import { convertMarkdownTables } from "../markdown/tables.js"; import { resolveDiscordAccount } from "./accounts.js"; import { buildDiscordSendError, + buildDiscordTextChunks, createDiscordClient, normalizeDiscordPollInput, normalizeStickerIds, @@ -31,6 +33,24 @@ type DiscordSendOpts = { embeds?: unknown[]; }; +/** Discord thread names are capped at 100 characters. */ +const DISCORD_THREAD_NAME_LIMIT = 100; + +/** Derive a thread title from the first non-empty line of the message text. */ +function deriveForumThreadName(text: string): string { + const firstLine = + text + .split("\n") + .find((l) => l.trim()) + ?.trim() ?? ""; + return firstLine.slice(0, DISCORD_THREAD_NAME_LIMIT) || new Date().toISOString().slice(0, 16); +} + +/** Forum/Media channels cannot receive regular messages; detect them here. */ +function isForumLikeType(channelType?: number): boolean { + return channelType === ChannelType.GuildForum || channelType === ChannelType.GuildMedia; +} + export async function sendMessageDiscord( to: string, text: string, @@ -51,6 +71,113 @@ export async function sendMessageDiscord( const { token, rest, request } = createDiscordClient(opts, cfg); const recipient = await parseAndResolveRecipient(to, opts.accountId); const { channelId } = await resolveChannelId(rest, recipient, request); + + // Forum/Media channels reject POST /messages; auto-create a thread post instead. + let channelType: number | undefined; + try { + const channel = (await rest.get(Routes.channel(channelId))) as APIChannel | undefined; + channelType = channel?.type; + } catch { + // If we can't fetch the channel, fall through to the normal send path. + } + + 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 { + threadRes = (await request( + () => + rest.post(Routes.threads(channelId), { + body: { + name: threadName, + 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 } }; + } catch (err) { + throw await buildDiscordSendError(err, { + channelId, + rest, + token, + 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 }; try { if (opts.mediaUrl) { diff --git a/src/discord/send.sends-basic-channel-messages.test.ts b/src/discord/send.sends-basic-channel-messages.test.ts index ebe2a3f7aa..0d01eff01c 100644 --- a/src/discord/send.sends-basic-channel-messages.test.ts +++ b/src/discord/send.sends-basic-channel-messages.test.ts @@ -1,4 +1,4 @@ -import { PermissionFlagsBits, Routes } from "discord-api-types/v10"; +import { ChannelType, PermissionFlagsBits, Routes } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { deleteMessageDiscord, @@ -58,7 +58,9 @@ describe("sendMessageDiscord", () => { }); it("sends basic channel messages", async () => { - const { rest, postMock } = makeRest(); + const { rest, postMock, getMock } = makeRest(); + // Channel type lookup returns a normal text channel (not a forum). + getMock.mockResolvedValueOnce({ type: ChannelType.GuildText }); postMock.mockResolvedValue({ id: "msg1", channel_id: "789", @@ -74,6 +76,89 @@ describe("sendMessageDiscord", () => { ); }); + it("auto-creates a forum thread when target is a Forum channel", async () => { + const { rest, postMock, getMock } = makeRest(); + // Channel type lookup returns a Forum channel. + getMock.mockResolvedValueOnce({ type: ChannelType.GuildForum }); + postMock.mockResolvedValue({ + id: "thread1", + message: { id: "starter1", channel_id: "thread1" }, + }); + const res = await sendMessageDiscord("channel:forum1", "Discussion topic\nBody of the post", { + rest, + token: "t", + }); + expect(res).toEqual({ messageId: "starter1", channelId: "thread1" }); + // Should POST to threads route, not channelMessages. + expect(postMock).toHaveBeenCalledWith( + Routes.threads("forum1"), + expect.objectContaining({ + body: { + name: "Discussion topic", + message: { content: "Discussion topic\nBody of the post" }, + }, + }), + ); + }); + + 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 @@ -118,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..d3e8a97593 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(