Files
openclaw/extensions/discord/src/chunk.ts
2026-07-28 15:49:09 -04:00

411 lines
14 KiB
TypeScript

// Discord plugin module implements chunk behavior.
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime";
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
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 REASONING_ITALICS_MARKER_CHARS = 2;
const MIN_REASONING_ITALICS_CHUNK_CHARS = 4;
const FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
function hasReasoningItalics(text: string): boolean {
return /^(?:Reasoning:|Thinking\.{0,3})\n+_/u.test(text) && text.trimEnd().endsWith("_");
}
function resolveDiscordChunkLimit(value: unknown, fallback: number) {
return resolveIntegerOption(value, fallback, { min: 1 });
}
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 canBalanceFence(openFence: OpenFence, maxChars: number) {
const markerLength = closeFenceLine(openFence).length;
return markerLength * 2 + 3 <= maxChars;
}
// Continuation chunks reopen the fence so Discord keeps rendering the code block. Prefer the full
// opening line (keeps the language for highlighting); degrade to a bare marker when it would not
// leave room for the closing marker plus at least one delimiter+char of body. When even the bare
// pair cannot fit, preserve the hard transport limit and emit the continuation without synthetic
// fences; the original fence text is still retained in its own chunks.
function reopenFenceLine(openFence: OpenFence, maxChars: number) {
const bareMarker = closeFenceLine(openFence);
if (!canBalanceFence(openFence, maxChars)) {
return null;
}
// openLine + closing marker (bareMarker + newline) + one delimiter + one body char must all fit.
if (openFence.openLine.length + bareMarker.length + 3 <= maxChars) {
return openFence.openLine;
}
return bareMarker;
}
function closeFenceIfNeeded(text: string, openFence: OpenFence | null, maxChars: number) {
if (!openFence || !canBalanceFence(openFence, maxChars)) {
return text;
}
const closeLine = closeFenceLine(openFence);
if (!text) {
return closeLine;
}
if (!text.endsWith("\n")) {
return `${text}\n${closeLine}`;
}
return `${text}${closeLine}`;
}
/**
* Chunks outbound Discord text by both character count and (soft) line count,
* while keeping fenced code blocks balanced across chunks.
*/
function chunkDiscordText(text: string, opts: ChunkDiscordTextOpts = {}): string[] {
const hardMaxChars = resolveDiscordChunkLimit(opts.maxChars, DEFAULT_MAX_CHARS);
const maxLines = resolveDiscordChunkLimit(opts.maxLines, DEFAULT_MAX_LINES);
const body = text ?? "";
if (!body) {
return [];
}
const alreadyOk = body.length <= hardMaxChars && countLines(body) <= maxLines;
if (alreadyOk) {
return [body];
}
// Reasoning rebalancing can add an opening and closing marker to each chunk.
// Reserve both before splitting so the rendered payload still fits Discord.
const maxChars =
hardMaxChars >= MIN_REASONING_ITALICS_CHUNK_CHARS && hasReasoningItalics(body)
? hardMaxChars - REASONING_ITALICS_MARKER_CHARS
: hardMaxChars;
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, maxChars);
if (payload.trim().length) {
chunks.push(payload);
}
current = "";
currentLines = 0;
if (openFence) {
const reopenLine = reopenFenceLine(openFence, maxChars);
if (reopenLine) {
current = reopenLine;
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;
}
}
// A flush can fire mid-line, before `openFence` advances to `nextOpenFence` below, so it closes
// against the still-open `openFence`. A fence-closing line that also carries trailing text would
// otherwise reserve 0 yet still get a closing fence appended on flush, overflowing maxChars.
const candidateFence = nextOpenFence ?? openFence;
const fenceToReserve =
candidateFence && canBalanceFence(candidateFence, maxChars) ? candidateFence : null;
const reserveChars = fenceToReserve ? closeFenceLine(fenceToReserve).length + 1 : 0;
const reserveLines = fenceToReserve ? 1 : 0;
const effectiveMaxChars = maxChars - reserveChars;
const effectiveMaxLines = maxLines - reserveLines;
const charLimit = effectiveMaxChars > 0 ? effectiveMaxChars : maxChars;
const lineLimit = effectiveMaxLines > 0 ? effectiveMaxLines : maxLines;
const reopenPrefixLen = fenceToReserve
? (reopenFenceLine(fenceToReserve, maxChars)?.length ?? 0)
: 0;
const prefixLen = current.length > 0 ? current.length + 1 : 0;
// A mid-line flush swaps `current` to the reopen prefix; size segments against whichever prefix
// is larger so the reopened chunk (prefix + segment + closing marker) still fits maxChars.
const reopenBudget = reopenPrefixLen > 0 ? reopenPrefixLen + 1 : 0;
const segmentLimit = Math.max(1, charLimit - Math.max(prefixLen, reopenBudget));
const segments = chunkTextForOutbound(originalLine, segmentLimit, {
preserveWhitespace: wasInsideFence,
});
for (let segIndex = 0; segIndex < segments.length; segIndex++) {
const segment = segments[segIndex];
const isLineContinuation = segIndex > 0;
let delimiter = isLineContinuation ? "" : current.length > 0 ? "\n" : "";
let 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();
// A fence-aware flush reopens the block as the new first line. Continuation text must
// start on the next line or Discord interprets it as part of the fence info string.
delimiter = current.length > 0 ? "\n" : "";
addition = `${delimiter}${segment}`;
}
if (current.length > 0) {
current += addition;
if (!isLineContinuation || delimiter) {
currentLines += 1;
}
} else {
current = expectDefined(segment, "current Discord chunk segment");
currentLines = 1;
}
}
openFence = nextOpenFence;
}
if (current.length) {
const payload = closeFenceIfNeeded(current, openFence, maxChars);
if (payload.trim().length) {
chunks.push(payload);
}
}
return rebalanceReasoningItalics(text, chunks, hardMaxChars);
}
export function chunkDiscordTextWithMode(
text: string,
opts: ChunkDiscordTextOpts & { chunkMode?: ChunkMode },
): string[] {
const chunkMode = opts.chunkMode ?? "length";
if (chunkMode !== "newline") {
return chunkDiscordText(text, opts);
}
const lineChunks = chunkMarkdownTextWithMode(
text,
resolveDiscordChunkLimit(opts.maxChars, DEFAULT_MAX_CHARS),
"newline",
);
const chunks: string[] = [];
for (const line of lineChunks) {
const nested = chunkDiscordText(line, opts);
if (!nested.length && line) {
chunks.push(line);
continue;
}
chunks.push(...nested);
}
return chunks;
}
// Find the end of a leading fenced or inline code span. This deliberately reuses the chunker's
// fence grammar so italics balancing cannot disagree about indentation, marker type, or length.
function leadingCodeSpanEnd(body: string): number {
if (!body) {
return -1;
}
const firstNewline = body.indexOf("\n");
const firstLine = firstNewline === -1 ? body : body.slice(0, firstNewline);
const openFence = parseFenceLine(firstLine);
if (openFence) {
if (firstNewline === -1) {
return body.length;
}
let lineStart = firstNewline + 1;
while (lineStart <= body.length) {
const lineEnd = body.indexOf("\n", lineStart);
const line = lineEnd === -1 ? body.slice(lineStart) : body.slice(lineStart, lineEnd);
const closeFence = parseFenceLine(line);
const closeSuffix = closeFence
? line.slice(closeFence.indent.length + closeFence.markerLen)
: "";
if (
closeFence?.markerChar === openFence.markerChar &&
closeFence.markerLen >= openFence.markerLen &&
/^[ \t]*_?[ \t]*$/u.test(closeSuffix)
) {
const markerEnd = closeFence.indent.length + closeFence.markerLen;
const trailingSpaces = /^ */.exec(line.slice(markerEnd))?.[0].length ?? 0;
return lineStart + markerEnd + trailingSpaces;
}
if (lineEnd === -1) {
return body.length;
}
lineStart = lineEnd + 1;
}
return body.length;
}
if (!body.startsWith("`")) {
return -1;
}
const ticks = /^(?<ticks>`+)/.exec(body)?.groups?.ticks;
if (!ticks) {
return -1;
}
for (let index = ticks.length; index < body.length;) {
if (body[index] !== "`") {
index += 1;
continue;
}
let runEnd = index + 1;
while (body[runEnd] === "`") {
runEnd += 1;
}
if (runEnd - index === ticks.length) {
return runEnd;
}
index = runEnd;
}
return -1;
}
function leadingCodePrefixEnd(body: string): number {
let prefixEnd = leadingCodeSpanEnd(body);
if (prefixEnd < 0) {
return -1;
}
while (prefixEnd < body.length) {
const separator = /^\s+/u.exec(body.slice(prefixEnd))?.[0] ?? "";
if (!separator) {
break;
}
const nextStart = prefixEnd + separator.length;
const nextEnd = leadingCodeSpanEnd(body.slice(nextStart));
if (nextEnd < 0) {
break;
}
prefixEnd = nextStart + nextEnd;
}
return prefixEnd;
}
function hasReasoningItalicsOpen(chunk: string): boolean {
const trimmed = chunk.trimStart();
if (trimmed.startsWith("_") || /^(?:Reasoning:|Thinking\.{0,3})\n+_/u.test(trimmed)) {
return true;
}
const codeEnd = leadingCodePrefixEnd(trimmed);
return codeEnd >= 0 && trimmed.slice(codeEnd).trimStart().startsWith("_");
}
// Keep a leading code delimiter untouched; reopen reasoning italics only after its code span.
function reopenReasoningItalicsAfterLeadingCode(body: string, codeEnd: number): string {
const code = body.slice(0, codeEnd);
const rest = body.slice(codeEnd);
if (!rest.trim()) {
return code + rest;
}
if (/^\s*_\s*$/.test(rest)) {
return code;
}
const whitespaceLength = rest.length - rest.trimStart().length;
const whitespace = rest.slice(0, whitespaceLength);
const restBody = rest.slice(whitespaceLength);
return restBody.startsWith("_") ? code + rest : `${code}${whitespace}_${restBody}`;
}
// Keep italics intact for reasoning payloads that are wrapped once with `_…_`.
// When Discord chunking splits the message, we close italics at the end of
// each chunk and reopen at the start of the next. Code-leading continuations reopen after code.
function rebalanceReasoningItalics(source: string, chunks: string[], maxChars: number): string[] {
if (chunks.length <= 1 || maxChars < MIN_REASONING_ITALICS_CHUNK_CHARS) {
return chunks;
}
if (!hasReasoningItalics(source)) {
return chunks;
}
const adjusted = [...chunks];
for (let i = 0; i < adjusted.length; i++) {
const isLast = i === adjusted.length - 1;
const current = expectDefined(adjusted[i], "Discord chunk adjustment index");
// Pure-code continuations never open reasoning italics, so do not append an unmatched closer.
const needsClosing = !current.trimEnd().endsWith("_") && hasReasoningItalicsOpen(current);
if (needsClosing) {
adjusted[i] = `${current}_`;
}
if (isLast) {
break;
}
const next = expectDefined(adjusted[i + 1], "non-final Discord chunk successor");
const leadingWhitespaceLen = next.length - next.trimStart().length;
const leadingWhitespace = next.slice(0, leadingWhitespaceLen);
const nextBody = next.slice(leadingWhitespaceLen);
if (nextBody.startsWith("_")) {
continue;
}
const codeEnd = leadingCodePrefixEnd(nextBody);
adjusted[i + 1] =
codeEnd >= 0
? `${leadingWhitespace}${reopenReasoningItalicsAfterLeadingCode(nextBody, codeEnd)}`
: `${leadingWhitespace}_${nextBody}`;
}
return adjusted;
}