diff --git a/src/config/config.test.ts b/src/config/config.test.ts index f69ec69ae..cf18da1c2 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -190,7 +190,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 }, }, @@ -207,6 +211,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/types.ts b/src/config/types.ts index 7c6e70f08..35c2e8c28 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -344,6 +344,12 @@ export type DiscordConfig = { 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. + */ + maxLinesPerMessage?: number; mediaMaxMb?: number; historyLimit?: number; /** Per-action tool gating (default: true for all). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 061ff6445..2c9858549 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -786,6 +786,7 @@ export const ClawdbotSchema = z.object({ token: z.string().optional(), groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), + maxLinesPerMessage: z.number().int().positive().optional(), slashCommand: z .object({ enabled: z.boolean().optional(), diff --git a/src/discord/chunk.test.ts b/src/discord/chunk.test.ts new file mode 100644 index 000000000..b01dce286 --- /dev/null +++ b/src/discord/chunk.test.ts @@ -0,0 +1,49 @@ +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 = false; + for (const line of chunk.split("\n")) { + if (line.trim().startsWith("```")) open = !open; + } + return open === false; +} + +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."); + }); +}); diff --git a/src/discord/chunk.ts b/src/discord/chunk.ts new file mode 100644 index 000000000..e223c206c --- /dev/null +++ b/src/discord/chunk.ts @@ -0,0 +1,120 @@ +export type ChunkDiscordTextOpts = { + /** Max characters per Discord message. Default: 2000. */ + maxChars?: number; + /** + * Soft max line count per message. + * + * Discord clients can "clip"/collapse very tall messages in the UI; splitting + * by lines keeps long multi-paragraph replies readable. + */ + maxLines?: number; +}; + +const DEFAULT_MAX_CHARS = 2000; +const DEFAULT_MAX_LINES = 20; + +function countLines(text: string) { + if (!text) return 0; + return text.split("\n").length; +} + +function isFenceLine(line: string) { + return line.trim().startsWith("```"); +} + +function splitLongLine(line: string, maxChars: number): string[] { + if (line.length <= maxChars) return [line]; + const out: string[] = []; + let remaining = line; + while (remaining.length > maxChars) { + out.push(remaining.slice(0, maxChars)); + remaining = remaining.slice(maxChars); + } + if (remaining.length) out.push(remaining); + return out; +} + +function closeFenceIfNeeded(text: string, fenceOpen: string | null) { + if (!fenceOpen) return text; + if (!text.endsWith("\n")) return `${text}\n\`\`\``; + return `${text}\`\`\``; +} + +/** + * 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 = opts.maxChars ?? DEFAULT_MAX_CHARS; + const maxLines = opts.maxLines ?? DEFAULT_MAX_LINES; + + const trimmed = text ?? ""; + if (!trimmed) return []; + + const alreadyOk = + trimmed.length <= maxChars && countLines(trimmed) <= maxLines; + if (alreadyOk) return [trimmed]; + + const lines = trimmed.split("\n"); + const chunks: string[] = []; + + let current = ""; + let currentLines = 0; + let openFence: string | null = null; + + const flush = () => { + const payload = closeFenceIfNeeded(current, openFence); + if (payload.trim().length) chunks.push(payload); + current = ""; + currentLines = 0; + if (openFence) { + current = openFence; + currentLines = 1; + } + }; + + for (const originalLine of lines) { + if (isFenceLine(originalLine)) { + openFence = openFence ? null : originalLine; + } + + const segments = splitLongLine(originalLine, maxChars); + 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 > maxChars; + const wouldExceedLines = nextLines > maxLines; + + if ((wouldExceedChars || wouldExceedLines) && current.length > 0) { + flush(); + } + + if (current.length > 0) { + current += addition; + if (!isLineContinuation) currentLines += 1; + } else { + current = segment; + currentLines = 1; + } + } + } + + 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 f89ed13be..28b4fa206 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -15,7 +15,7 @@ import { type PartialUser, type User, } from "discord.js"; -import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; +import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; @@ -46,6 +46,7 @@ import { upsertProviderPairingRequest, } from "../pairing/pairing-store.js"; import type { RuntimeEnv } from "../runtime.js"; +import { chunkDiscordText } from "./chunk.js"; import { sendMessageDiscord } from "./send.js"; import { normalizeDiscordToken } from "./token.js"; @@ -646,6 +647,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { runtime, replyToMode, textLimit, + maxLinesPerMessage: cfg.discord?.maxLinesPerMessage, }); didSendReply = true; }, @@ -1287,6 +1289,7 @@ async function deliverReplies({ runtime, replyToMode, textLimit, + maxLinesPerMessage, }: { replies: ReplyPayload[]; target: string; @@ -1294,6 +1297,7 @@ async function deliverReplies({ runtime: RuntimeEnv; replyToMode: ReplyToMode; textLimit: number; + maxLinesPerMessage?: number; }) { let hasReplied = false; const chunkLimit = Math.min(textLimit, 2000); @@ -1304,7 +1308,10 @@ async function deliverReplies({ const replyToId = payload.replyToId; if (!text && mediaList.length === 0) continue; if (mediaList.length === 0) { - for (const chunk of chunkText(text, chunkLimit)) { + for (const chunk of chunkDiscordText(text, { + maxChars: chunkLimit, + maxLines: maxLinesPerMessage, + })) { const replyTo = resolveDiscordReplyTarget({ replyToMode, replyToId, diff --git a/src/discord/send.ts b/src/discord/send.ts index 7c58158fa..4004836ee 100644 --- a/src/discord/send.ts +++ b/src/discord/send.ts @@ -12,7 +12,6 @@ import type { RESTPostAPIGuildScheduledEventJSONBody, } from "discord-api-types/v10"; -import { chunkText } from "../auto-reply/chunk.js"; import { loadConfig } from "../config/config.js"; import { normalizePollDurationHours, @@ -20,6 +19,7 @@ import { type PollInput, } from "../polls.js"; import { loadWebMedia, loadWebMediaRaw } from "../web/media.js"; +import { chunkDiscordText } from "./chunk.js"; import { normalizeDiscordToken } from "./token.js"; const DISCORD_TEXT_LIMIT = 2000; @@ -354,13 +354,18 @@ async function sendDiscordText( const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined; - if (text.length <= DISCORD_TEXT_LIMIT) { + const maxLines = loadConfig().discord?.maxLinesPerMessage; + const chunks = chunkDiscordText(text, { + maxChars: DISCORD_TEXT_LIMIT, + maxLines, + }); + if (chunks.length === 1) { const res = (await rest.post(Routes.channelMessages(channelId), { - body: { content: text, message_reference: messageReference }, + body: { content: chunks[0], message_reference: messageReference }, })) as { id: string; channel_id: string }; return res; } - const chunks = chunkText(text, DISCORD_TEXT_LIMIT); + let last: { id: string; channel_id: string } | null = null; let isFirst = true; for (const chunk of chunks) {