fix(discord): cap lines per message
This commit is contained in:
70
src/discord/chunk.test.ts
Normal file
70
src/discord/chunk.test.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
191
src/discord/chunk.ts
Normal file
191
src/discord/chunk.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<DiscordSendResult> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user