From bf2daeb3ae07c06a33085e934f5acabfd01c9347 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 8 Jan 2026 04:02:04 +0100
Subject: [PATCH] fix(discord): cap lines per message
---
CHANGELOG.md | 1 +
README.md | 1 +
docs/concepts/streaming.md | 1 +
docs/gateway/configuration.md | 3 +
docs/providers/discord.md | 4 +-
docs/providers/imessage.md | 4 +
docs/providers/signal.md | 3 +-
docs/providers/slack.md | 4 +
docs/providers/telegram.md | 4 +
docs/providers/whatsapp.md | 6 +-
src/config/config.test.ts | 7 +-
src/config/schema.ts | 3 +
src/config/types.ts | 6 ++
src/config/zod-schema.ts | 1 +
src/discord/chunk.test.ts | 70 +++++++++++++
src/discord/chunk.ts | 191 ++++++++++++++++++++++++++++++++++
src/discord/monitor.ts | 53 +++++++---
src/discord/send.ts | 43 ++++++--
18 files changed, 373 insertions(+), 32 deletions(-)
create mode 100644 src/discord/chunk.test.ts
create mode 100644 src/discord/chunk.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 52dc0328c..a6f053d07 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@
- Gateway/CLI: include gateway target/source details in close/timeout errors and verbose health/status output.
- Gateway/CLI: honor `gateway.auth.password` for local CLI calls when env is unset. Thanks @jeffersonwarrior for PR #301.
- Discord: format slow listener logs in seconds to match shared duration style.
+- Discord: split tall replies by line count to avoid client clipping; add `discord.maxLinesPerMessage` + docs. Thanks @jdrhyne for PR #371.
- CLI: show colored table output for `clawdbot cron list` (JSON behind `--json`).
- CLI: add cron `create`/`remove`/`delete` aliases for job management.
- Agent: avoid duplicating context/skills when SDK rebuilds the system prompt. (#418)
diff --git a/README.md b/README.md
index 45ef4a5d9..e00510302 100644
--- a/README.md
+++ b/README.md
@@ -455,4 +455,5 @@ Thanks to all clawtributors:
+
diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md
index 26c216b82..de36a6b52 100644
--- a/docs/concepts/streaming.md
+++ b/docs/concepts/streaming.md
@@ -36,6 +36,7 @@ Legend:
- `agent.blockStreamingBreak`: `"text_end"` or `"message_end"`.
- `agent.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
- Provider hard cap: `*.textChunkLimit` (e.g., `whatsapp.textChunkLimit`).
+- Discord soft cap: `discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping.
**Boundary semantics:**
- `text_end`: stream blocks as soon as chunker emits; flush on each `text_end`.
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index 700ea90fd..81be68269 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -689,6 +689,8 @@ Multi-account support lives under `discord.accounts` (see the multi-account sect
}
},
historyLimit: 20, // include last N guild messages as context
+ textChunkLimit: 2000, // optional outbound text chunk size (chars)
+ maxLinesPerMessage: 17, // soft max lines per message (Discord UI clipping)
retry: { // outbound retry policy
attempts: 3,
minDelayMs: 500,
@@ -706,6 +708,7 @@ Reaction notification modes:
- `own`: reactions on the bot's own messages (default).
- `all`: all reactions on all messages.
- `allowlist`: reactions from `guilds..users` on all messages (empty list disables).
+Outbound text is chunked by `discord.textChunkLimit` (default 2000). Discord clients can clip very tall messages, so `discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars.
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
### `slack` (socket mode)
diff --git a/docs/providers/discord.md b/docs/providers/discord.md
index 383916f63..fbf512dc0 100644
--- a/docs/providers/discord.md
+++ b/docs/providers/discord.md
@@ -156,7 +156,7 @@ Notes:
## Capabilities & limits
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
-- Typing indicators sent best-effort; message chunking honors Discord’s 2k character limit.
+- Typing indicators sent best-effort; message chunking uses `discord.textChunkLimit` (default 2000) and splits tall replies by line count (`discord.maxLinesPerMessage`, default 17).
- File uploads supported up to the configured `discord.mediaMaxMb` (default 8 MB).
- Mention-gated guild replies by default to avoid noisy bots.
- Reply context is injected when a message references another message (quoted content + ids).
@@ -244,6 +244,8 @@ Ack reactions are controlled globally via `messages.ackReaction` +
- `guilds..channels`: channel rules (keys are channel slugs or ids).
- `guilds..requireMention`: per-guild mention requirement (overridable per channel).
- `guilds..reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
+- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
+- `maxLinesPerMessage`: soft max line count per message. Default: 17.
- `mediaMaxMb`: clamp inbound media saved to disk.
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
diff --git a/docs/providers/imessage.md b/docs/providers/imessage.md
index f47d5d204..3eb48fd74 100644
--- a/docs/providers/imessage.md
+++ b/docs/providers/imessage.md
@@ -77,6 +77,10 @@ Groups:
- Optional attachment ingestion via `imessage.includeAttachments`.
- Media cap via `imessage.mediaMaxMb`.
+## Limits
+- Outbound text is chunked to `imessage.textChunkLimit` (default 4000).
+- Media uploads are capped by `imessage.mediaMaxMb` (default 16).
+
## Addressing / delivery targets
Prefer `chat_id` for stable routing:
- `chat_id:123` (preferred)
diff --git a/docs/providers/signal.md b/docs/providers/signal.md
index e41660a59..a3f081616 100644
--- a/docs/providers/signal.md
+++ b/docs/providers/signal.md
@@ -60,8 +60,9 @@ Groups:
- Replies always route back to the same number or group.
## Media + limits
+- Outbound text is chunked to `signal.textChunkLimit` (default 4000).
- Attachments supported (base64 fetched from `signal-cli`).
-- Default cap: `signal.mediaMaxMb`.
+- Default media cap: `signal.mediaMaxMb` (default 8).
- Use `signal.ignoreAttachments` to skip downloading media.
## Delivery targets (CLI/cron)
diff --git a/docs/providers/slack.md b/docs/providers/slack.md
index 23c203a9b..a3507bd52 100644
--- a/docs/providers/slack.md
+++ b/docs/providers/slack.md
@@ -196,6 +196,10 @@ Tokens can also be supplied via env vars:
Ack reactions are controlled globally via `messages.ackReaction` +
`messages.ackReactionScope`.
+## Limits
+- Outbound text is chunked to `slack.textChunkLimit` (default 4000).
+- Media uploads are capped by `slack.mediaMaxMb` (default 20).
+
## Reply threading
Slack supports optional threaded replies via tags:
- `[[reply_to_current]]` — reply to the triggering message.
diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md
index dc63984e5..77705b6c2 100644
--- a/docs/providers/telegram.md
+++ b/docs/providers/telegram.md
@@ -47,6 +47,10 @@ Multi-account support: use `telegram.accounts` with per-account tokens and optio
- Raw HTML from models is escaped to avoid Telegram parse errors.
- If Telegram rejects the HTML payload, Clawdbot retries the same message as plain text.
+## Limits
+- Outbound text is chunked to `telegram.textChunkLimit` (default 4000).
+- Media downloads/uploads are capped by `telegram.mediaMaxMb` (default 5).
+
## Group activation modes
By default, the bot only responds to mentions in groups (`@botname` or patterns in `routing.groupChat.mentionPatterns`). To change this behavior:
diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md
index a777af70f..5467b3938 100644
--- a/docs/providers/whatsapp.md
+++ b/docs/providers/whatsapp.md
@@ -126,9 +126,13 @@ Recommended for personal numbers:
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
- Tool gating: `whatsapp.actions.reactions` (default: enabled).
+## Limits
+- Outbound text is chunked to `whatsapp.textChunkLimit` (default 4000).
+- Media items are capped by `agent.mediaMaxMb` (default 5 MB).
+
## Outbound send (text + media)
- Uses active web listener; error if gateway not running.
-- Text chunking: 4k max per message.
+- Text chunking: 4k max per message (configurable via `whatsapp.textChunkLimit`).
- Media:
- Image/video/audio/document supported.
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
diff --git a/src/config/config.test.ts b/src/config/config.test.ts
index 121e0802e..8a5d50f0a 100644
--- a/src/config/config.test.ts
+++ b/src/config/config.test.ts
@@ -212,7 +212,11 @@ describe("config identity defaults", () => {
routing: {},
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },
telegram: { enabled: true, textChunkLimit: 3333 },
- discord: { enabled: true, textChunkLimit: 1999 },
+ discord: {
+ enabled: true,
+ textChunkLimit: 1999,
+ maxLinesPerMessage: 17,
+ },
signal: { enabled: true, textChunkLimit: 2222 },
imessage: { enabled: true, textChunkLimit: 1111 },
},
@@ -229,6 +233,7 @@ describe("config identity defaults", () => {
expect(cfg.whatsapp?.textChunkLimit).toBe(4444);
expect(cfg.telegram?.textChunkLimit).toBe(3333);
expect(cfg.discord?.textChunkLimit).toBe(1999);
+ expect(cfg.discord?.maxLinesPerMessage).toBe(17);
expect(cfg.signal?.textChunkLimit).toBe(2222);
expect(cfg.imessage?.textChunkLimit).toBe(1111);
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 639c80f9f..1410bab77 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -121,6 +121,7 @@ const FIELD_LABELS: Record = {
"discord.retry.minDelayMs": "Discord Retry Min Delay (ms)",
"discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)",
"discord.retry.jitter": "Discord Retry Jitter",
+ "discord.maxLinesPerMessage": "Discord Max Lines Per Message",
"slack.dm.policy": "Slack DM Policy",
"discord.token": "Discord Bot Token",
"slack.botToken": "Slack Bot Token",
@@ -193,6 +194,8 @@ const FIELD_HELP: Record = {
"Maximum retry delay cap in ms for Discord outbound calls.",
"discord.retry.jitter":
"Jitter factor (0-1) applied to Discord retry delays.",
+ "discord.maxLinesPerMessage":
+ "Soft max line count per Discord message (default: 17).",
"slack.dm.policy":
'Direct message access control ("pairing" recommended). "open" requires slack.dm.allowFrom=["*"].',
};
diff --git a/src/config/types.ts b/src/config/types.ts
index a488282a8..abccd28a2 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -411,6 +411,12 @@ export type DiscordAccountConfig = {
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 2000. */
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. Default: 17.
+ */
+ maxLinesPerMessage?: number;
mediaMaxMb?: number;
historyLimit?: number;
/** Retry policy for outbound Discord API calls. */
diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts
index 67845e6d5..778bab915 100644
--- a/src/config/zod-schema.ts
+++ b/src/config/zod-schema.ts
@@ -254,6 +254,7 @@ const DiscordAccountSchema = z.object({
token: z.string().optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
+ maxLinesPerMessage: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
historyLimit: z.number().int().min(0).optional(),
retry: RetryConfigSchema,
diff --git a/src/discord/chunk.test.ts b/src/discord/chunk.test.ts
new file mode 100644
index 000000000..79eca1ddf
--- /dev/null
+++ b/src/discord/chunk.test.ts
@@ -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);
+ }
+ });
+});
diff --git a/src/discord/chunk.ts b/src/discord/chunk.ts
new file mode 100644
index 000000000..372704c2f
--- /dev/null
+++ b/src/discord/chunk.ts
@@ -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;
+}
diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts
index ca6b2d254..6bc63a1c4 100644
--- a/src/discord/monitor.ts
+++ b/src/discord/monitor.ts
@@ -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, {
diff --git a/src/discord/send.ts b/src/discord/send.ts
index 979f90547..063b80937 100644
--- a/src/discord/send.ts
+++ b/src/discord/send.ts
@@ -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 {
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) {