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: {},
|
routing: {},
|
||||||
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },
|
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },
|
||||||
telegram: { enabled: true, textChunkLimit: 3333 },
|
telegram: { enabled: true, textChunkLimit: 3333 },
|
||||||
discord: { enabled: true, textChunkLimit: 1999 },
|
discord: {
|
||||||
|
enabled: true,
|
||||||
|
textChunkLimit: 1999,
|
||||||
|
maxLinesPerMessage: 17,
|
||||||
|
},
|
||||||
signal: { enabled: true, textChunkLimit: 2222 },
|
signal: { enabled: true, textChunkLimit: 2222 },
|
||||||
imessage: { enabled: true, textChunkLimit: 1111 },
|
imessage: { enabled: true, textChunkLimit: 1111 },
|
||||||
},
|
},
|
||||||
@@ -207,6 +211,7 @@ describe("config identity defaults", () => {
|
|||||||
expect(cfg.whatsapp?.textChunkLimit).toBe(4444);
|
expect(cfg.whatsapp?.textChunkLimit).toBe(4444);
|
||||||
expect(cfg.telegram?.textChunkLimit).toBe(3333);
|
expect(cfg.telegram?.textChunkLimit).toBe(3333);
|
||||||
expect(cfg.discord?.textChunkLimit).toBe(1999);
|
expect(cfg.discord?.textChunkLimit).toBe(1999);
|
||||||
|
expect(cfg.discord?.maxLinesPerMessage).toBe(17);
|
||||||
expect(cfg.signal?.textChunkLimit).toBe(2222);
|
expect(cfg.signal?.textChunkLimit).toBe(2222);
|
||||||
expect(cfg.imessage?.textChunkLimit).toBe(1111);
|
expect(cfg.imessage?.textChunkLimit).toBe(1111);
|
||||||
|
|
||||||
|
|||||||
@@ -344,6 +344,12 @@ export type DiscordConfig = {
|
|||||||
groupPolicy?: GroupPolicy;
|
groupPolicy?: GroupPolicy;
|
||||||
/** Outbound text chunk size (chars). Default: 2000. */
|
/** Outbound text chunk size (chars). Default: 2000. */
|
||||||
textChunkLimit?: number;
|
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;
|
mediaMaxMb?: number;
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
/** Per-action tool gating (default: true for all). */
|
/** Per-action tool gating (default: true for all). */
|
||||||
|
|||||||
@@ -786,6 +786,7 @@ export const ClawdbotSchema = z.object({
|
|||||||
token: z.string().optional(),
|
token: z.string().optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
maxLinesPerMessage: z.number().int().positive().optional(),
|
||||||
slashCommand: z
|
slashCommand: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
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 PartialUser,
|
||||||
type User,
|
type User,
|
||||||
} from "discord.js";
|
} 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 { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
|
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
|
||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
upsertProviderPairingRequest,
|
upsertProviderPairingRequest,
|
||||||
} from "../pairing/pairing-store.js";
|
} from "../pairing/pairing-store.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { chunkDiscordText } from "./chunk.js";
|
||||||
import { sendMessageDiscord } from "./send.js";
|
import { sendMessageDiscord } from "./send.js";
|
||||||
import { normalizeDiscordToken } from "./token.js";
|
import { normalizeDiscordToken } from "./token.js";
|
||||||
|
|
||||||
@@ -646,6 +647,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
runtime,
|
runtime,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
textLimit,
|
textLimit,
|
||||||
|
maxLinesPerMessage: cfg.discord?.maxLinesPerMessage,
|
||||||
});
|
});
|
||||||
didSendReply = true;
|
didSendReply = true;
|
||||||
},
|
},
|
||||||
@@ -1287,6 +1289,7 @@ async function deliverReplies({
|
|||||||
runtime,
|
runtime,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
textLimit,
|
textLimit,
|
||||||
|
maxLinesPerMessage,
|
||||||
}: {
|
}: {
|
||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
target: string;
|
target: string;
|
||||||
@@ -1294,6 +1297,7 @@ async function deliverReplies({
|
|||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
replyToMode: ReplyToMode;
|
replyToMode: ReplyToMode;
|
||||||
textLimit: number;
|
textLimit: number;
|
||||||
|
maxLinesPerMessage?: number;
|
||||||
}) {
|
}) {
|
||||||
let hasReplied = false;
|
let hasReplied = false;
|
||||||
const chunkLimit = Math.min(textLimit, 2000);
|
const chunkLimit = Math.min(textLimit, 2000);
|
||||||
@@ -1304,7 +1308,10 @@ async function deliverReplies({
|
|||||||
const replyToId = payload.replyToId;
|
const replyToId = payload.replyToId;
|
||||||
if (!text && mediaList.length === 0) continue;
|
if (!text && mediaList.length === 0) continue;
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
for (const chunk of chunkText(text, chunkLimit)) {
|
for (const chunk of chunkDiscordText(text, {
|
||||||
|
maxChars: chunkLimit,
|
||||||
|
maxLines: maxLinesPerMessage,
|
||||||
|
})) {
|
||||||
const replyTo = resolveDiscordReplyTarget({
|
const replyTo = resolveDiscordReplyTarget({
|
||||||
replyToMode,
|
replyToMode,
|
||||||
replyToId,
|
replyToId,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type {
|
|||||||
RESTPostAPIGuildScheduledEventJSONBody,
|
RESTPostAPIGuildScheduledEventJSONBody,
|
||||||
} from "discord-api-types/v10";
|
} from "discord-api-types/v10";
|
||||||
|
|
||||||
import { chunkText } from "../auto-reply/chunk.js";
|
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
normalizePollDurationHours,
|
normalizePollDurationHours,
|
||||||
@@ -20,6 +19,7 @@ import {
|
|||||||
type PollInput,
|
type PollInput,
|
||||||
} from "../polls.js";
|
} from "../polls.js";
|
||||||
import { loadWebMedia, loadWebMediaRaw } from "../web/media.js";
|
import { loadWebMedia, loadWebMediaRaw } from "../web/media.js";
|
||||||
|
import { chunkDiscordText } from "./chunk.js";
|
||||||
import { normalizeDiscordToken } from "./token.js";
|
import { normalizeDiscordToken } from "./token.js";
|
||||||
|
|
||||||
const DISCORD_TEXT_LIMIT = 2000;
|
const DISCORD_TEXT_LIMIT = 2000;
|
||||||
@@ -354,13 +354,18 @@ async function sendDiscordText(
|
|||||||
const messageReference = replyTo
|
const messageReference = replyTo
|
||||||
? { message_id: replyTo, fail_if_not_exists: false }
|
? { message_id: replyTo, fail_if_not_exists: false }
|
||||||
: undefined;
|
: 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), {
|
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 };
|
})) as { id: string; channel_id: string };
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
const chunks = chunkText(text, DISCORD_TEXT_LIMIT);
|
|
||||||
let last: { id: string; channel_id: string } | null = null;
|
let last: { id: string; channel_id: string } | null = null;
|
||||||
let isFirst = true;
|
let isFirst = true;
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
|
|||||||
Reference in New Issue
Block a user