Skip to content

Commit 596fa99

Browse files
committed
discord: chunk outbound messages by chars+lines
Prevents Discord client clipping by splitting tall replies; adds discord.maxLinesPerMessage.
1 parent 50d4b17 commit 596fa99

File tree

7 files changed

+200
-7
lines changed

7 files changed

+200
-7
lines changed

src/config/config.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,11 @@ describe("config identity defaults", () => {
190190
routing: {},
191191
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },
192192
telegram: { enabled: true, textChunkLimit: 3333 },
193-
discord: { enabled: true, textChunkLimit: 1999 },
193+
discord: {
194+
enabled: true,
195+
textChunkLimit: 1999,
196+
maxLinesPerMessage: 17,
197+
},
194198
signal: { enabled: true, textChunkLimit: 2222 },
195199
imessage: { enabled: true, textChunkLimit: 1111 },
196200
},
@@ -207,6 +211,7 @@ describe("config identity defaults", () => {
207211
expect(cfg.whatsapp?.textChunkLimit).toBe(4444);
208212
expect(cfg.telegram?.textChunkLimit).toBe(3333);
209213
expect(cfg.discord?.textChunkLimit).toBe(1999);
214+
expect(cfg.discord?.maxLinesPerMessage).toBe(17);
210215
expect(cfg.signal?.textChunkLimit).toBe(2222);
211216
expect(cfg.imessage?.textChunkLimit).toBe(1111);
212217

src/config/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,12 @@ export type DiscordConfig = {
344344
groupPolicy?: GroupPolicy;
345345
/** Outbound text chunk size (chars). Default: 2000. */
346346
textChunkLimit?: number;
347+
/**
348+
* Soft max line count per Discord message.
349+
* Discord clients can clip/collapse very tall messages; splitting by lines
350+
* keeps replies readable in-channel.
351+
*/
352+
maxLinesPerMessage?: number;
347353
mediaMaxMb?: number;
348354
historyLimit?: number;
349355
/** Per-action tool gating (default: true for all). */

src/config/zod-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,7 @@ export const ClawdbotSchema = z.object({
786786
token: z.string().optional(),
787787
groupPolicy: GroupPolicySchema.optional().default("open"),
788788
textChunkLimit: z.number().int().positive().optional(),
789+
maxLinesPerMessage: z.number().int().positive().optional(),
789790
slashCommand: z
790791
.object({
791792
enabled: z.boolean().optional(),

src/discord/chunk.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { chunkDiscordText } from "./chunk.js";
4+
5+
function countLines(text: string) {
6+
return text.split("\n").length;
7+
}
8+
9+
function hasBalancedFences(chunk: string) {
10+
let open = false;
11+
for (const line of chunk.split("\n")) {
12+
if (line.trim().startsWith("```")) open = !open;
13+
}
14+
return open === false;
15+
}
16+
17+
describe("chunkDiscordText", () => {
18+
it("splits tall messages even when under 2000 chars", () => {
19+
const text = Array.from({ length: 45 }, (_, i) => `line-${i + 1}`).join(
20+
"\n",
21+
);
22+
expect(text.length).toBeLessThan(2000);
23+
24+
const chunks = chunkDiscordText(text, { maxChars: 2000, maxLines: 20 });
25+
expect(chunks.length).toBeGreaterThan(1);
26+
for (const chunk of chunks) {
27+
expect(countLines(chunk)).toBeLessThanOrEqual(20);
28+
}
29+
});
30+
31+
it("keeps fenced code blocks balanced across chunks", () => {
32+
const body = Array.from(
33+
{ length: 30 },
34+
(_, i) => `console.log(${i});`,
35+
).join("\n");
36+
const text = `Here is code:\n\n\`\`\`js\n${body}\n\`\`\`\n\nDone.`;
37+
38+
const chunks = chunkDiscordText(text, { maxChars: 2000, maxLines: 10 });
39+
expect(chunks.length).toBeGreaterThan(1);
40+
41+
for (const chunk of chunks) {
42+
expect(hasBalancedFences(chunk)).toBe(true);
43+
expect(chunk.length).toBeLessThanOrEqual(2000);
44+
}
45+
46+
expect(chunks[0]).toContain("```js");
47+
expect(chunks.at(-1)).toContain("Done.");
48+
});
49+
});

src/discord/chunk.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
export type ChunkDiscordTextOpts = {
2+
/** Max characters per Discord message. Default: 2000. */
3+
maxChars?: number;
4+
/**
5+
* Soft max line count per message.
6+
*
7+
* Discord clients can "clip"/collapse very tall messages in the UI; splitting
8+
* by lines keeps long multi-paragraph replies readable.
9+
*/
10+
maxLines?: number;
11+
};
12+
13+
const DEFAULT_MAX_CHARS = 2000;
14+
const DEFAULT_MAX_LINES = 20;
15+
16+
function countLines(text: string) {
17+
if (!text) return 0;
18+
return text.split("\n").length;
19+
}
20+
21+
function isFenceLine(line: string) {
22+
return line.trim().startsWith("```");
23+
}
24+
25+
function splitLongLine(line: string, maxChars: number): string[] {
26+
if (line.length <= maxChars) return [line];
27+
const out: string[] = [];
28+
let remaining = line;
29+
while (remaining.length > maxChars) {
30+
out.push(remaining.slice(0, maxChars));
31+
remaining = remaining.slice(maxChars);
32+
}
33+
if (remaining.length) out.push(remaining);
34+
return out;
35+
}
36+
37+
function closeFenceIfNeeded(text: string, fenceOpen: string | null) {
38+
if (!fenceOpen) return text;
39+
if (!text.endsWith("\n")) return `${text}\n\`\`\``;
40+
return `${text}\`\`\``;
41+
}
42+
43+
/**
44+
* Chunks outbound Discord text by both character count and (soft) line count,
45+
* while keeping fenced code blocks balanced across chunks.
46+
*/
47+
export function chunkDiscordText(
48+
text: string,
49+
opts: ChunkDiscordTextOpts = {},
50+
): string[] {
51+
const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
52+
const maxLines = opts.maxLines ?? DEFAULT_MAX_LINES;
53+
54+
const trimmed = text ?? "";
55+
if (!trimmed) return [];
56+
57+
const alreadyOk =
58+
trimmed.length <= maxChars && countLines(trimmed) <= maxLines;
59+
if (alreadyOk) return [trimmed];
60+
61+
const lines = trimmed.split("\n");
62+
const chunks: string[] = [];
63+
64+
let current = "";
65+
let currentLines = 0;
66+
let openFence: string | null = null;
67+
68+
const flush = () => {
69+
const payload = closeFenceIfNeeded(current, openFence);
70+
if (payload.trim().length) chunks.push(payload);
71+
current = "";
72+
currentLines = 0;
73+
if (openFence) {
74+
current = openFence;
75+
currentLines = 1;
76+
}
77+
};
78+
79+
for (const originalLine of lines) {
80+
if (isFenceLine(originalLine)) {
81+
openFence = openFence ? null : originalLine;
82+
}
83+
84+
const segments = splitLongLine(originalLine, maxChars);
85+
for (let segIndex = 0; segIndex < segments.length; segIndex++) {
86+
const segment = segments[segIndex];
87+
const isLineContinuation = segIndex > 0;
88+
const delimiter = isLineContinuation
89+
? ""
90+
: current.length > 0
91+
? "\n"
92+
: "";
93+
const addition = `${delimiter}${segment}`;
94+
const nextLen = current.length + addition.length;
95+
const nextLines = currentLines + (isLineContinuation ? 0 : 1);
96+
97+
const wouldExceedChars = nextLen > maxChars;
98+
const wouldExceedLines = nextLines > maxLines;
99+
100+
if ((wouldExceedChars || wouldExceedLines) && current.length > 0) {
101+
flush();
102+
}
103+
104+
if (current.length > 0) {
105+
current += addition;
106+
if (!isLineContinuation) currentLines += 1;
107+
} else {
108+
current = segment;
109+
currentLines = 1;
110+
}
111+
}
112+
}
113+
114+
if (current.length) {
115+
const payload = closeFenceIfNeeded(current, openFence);
116+
if (payload.trim().length) chunks.push(payload);
117+
}
118+
119+
return chunks;
120+
}

src/discord/monitor.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
type PartialUser,
1616
type User,
1717
} from "discord.js";
18-
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
18+
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
1919
import { hasControlCommand } from "../auto-reply/command-detection.js";
2020
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
2121
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
@@ -46,6 +46,7 @@ import {
4646
upsertProviderPairingRequest,
4747
} from "../pairing/pairing-store.js";
4848
import type { RuntimeEnv } from "../runtime.js";
49+
import { chunkDiscordText } from "./chunk.js";
4950
import { sendMessageDiscord } from "./send.js";
5051
import { normalizeDiscordToken } from "./token.js";
5152

@@ -646,6 +647,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
646647
runtime,
647648
replyToMode,
648649
textLimit,
650+
maxLinesPerMessage: cfg.discord?.maxLinesPerMessage,
649651
});
650652
didSendReply = true;
651653
},
@@ -1287,13 +1289,15 @@ async function deliverReplies({
12871289
runtime,
12881290
replyToMode,
12891291
textLimit,
1292+
maxLinesPerMessage,
12901293
}: {
12911294
replies: ReplyPayload[];
12921295
target: string;
12931296
token: string;
12941297
runtime: RuntimeEnv;
12951298
replyToMode: ReplyToMode;
12961299
textLimit: number;
1300+
maxLinesPerMessage?: number;
12971301
}) {
12981302
let hasReplied = false;
12991303
const chunkLimit = Math.min(textLimit, 2000);
@@ -1304,7 +1308,10 @@ async function deliverReplies({
13041308
const replyToId = payload.replyToId;
13051309
if (!text && mediaList.length === 0) continue;
13061310
if (mediaList.length === 0) {
1307-
for (const chunk of chunkText(text, chunkLimit)) {
1311+
for (const chunk of chunkDiscordText(text, {
1312+
maxChars: chunkLimit,
1313+
maxLines: maxLinesPerMessage,
1314+
})) {
13081315
const replyTo = resolveDiscordReplyTarget({
13091316
replyToMode,
13101317
replyToId,

src/discord/send.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ import type {
1212
RESTPostAPIGuildScheduledEventJSONBody,
1313
} from "discord-api-types/v10";
1414

15-
import { chunkText } from "../auto-reply/chunk.js";
1615
import { loadConfig } from "../config/config.js";
1716
import {
1817
normalizePollDurationHours,
1918
normalizePollInput,
2019
type PollInput,
2120
} from "../polls.js";
2221
import { loadWebMedia, loadWebMediaRaw } from "../web/media.js";
22+
import { chunkDiscordText } from "./chunk.js";
2323
import { normalizeDiscordToken } from "./token.js";
2424

2525
const DISCORD_TEXT_LIMIT = 2000;
@@ -354,13 +354,18 @@ async function sendDiscordText(
354354
const messageReference = replyTo
355355
? { message_id: replyTo, fail_if_not_exists: false }
356356
: undefined;
357-
if (text.length <= DISCORD_TEXT_LIMIT) {
357+
const maxLines = loadConfig().discord?.maxLinesPerMessage;
358+
const chunks = chunkDiscordText(text, {
359+
maxChars: DISCORD_TEXT_LIMIT,
360+
maxLines,
361+
});
362+
if (chunks.length === 1) {
358363
const res = (await rest.post(Routes.channelMessages(channelId), {
359-
body: { content: text, message_reference: messageReference },
364+
body: { content: chunks[0], message_reference: messageReference },
360365
})) as { id: string; channel_id: string };
361366
return res;
362367
}
363-
const chunks = chunkText(text, DISCORD_TEXT_LIMIT);
368+
364369
let last: { id: string; channel_id: string } | null = null;
365370
let isFirst = true;
366371
for (const chunk of chunks) {

0 commit comments

Comments
 (0)