mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 03:51:25 +00:00
fix(discord): cap lines per message
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user