diff --git a/CHANGELOG.md b/CHANGELOG.md index 52dc0328c..a6f053d07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Gateway/CLI: include gateway target/source details in close/timeout errors and verbose health/status output. - Gateway/CLI: honor `gateway.auth.password` for local CLI calls when env is unset. Thanks @jeffersonwarrior for PR #301. - Discord: format slow listener logs in seconds to match shared duration style. +- Discord: split tall replies by line count to avoid client clipping; add `discord.maxLinesPerMessage` + docs. Thanks @jdrhyne for PR #371. - CLI: show colored table output for `clawdbot cron list` (JSON behind `--json`). - CLI: add cron `create`/`remove`/`delete` aliases for job management. - Agent: avoid duplicating context/skills when SDK rebuilds the system prompt. (#418) diff --git a/README.md b/README.md index 45ef4a5d9..e00510302 100644 --- a/README.md +++ b/README.md @@ -455,4 +455,5 @@ Thanks to all clawtributors: imfing buddyh gupsammy kitze minghinmatthewlam rafaelreis-r andranik-sahakyan antons Asleep123 djangonavarro220 cash-echo-bot erikpr1994 gtsifrikas hugobarauna Iamadig jamesgroat jayhickey jonasjancarik loukotal ManuelHettich hrdwdmrbl conhecendocontato MSch reeltimeapps mrdbstn + jdrhyne

diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index 26c216b82..de36a6b52 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -36,6 +36,7 @@ Legend: - `agent.blockStreamingBreak`: `"text_end"` or `"message_end"`. - `agent.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`. - Provider hard cap: `*.textChunkLimit` (e.g., `whatsapp.textChunkLimit`). +- Discord soft cap: `discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping. **Boundary semantics:** - `text_end`: stream blocks as soon as chunker emits; flush on each `text_end`. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 700ea90fd..81be68269 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -689,6 +689,8 @@ Multi-account support lives under `discord.accounts` (see the multi-account sect } }, historyLimit: 20, // include last N guild messages as context + textChunkLimit: 2000, // optional outbound text chunk size (chars) + maxLinesPerMessage: 17, // soft max lines per message (Discord UI clipping) retry: { // outbound retry policy attempts: 3, minDelayMs: 500, @@ -706,6 +708,7 @@ Reaction notification modes: - `own`: reactions on the bot's own messages (default). - `all`: all reactions on all messages. - `allowlist`: reactions from `guilds..users` on all messages (empty list disables). +Outbound text is chunked by `discord.textChunkLimit` (default 2000). Discord clients can clip very tall messages, so `discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars. Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry). ### `slack` (socket mode) diff --git a/docs/providers/discord.md b/docs/providers/discord.md index 383916f63..fbf512dc0 100644 --- a/docs/providers/discord.md +++ b/docs/providers/discord.md @@ -156,7 +156,7 @@ Notes: ## Capabilities & limits - DMs and guild text channels (threads are treated as separate channels; voice not supported). -- Typing indicators sent best-effort; message chunking honors Discord’s 2k character limit. +- Typing indicators sent best-effort; message chunking uses `discord.textChunkLimit` (default 2000) and splits tall replies by line count (`discord.maxLinesPerMessage`, default 17). - File uploads supported up to the configured `discord.mediaMaxMb` (default 8 MB). - Mention-gated guild replies by default to avoid noisy bots. - Reply context is injected when a message references another message (quoted content + ids). @@ -244,6 +244,8 @@ Ack reactions are controlled globally via `messages.ackReaction` + - `guilds..channels`: channel rules (keys are channel slugs or ids). - `guilds..requireMention`: per-guild mention requirement (overridable per channel). - `guilds..reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`). +- `textChunkLimit`: outbound text chunk size (chars). Default: 2000. +- `maxLinesPerMessage`: soft max line count per message. Default: 17. - `mediaMaxMb`: clamp inbound media saved to disk. - `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables). - `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter). diff --git a/docs/providers/imessage.md b/docs/providers/imessage.md index f47d5d204..3eb48fd74 100644 --- a/docs/providers/imessage.md +++ b/docs/providers/imessage.md @@ -77,6 +77,10 @@ Groups: - Optional attachment ingestion via `imessage.includeAttachments`. - Media cap via `imessage.mediaMaxMb`. +## Limits +- Outbound text is chunked to `imessage.textChunkLimit` (default 4000). +- Media uploads are capped by `imessage.mediaMaxMb` (default 16). + ## Addressing / delivery targets Prefer `chat_id` for stable routing: - `chat_id:123` (preferred) diff --git a/docs/providers/signal.md b/docs/providers/signal.md index e41660a59..a3f081616 100644 --- a/docs/providers/signal.md +++ b/docs/providers/signal.md @@ -60,8 +60,9 @@ Groups: - Replies always route back to the same number or group. ## Media + limits +- Outbound text is chunked to `signal.textChunkLimit` (default 4000). - Attachments supported (base64 fetched from `signal-cli`). -- Default cap: `signal.mediaMaxMb`. +- Default media cap: `signal.mediaMaxMb` (default 8). - Use `signal.ignoreAttachments` to skip downloading media. ## Delivery targets (CLI/cron) diff --git a/docs/providers/slack.md b/docs/providers/slack.md index 23c203a9b..a3507bd52 100644 --- a/docs/providers/slack.md +++ b/docs/providers/slack.md @@ -196,6 +196,10 @@ Tokens can also be supplied via env vars: Ack reactions are controlled globally via `messages.ackReaction` + `messages.ackReactionScope`. +## Limits +- Outbound text is chunked to `slack.textChunkLimit` (default 4000). +- Media uploads are capped by `slack.mediaMaxMb` (default 20). + ## Reply threading Slack supports optional threaded replies via tags: - `[[reply_to_current]]` — reply to the triggering message. diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md index dc63984e5..77705b6c2 100644 --- a/docs/providers/telegram.md +++ b/docs/providers/telegram.md @@ -47,6 +47,10 @@ Multi-account support: use `telegram.accounts` with per-account tokens and optio - Raw HTML from models is escaped to avoid Telegram parse errors. - If Telegram rejects the HTML payload, Clawdbot retries the same message as plain text. +## Limits +- Outbound text is chunked to `telegram.textChunkLimit` (default 4000). +- Media downloads/uploads are capped by `telegram.mediaMaxMb` (default 5). + ## Group activation modes By default, the bot only responds to mentions in groups (`@botname` or patterns in `routing.groupChat.mentionPatterns`). To change this behavior: diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md index a777af70f..5467b3938 100644 --- a/docs/providers/whatsapp.md +++ b/docs/providers/whatsapp.md @@ -126,9 +126,13 @@ Recommended for personal numbers: - Reaction removal semantics: see [/tools/reactions](/tools/reactions). - Tool gating: `whatsapp.actions.reactions` (default: enabled). +## Limits +- Outbound text is chunked to `whatsapp.textChunkLimit` (default 4000). +- Media items are capped by `agent.mediaMaxMb` (default 5 MB). + ## Outbound send (text + media) - Uses active web listener; error if gateway not running. -- Text chunking: 4k max per message. +- Text chunking: 4k max per message (configurable via `whatsapp.textChunkLimit`). - Media: - Image/video/audio/document supported. - Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`. diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 121e0802e..8a5d50f0a 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -212,7 +212,11 @@ describe("config identity defaults", () => { routing: {}, whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 }, telegram: { enabled: true, textChunkLimit: 3333 }, - discord: { enabled: true, textChunkLimit: 1999 }, + discord: { + enabled: true, + textChunkLimit: 1999, + maxLinesPerMessage: 17, + }, signal: { enabled: true, textChunkLimit: 2222 }, imessage: { enabled: true, textChunkLimit: 1111 }, }, @@ -229,6 +233,7 @@ describe("config identity defaults", () => { expect(cfg.whatsapp?.textChunkLimit).toBe(4444); expect(cfg.telegram?.textChunkLimit).toBe(3333); expect(cfg.discord?.textChunkLimit).toBe(1999); + expect(cfg.discord?.maxLinesPerMessage).toBe(17); expect(cfg.signal?.textChunkLimit).toBe(2222); expect(cfg.imessage?.textChunkLimit).toBe(1111); diff --git a/src/config/schema.ts b/src/config/schema.ts index 639c80f9f..1410bab77 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -121,6 +121,7 @@ const FIELD_LABELS: Record = { "discord.retry.minDelayMs": "Discord Retry Min Delay (ms)", "discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", "discord.retry.jitter": "Discord Retry Jitter", + "discord.maxLinesPerMessage": "Discord Max Lines Per Message", "slack.dm.policy": "Slack DM Policy", "discord.token": "Discord Bot Token", "slack.botToken": "Slack Bot Token", @@ -193,6 +194,8 @@ const FIELD_HELP: Record = { "Maximum retry delay cap in ms for Discord outbound calls.", "discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.", + "discord.maxLinesPerMessage": + "Soft max line count per Discord message (default: 17).", "slack.dm.policy": 'Direct message access control ("pairing" recommended). "open" requires slack.dm.allowFrom=["*"].', }; diff --git a/src/config/types.ts b/src/config/types.ts index a488282a8..abccd28a2 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -411,6 +411,12 @@ export type DiscordAccountConfig = { groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 2000. */ textChunkLimit?: number; + /** + * Soft max line count per Discord message. + * Discord clients can clip/collapse very tall messages; splitting by lines + * keeps replies readable in-channel. Default: 17. + */ + maxLinesPerMessage?: number; mediaMaxMb?: number; historyLimit?: number; /** Retry policy for outbound Discord API calls. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 67845e6d5..778bab915 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -254,6 +254,7 @@ const DiscordAccountSchema = z.object({ token: z.string().optional(), groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), + maxLinesPerMessage: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), historyLimit: z.number().int().min(0).optional(), retry: RetryConfigSchema, diff --git a/src/discord/chunk.test.ts b/src/discord/chunk.test.ts new file mode 100644 index 000000000..79eca1ddf --- /dev/null +++ b/src/discord/chunk.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { chunkDiscordText } from "./chunk.js"; + +function countLines(text: string) { + return text.split("\n").length; +} + +function hasBalancedFences(chunk: string) { + let open: { markerChar: string; markerLen: number } | null = null; + for (const line of chunk.split("\n")) { + const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/); + if (!match) continue; + const marker = match[2]; + if (!open) { + open = { markerChar: marker[0], markerLen: marker.length }; + continue; + } + if (open.markerChar === marker[0] && marker.length >= open.markerLen) { + open = null; + } + } + return open === null; +} + +describe("chunkDiscordText", () => { + it("splits tall messages even when under 2000 chars", () => { + const text = Array.from({ length: 45 }, (_, i) => `line-${i + 1}`).join( + "\n", + ); + expect(text.length).toBeLessThan(2000); + + const chunks = chunkDiscordText(text, { maxChars: 2000, maxLines: 20 }); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(countLines(chunk)).toBeLessThanOrEqual(20); + } + }); + + it("keeps fenced code blocks balanced across chunks", () => { + const body = Array.from( + { length: 30 }, + (_, i) => `console.log(${i});`, + ).join("\n"); + const text = `Here is code:\n\n\`\`\`js\n${body}\n\`\`\`\n\nDone.`; + + const chunks = chunkDiscordText(text, { maxChars: 2000, maxLines: 10 }); + expect(chunks.length).toBeGreaterThan(1); + + for (const chunk of chunks) { + expect(hasBalancedFences(chunk)).toBe(true); + expect(chunk.length).toBeLessThanOrEqual(2000); + } + + expect(chunks[0]).toContain("```js"); + expect(chunks.at(-1)).toContain("Done."); + }); + + it("reserves space for closing fences when chunking", () => { + const body = "a".repeat(120); + const text = `\`\`\`txt\n${body}\n\`\`\``; + + const chunks = chunkDiscordText(text, { maxChars: 50, maxLines: 50 }); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(50); + expect(hasBalancedFences(chunk)).toBe(true); + } + }); +}); diff --git a/src/discord/chunk.ts b/src/discord/chunk.ts new file mode 100644 index 000000000..372704c2f --- /dev/null +++ b/src/discord/chunk.ts @@ -0,0 +1,191 @@ +export type ChunkDiscordTextOpts = { + /** Max characters per Discord message. Default: 2000. */ + maxChars?: number; + /** + * Soft max line count per message. Default: 17. + * + * Discord clients can clip/collapse very tall messages in the UI; splitting + * by lines keeps long multi-paragraph replies readable. + */ + maxLines?: number; +}; + +type OpenFence = { + indent: string; + markerChar: string; + markerLen: number; + openLine: string; +}; + +const DEFAULT_MAX_CHARS = 2000; +const DEFAULT_MAX_LINES = 17; +const FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/; + +function countLines(text: string) { + if (!text) return 0; + return text.split("\n").length; +} + +function parseFenceLine(line: string): OpenFence | null { + const match = line.match(FENCE_RE); + if (!match) return null; + const indent = match[1] ?? ""; + const marker = match[2] ?? ""; + return { + indent, + markerChar: marker[0] ?? "`", + markerLen: marker.length, + openLine: line, + }; +} + +function closeFenceLine(openFence: OpenFence) { + return `${openFence.indent}${openFence.markerChar.repeat( + openFence.markerLen, + )}`; +} + +function closeFenceIfNeeded(text: string, openFence: OpenFence | null) { + if (!openFence) return text; + const closeLine = closeFenceLine(openFence); + if (!text) return closeLine; + if (!text.endsWith("\n")) return `${text}\n${closeLine}`; + return `${text}${closeLine}`; +} + +function splitLongLine( + line: string, + maxChars: number, + opts: { preserveWhitespace: boolean }, +): string[] { + const limit = Math.max(1, Math.floor(maxChars)); + if (line.length <= limit) return [line]; + const out: string[] = []; + let remaining = line; + while (remaining.length > limit) { + if (opts.preserveWhitespace) { + out.push(remaining.slice(0, limit)); + remaining = remaining.slice(limit); + continue; + } + const window = remaining.slice(0, limit); + let breakIdx = -1; + for (let i = window.length - 1; i >= 0; i--) { + if (/\s/.test(window[i])) { + breakIdx = i; + break; + } + } + if (breakIdx <= 0) breakIdx = limit; + out.push(remaining.slice(0, breakIdx)); + const brokeOnSeparator = + breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); + remaining = remaining.slice(breakIdx + (brokeOnSeparator ? 1 : 0)); + } + if (remaining.length) out.push(remaining); + return out; +} + +/** + * Chunks outbound Discord text by both character count and (soft) line count, + * while keeping fenced code blocks balanced across chunks. + */ +export function chunkDiscordText( + text: string, + opts: ChunkDiscordTextOpts = {}, +): string[] { + const maxChars = Math.max(1, Math.floor(opts.maxChars ?? DEFAULT_MAX_CHARS)); + const maxLines = Math.max(1, Math.floor(opts.maxLines ?? DEFAULT_MAX_LINES)); + + const body = text ?? ""; + if (!body) return []; + + const alreadyOk = body.length <= maxChars && countLines(body) <= maxLines; + if (alreadyOk) return [body]; + + const lines = body.split("\n"); + const chunks: string[] = []; + + let current = ""; + let currentLines = 0; + let openFence: OpenFence | null = null; + + const flush = () => { + if (!current) return; + const payload = closeFenceIfNeeded(current, openFence); + if (payload.trim().length) chunks.push(payload); + current = ""; + currentLines = 0; + if (openFence) { + current = openFence.openLine; + currentLines = 1; + } + }; + + for (const originalLine of lines) { + const fenceInfo = parseFenceLine(originalLine); + const wasInsideFence = openFence !== null; + let nextOpenFence: OpenFence | null = openFence; + if (fenceInfo) { + if (!openFence) { + nextOpenFence = fenceInfo; + } else if ( + openFence.markerChar === fenceInfo.markerChar && + fenceInfo.markerLen >= openFence.markerLen + ) { + nextOpenFence = null; + } + } + + const reserveChars = nextOpenFence + ? closeFenceLine(nextOpenFence).length + 1 + : 0; + const reserveLines = nextOpenFence ? 1 : 0; + const effectiveMaxChars = maxChars - reserveChars; + const effectiveMaxLines = maxLines - reserveLines; + const charLimit = effectiveMaxChars > 0 ? effectiveMaxChars : maxChars; + const lineLimit = effectiveMaxLines > 0 ? effectiveMaxLines : maxLines; + const prefixLen = current.length > 0 ? current.length + 1 : 0; + const segmentLimit = Math.max(1, charLimit - prefixLen); + const segments = splitLongLine(originalLine, segmentLimit, { + preserveWhitespace: wasInsideFence, + }); + + for (let segIndex = 0; segIndex < segments.length; segIndex++) { + const segment = segments[segIndex]; + const isLineContinuation = segIndex > 0; + const delimiter = isLineContinuation + ? "" + : current.length > 0 + ? "\n" + : ""; + const addition = `${delimiter}${segment}`; + const nextLen = current.length + addition.length; + const nextLines = currentLines + (isLineContinuation ? 0 : 1); + + const wouldExceedChars = nextLen > charLimit; + const wouldExceedLines = nextLines > lineLimit; + + if ((wouldExceedChars || wouldExceedLines) && current.length > 0) { + flush(); + } + + if (current.length > 0) { + current += addition; + if (!isLineContinuation) currentLines += 1; + } else { + current = segment; + currentLines = 1; + } + } + + openFence = nextOpenFence; + } + + if (current.length) { + const payload = closeFenceIfNeeded(current, openFence); + if (payload.trim().length) chunks.push(payload); + } + + return chunks; +} diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index ca6b2d254..6bc63a1c4 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -17,10 +17,7 @@ import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"; import type { APIAttachment } from "discord-api-types/v10"; import { ApplicationCommandOptionType, Routes } from "discord-api-types/v10"; -import { - chunkMarkdownText, - resolveTextChunkLimit, -} from "../auto-reply/chunk.js"; +import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { buildCommandText, @@ -63,6 +60,7 @@ import { resolveThreadSessionKeys } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { loadWebMedia } from "../web/media.js"; import { resolveDiscordAccount } from "./accounts.js"; +import { chunkDiscordText } from "./chunk.js"; import { fetchDiscordApplicationId } from "./probe.js"; import { reactMessageDiscord, sendMessageDiscord } from "./send.js"; import { normalizeDiscordToken } from "./token.js"; @@ -1009,6 +1007,7 @@ export function createDiscordMessageHandler(params: { runtime, replyToMode, textLimit, + maxLinesPerMessage: discordConfig?.maxLinesPerMessage, }); didSendReply = true; }, @@ -1485,7 +1484,8 @@ function createDiscordNativeCommand(params: { await deliverDiscordInteractionReply({ interaction, payload, - textLimit: resolveTextChunkLimit(cfg, "discord"), + textLimit: resolveTextChunkLimit(cfg, "discord", accountId), + maxLinesPerMessage: discordConfig?.maxLinesPerMessage, preferFollowUp: didReply, }); didReply = true; @@ -1517,13 +1517,21 @@ async function deliverDiscordInteractionReply(params: { interaction: CommandInteraction; payload: ReplyPayload; textLimit: number; + maxLinesPerMessage?: number; preferFollowUp: boolean; }) { - const { interaction, payload, textLimit, preferFollowUp } = params; + const { + interaction, + payload, + textLimit, + maxLinesPerMessage, + preferFollowUp, + } = params; const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const text = payload.text ?? ""; + let hasReplied = false; const sendMessage = async ( content: string, files?: { name: string; data: Buffer }[], @@ -1541,11 +1549,13 @@ async function deliverDiscordInteractionReply(params: { }), } : { content }; - if (!preferFollowUp) { + if (!preferFollowUp && !hasReplied) { await interaction.reply(payload); + hasReplied = true; return; } await interaction.followUp(payload); + hasReplied = true; }; if (mediaList.length > 0) { @@ -1558,21 +1568,26 @@ async function deliverDiscordInteractionReply(params: { }; }), ); - const caption = text.length > textLimit ? text.slice(0, textLimit) : text; + const chunks = chunkDiscordText(text, { + maxChars: textLimit, + maxLines: maxLinesPerMessage, + }); + const caption = chunks[0] ?? ""; await sendMessage(caption, media); - if (text.length > textLimit) { - const remaining = text.slice(textLimit).trim(); - if (remaining) { - for (const chunk of chunkMarkdownText(remaining, textLimit)) { - await interaction.followUp({ content: chunk }); - } - } + for (const chunk of chunks.slice(1)) { + if (!chunk.trim()) continue; + await interaction.followUp({ content: chunk }); } return; } if (!text.trim()) return; - for (const chunk of chunkMarkdownText(text, textLimit)) { + const chunks = chunkDiscordText(text, { + maxChars: textLimit, + maxLines: maxLinesPerMessage, + }); + for (const chunk of chunks) { + if (!chunk.trim()) continue; await sendMessage(chunk); } } @@ -1585,6 +1600,7 @@ async function deliverDiscordReply(params: { rest?: RequestClient; runtime: RuntimeEnv; textLimit: number; + maxLinesPerMessage?: number; replyToMode: ReplyToMode; }) { const chunkLimit = Math.min(params.textLimit, 2000); @@ -1595,7 +1611,10 @@ async function deliverDiscordReply(params: { if (!text && mediaList.length === 0) continue; if (mediaList.length === 0) { - for (const chunk of chunkMarkdownText(text, chunkLimit)) { + for (const chunk of chunkDiscordText(text, { + maxChars: chunkLimit, + maxLines: params.maxLinesPerMessage, + })) { const trimmed = chunk.trim(); if (!trimmed) continue; await sendMessageDiscord(params.target, trimmed, { diff --git a/src/discord/send.ts b/src/discord/send.ts index 979f90547..063b80937 100644 --- a/src/discord/send.ts +++ b/src/discord/send.ts @@ -17,7 +17,6 @@ import { Routes, } from "discord-api-types/v10"; -import { chunkMarkdownText } from "../auto-reply/chunk.js"; import { loadConfig } from "../config/config.js"; import type { RetryConfig } from "../infra/retry.js"; import { @@ -31,6 +30,7 @@ import { } from "../polls.js"; import { loadWebMedia, loadWebMediaRaw } from "../web/media.js"; import { resolveDiscordAccount } from "./accounts.js"; +import { chunkDiscordText } from "./chunk.js"; import { normalizeDiscordToken } from "./token.js"; const DISCORD_TEXT_LIMIT = 2000; @@ -425,6 +425,7 @@ async function sendDiscordText( text: string, replyTo: string | undefined, request: DiscordRequest, + maxLinesPerMessage?: number, ) { if (!text.trim()) { throw new Error("Message must be non-empty for Discord sends"); @@ -432,17 +433,20 @@ async function sendDiscordText( const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined; - if (text.length <= DISCORD_TEXT_LIMIT) { + const chunks = chunkDiscordText(text, { + maxChars: DISCORD_TEXT_LIMIT, + maxLines: maxLinesPerMessage, + }); + if (chunks.length === 1) { const res = (await request( () => rest.post(Routes.channelMessages(channelId), { - body: { content: text, message_reference: messageReference }, + body: { content: chunks[0], message_reference: messageReference }, }) as Promise<{ id: string; channel_id: string }>, "text", )) as { id: string; channel_id: string }; return res; } - const chunks = chunkMarkdownText(text, DISCORD_TEXT_LIMIT); let last: { id: string; channel_id: string } | null = null; let isFirst = true; for (const chunk of chunks) { @@ -471,10 +475,16 @@ async function sendDiscordMedia( mediaUrl: string, replyTo: string | undefined, request: DiscordRequest, + maxLinesPerMessage?: number, ) { const media = await loadWebMedia(mediaUrl); - const caption = - text.length > DISCORD_TEXT_LIMIT ? text.slice(0, DISCORD_TEXT_LIMIT) : text; + const chunks = text + ? chunkDiscordText(text, { + maxChars: DISCORD_TEXT_LIMIT, + maxLines: maxLinesPerMessage, + }) + : []; + const caption = chunks[0] ?? ""; const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined; @@ -494,11 +504,16 @@ async function sendDiscordMedia( }) as Promise<{ id: string; channel_id: string }>, "media", )) as { id: string; channel_id: string }; - if (text.length > DISCORD_TEXT_LIMIT) { - const remaining = text.slice(DISCORD_TEXT_LIMIT).trim(); - if (remaining) { - await sendDiscordText(rest, channelId, remaining, undefined, request); - } + for (const chunk of chunks.slice(1)) { + if (!chunk.trim()) continue; + await sendDiscordText( + rest, + channelId, + chunk, + undefined, + request, + maxLinesPerMessage, + ); } return res; } @@ -534,6 +549,10 @@ export async function sendMessageDiscord( opts: DiscordSendOpts = {}, ): Promise { const cfg = loadConfig(); + const accountInfo = resolveDiscordAccount({ + cfg, + accountId: opts.accountId, + }); const { token, rest, request } = createDiscordClient(opts, cfg); const recipient = parseRecipient(to); const { channelId } = await resolveChannelId(rest, recipient, request); @@ -549,6 +568,7 @@ export async function sendMessageDiscord( opts.mediaUrl, opts.replyTo, request, + accountInfo.config.maxLinesPerMessage, ); } else { result = await sendDiscordText( @@ -557,6 +577,7 @@ export async function sendMessageDiscord( text, opts.replyTo, request, + accountInfo.config.maxLinesPerMessage, ); } } catch (err) {