Files
openclaw/extensions/discord/src/chunk.ts
Peter Steinberger 218dcd815a feat(tooling): enforce noUncheckedIndexedAccess in the extensions lane (NUIA phase 4) (#105132)
* fix(extensions): make indexed access explicit across channel plugins

Transport-payload-safe burn-down: malformed Telegram/Discord/QQ/LINE
and sibling channel input keeps existing skip paths; no synthesized
fields, no new throws in delivery loops. Zalo escape sentinels preserve
literal matches instead of undefined replacements.

* fix(extensions): make indexed access explicit across provider and memory plugins

Stream and model iteration, tool-block guards, capture guards, and
sparse accumulators; singleton model reads carry named invariants.

* fix(extensions): make indexed access explicit across tooling plugins, flip the extensions lane

Remaining plugins (oc-path, qa-lab, browser, logbook, and siblings) plus
the tsconfig.extensions.json flag flip. Cleanup: logbook sampleFrames
NaN index at max=1, QA retry clamp at non-positive attempts, dead Canvas
probe and OpenShell no-op slice removed, twitch test setup leak excluded
from the prod lane.

* refactor(plugin-sdk): expose expectDefined via a focused SDK subpath

Extensions imported @openclaw/normalization-core directly, crossing the
external-plugin packaging boundary (it only worked because the runtime
builder bundles undeclared workspace helpers). expect-runtime joins the
canonical entrypoints JSON, generated exports, API baseline, docs, and
subpath contract test; all 78 extension imports now use the SDK seam.
Two scanner-shaped locals renamed for review-bundle hygiene.

* chore(plugin-sdk): raise surface budgets for the expect-runtime subpath

One new entrypoint with one callable export, added intentionally as the
packaging-honest seam for extension invariant helpers.
2026-07-12 09:17:31 +01:00

333 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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";
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,})(.*)$/;
const CJK_PUNCTUATION_BREAK_AFTER_RE = /[]/u;
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 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 isHighSurrogate(code: number) {
return code >= 0xd800 && code <= 0xdbff;
}
function isLowSurrogate(code: number) {
return code >= 0xdc00 && code <= 0xdfff;
}
function clampToCodePointBoundary(text: string, index: number) {
const boundary = Math.min(Math.max(0, index), text.length);
if (boundary <= 0 || boundary >= text.length) {
return boundary;
}
const previous = text.charCodeAt(boundary - 1);
const next = text.charCodeAt(boundary);
if (isHighSurrogate(previous) && isLowSurrogate(next)) {
return boundary > 1 ? boundary - 1 : boundary + 1;
}
return boundary;
}
function findWhitespaceBreak(window: string) {
for (let i = window.length - 1; i >= 0; i--) {
if (/\s/.test(window.charAt(i))) {
// Return the separator index so whitespace stays with the next segment.
return i;
}
}
return -1;
}
function findCjkPunctuationBreak(window: string) {
for (let end = window.length; end > 0;) {
const code = window.charCodeAt(end - 1);
const start = isLowSurrogate(code) && end > 1 ? end - 2 : end - 1;
const char = window.slice(start, end);
if (start > 0 && CJK_PUNCTUATION_BREAK_AFTER_RE.test(char)) {
// Return the exclusive end so CJK punctuation stays with the current segment.
return end;
}
end = start;
}
return -1;
}
function splitLongLine(
line: string,
maxChars: number,
opts: { preserveWhitespace: boolean },
): string[] {
const limit = resolveDiscordChunkLimit(maxChars, DEFAULT_MAX_CHARS);
if (line.length <= limit) {
return [line];
}
const out: string[] = [];
let remaining = line;
while (remaining.length > limit) {
if (opts.preserveWhitespace) {
const breakIdx = clampToCodePointBoundary(remaining, limit);
out.push(remaining.slice(0, breakIdx));
remaining = remaining.slice(breakIdx);
continue;
}
const window = remaining.slice(0, limit);
let breakIdx = findWhitespaceBreak(window);
if (breakIdx <= 0) {
breakIdx = findCjkPunctuationBreak(window);
}
if (breakIdx <= 0) {
breakIdx = clampToCodePointBoundary(remaining, limit);
}
out.push(remaining.slice(0, breakIdx));
// Keep the separator for the next segment so words don't get glued together.
remaining = remaining.slice(breakIdx);
}
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 = resolveDiscordChunkLimit(opts.maxChars, DEFAULT_MAX_CHARS);
const maxLines = resolveDiscordChunkLimit(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;
}
}
// 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 fenceToReserve = nextOpenFence ?? openFence;
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 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 = expectDefined(segment, "current Discord chunk segment");
currentLines = 1;
}
}
openFence = nextOpenFence;
}
if (current.length) {
const payload = closeFenceIfNeeded(current, openFence);
if (payload.trim().length) {
chunks.push(payload);
}
}
return rebalanceReasoningItalics(text, chunks);
}
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;
}
// 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 so every chunk renders
// consistently.
function rebalanceReasoningItalics(source: string, chunks: string[]): string[] {
if (chunks.length <= 1) {
return chunks;
}
const opensWithReasoningItalics =
/^(?:Reasoning:|Thinking\.{0,3})\n+_/u.test(source) && source.trimEnd().endsWith("_");
if (!opensWithReasoningItalics) {
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");
// Ensure current chunk closes italics so Discord renders it italicized.
const needsClosing = !current.trimEnd().endsWith("_");
if (needsClosing) {
adjusted[i] = `${current}_`;
}
if (isLast) {
break;
}
// Re-open italics on the next chunk if needed.
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("_")) {
adjusted[i + 1] = `${leadingWhitespace}_${nextBody}`;
}
}
return adjusted;
}