discord: chunk outbound messages by chars+lines
Prevents Discord client clipping by splitting tall replies; adds discord.maxLinesPerMessage.
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
49
src/discord/chunk.test.ts
Normal 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
120
src/discord/chunk.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user