discord: chunk outbound messages by chars+lines

Prevents Discord client clipping by splitting tall replies; adds discord.maxLinesPerMessage.
This commit is contained in:
Jonathan D. Rhyne
2026-01-07 02:22:05 -05:00
parent 50d4b17417
commit 596fa99f02
7 changed files with 200 additions and 7 deletions

View File

@@ -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);

View File

@@ -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). */

View File

@@ -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(),

49
src/discord/chunk.test.ts Normal file
View File

@@ -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.");
});
});

120
src/discord/chunk.ts Normal file
View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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) {