fix: newline chunking across channels

This commit is contained in:
Peter Steinberger
2026-01-25 04:05:14 +00:00
parent ca78ccf74c
commit 458e731f8b
80 changed files with 580 additions and 91 deletions

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
chunkByNewline,
chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText,
chunkTextWithMode,
resolveChunkMode,
@@ -246,10 +247,10 @@ describe("chunkByNewline", () => {
expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
});
it("filters empty lines", () => {
it("preserves blank lines by folding into the next chunk", () => {
const text = "Line one\n\n\nLine two\n\nLine three";
const chunks = chunkByNewline(text, 1000);
expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
expect(chunks).toEqual(["Line one", "\n\nLine two", "\nLine three"]);
});
it("trims whitespace from lines", () => {
@@ -258,6 +259,12 @@ describe("chunkByNewline", () => {
expect(chunks).toEqual(["Line one", "Line two"]);
});
it("preserves leading blank lines on the first chunk", () => {
const text = "\n\nLine one\nLine two";
const chunks = chunkByNewline(text, 1000);
expect(chunks).toEqual(["\n\nLine one", "Line two"]);
});
it("falls back to length-based for long lines", () => {
const text = "Short line\n" + "a".repeat(50) + "\nAnother short";
const chunks = chunkByNewline(text, 20);
@@ -269,6 +276,12 @@ describe("chunkByNewline", () => {
expect(chunks[4]).toBe("Another short");
});
it("does not split long lines when splitLongLines is false", () => {
const text = "a".repeat(50);
const chunks = chunkByNewline(text, 20, { splitLongLines: false });
expect(chunks).toEqual([text]);
});
it("returns empty array for empty input", () => {
expect(chunkByNewline("", 100)).toEqual([]);
});
@@ -276,6 +289,18 @@ describe("chunkByNewline", () => {
it("returns empty array for whitespace-only input", () => {
expect(chunkByNewline(" \n\n ", 100)).toEqual([]);
});
it("preserves trailing blank lines on the last chunk", () => {
const text = "Line one\n\n";
const chunks = chunkByNewline(text, 1000);
expect(chunks).toEqual(["Line one\n\n"]);
});
it("keeps whitespace when trimLines is false", () => {
const text = " indented line \nNext";
const chunks = chunkByNewline(text, 1000, { trimLines: false });
expect(chunks).toEqual([" indented line ", "Next"]);
});
});
describe("chunkTextWithMode", () => {
@@ -292,6 +317,26 @@ describe("chunkTextWithMode", () => {
});
});
describe("chunkMarkdownTextWithMode", () => {
it("uses markdown-aware chunking for length mode", () => {
const text = "Line one\nLine two";
expect(chunkMarkdownTextWithMode(text, 1000, "length")).toEqual(chunkMarkdownText(text, 1000));
});
it("uses newline-based chunking for newline mode", () => {
const text = "Line one\nLine two";
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual(["Line one", "Line two"]);
});
it("does not split inside code fences for newline mode", () => {
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([
"```js\nconst a = 1;\nconst b = 2;\n```",
"After",
]);
});
});
describe("resolveChunkMode", () => {
it("returns length as default", () => {
expect(resolveChunkMode(undefined, "telegram")).toBe("length");
@@ -304,16 +349,16 @@ describe("resolveChunkMode", () => {
expect(resolveChunkMode(cfg, "__internal__")).toBe("length");
});
it("supports provider-level overrides for bluebubbles", () => {
const cfg = { channels: { bluebubbles: { chunkMode: "newline" as const } } };
expect(resolveChunkMode(cfg, "bluebubbles")).toBe("newline");
it("supports provider-level overrides for slack", () => {
const cfg = { channels: { slack: { chunkMode: "newline" as const } } };
expect(resolveChunkMode(cfg, "slack")).toBe("newline");
expect(resolveChunkMode(cfg, "discord")).toBe("length");
});
it("supports account-level overrides for bluebubbles", () => {
it("supports account-level overrides for slack", () => {
const cfg = {
channels: {
bluebubbles: {
slack: {
chunkMode: "length" as const,
accounts: {
primary: { chunkMode: "newline" as const },
@@ -321,12 +366,7 @@ describe("resolveChunkMode", () => {
},
},
};
expect(resolveChunkMode(cfg, "bluebubbles", "primary")).toBe("newline");
expect(resolveChunkMode(cfg, "bluebubbles", "other")).toBe("length");
});
it("ignores chunkMode for non-bluebubbles providers", () => {
const cfg = { channels: { ["telegram" as string]: { chunkMode: "newline" as const } } };
expect(resolveChunkMode(cfg, "telegram")).toBe("length");
expect(resolveChunkMode(cfg, "slack", "primary")).toBe("newline");
expect(resolveChunkMode(cfg, "slack", "other")).toBe("length");
});
});

View File

@@ -101,8 +101,6 @@ export function resolveChunkMode(
accountId?: string | null,
): ChunkMode {
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return DEFAULT_CHUNK_MODE;
// Chunk mode is only supported for BlueBubbles.
if (provider !== "bluebubbles") return DEFAULT_CHUNK_MODE;
const channelsConfig = cfg?.channels as Record<string, unknown> | undefined;
const providerConfig = (channelsConfig?.[provider] ??
(cfg as Record<string, unknown> | undefined)?.[provider]) as ProviderChunkConfig | undefined;
@@ -111,25 +109,56 @@ export function resolveChunkMode(
}
/**
* Split text on newlines, filtering empty lines.
* Lines exceeding maxLineLength are further split using length-based chunking.
* Split text on newlines, trimming line whitespace.
* Blank lines are folded into the next non-empty line as leading "\n" prefixes.
* Long lines can be split by length (default) or kept intact via splitLongLines:false.
*/
export function chunkByNewline(text: string, maxLineLength: number): string[] {
export function chunkByNewline(
text: string,
maxLineLength: number,
opts?: {
splitLongLines?: boolean;
trimLines?: boolean;
isSafeBreak?: (index: number) => boolean;
},
): string[] {
if (!text) return [];
const lines = text.split("\n");
if (maxLineLength <= 0) return text.trim() ? [text] : [];
const splitLongLines = opts?.splitLongLines !== false;
const trimLines = opts?.trimLines !== false;
const lines = splitByNewline(text, opts?.isSafeBreak);
const chunks: string[] = [];
let pendingBlankLines = 0;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue; // skip empty lines
if (trimmed.length <= maxLineLength) {
chunks.push(trimmed);
} else {
// Long line: fall back to length-based chunking
const subChunks = chunkText(trimmed, maxLineLength);
chunks.push(...subChunks);
if (!trimmed) {
pendingBlankLines += 1;
continue;
}
const maxPrefix = Math.max(0, maxLineLength - 1);
const cappedBlankLines = pendingBlankLines > 0 ? Math.min(pendingBlankLines, maxPrefix) : 0;
const prefix = cappedBlankLines > 0 ? "\n".repeat(cappedBlankLines) : "";
pendingBlankLines = 0;
const lineValue = trimLines ? trimmed : line;
if (!splitLongLines || lineValue.length + prefix.length <= maxLineLength) {
chunks.push(prefix + lineValue);
continue;
}
const firstLimit = Math.max(1, maxLineLength - prefix.length);
const first = lineValue.slice(0, firstLimit);
chunks.push(prefix + first);
const remaining = lineValue.slice(firstLimit);
if (remaining) {
chunks.push(...chunkText(remaining, maxLineLength));
}
}
if (pendingBlankLines > 0 && chunks.length > 0) {
chunks[chunks.length - 1] += "\n".repeat(pendingBlankLines);
}
return chunks;
@@ -140,11 +169,59 @@ export function chunkByNewline(text: string, maxLineLength: number): string[] {
*/
export function chunkTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
if (mode === "newline") {
return chunkByNewline(text, limit);
const chunks: string[] = [];
const lineChunks = chunkByNewline(text, limit, { splitLongLines: false });
for (const line of lineChunks) {
const nested = chunkText(line, limit);
if (!nested.length && line) {
chunks.push(line);
continue;
}
chunks.push(...nested);
}
return chunks;
}
return chunkText(text, limit);
}
export function chunkMarkdownTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
if (mode === "newline") {
const spans = parseFenceSpans(text);
const chunks: string[] = [];
const lineChunks = chunkByNewline(text, limit, {
splitLongLines: false,
trimLines: false,
isSafeBreak: (index) => isSafeFenceBreak(spans, index),
});
for (const line of lineChunks) {
const nested = chunkMarkdownText(line, limit);
if (!nested.length && line) {
chunks.push(line);
continue;
}
chunks.push(...nested);
}
return chunks;
}
return chunkMarkdownText(text, limit);
}
function splitByNewline(
text: string,
isSafeBreak: (index: number) => boolean = () => true,
): string[] {
const lines: string[] = [];
let start = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === "\n" && isSafeBreak(i)) {
lines.push(text.slice(start, i));
start = i + 1;
}
}
lines.push(text.slice(start));
return lines;
}
export function chunkText(text: string, limit: number): string[] {
if (!text) return [];
if (limit <= 0) return [text];

View File

@@ -69,7 +69,7 @@ export function resolveBlockStreamingChunking(
});
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
// BlueBubbles-only: if chunkMode is "newline", use newline-based streaming
// When chunkMode is "newline", use newline-based streaming
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
if (channelChunkMode === "newline") {
// For newline mode: use very low minChars to flush quickly on newlines
@@ -103,7 +103,7 @@ export function resolveBlockStreamingCoalescing(
): BlockStreamingCoalescing | undefined {
const providerKey = normalizeChunkProvider(provider);
// BlueBubbles-only: when chunkMode is "newline", disable coalescing to send each line immediately
// When chunkMode is "newline", disable coalescing to send each line immediately
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
if (channelChunkMode === "newline") {
return undefined;

View File

@@ -6,6 +6,7 @@ import type { ChannelOutboundAdapter } from "../types.js";
export const imessageOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage;

View File

@@ -6,6 +6,7 @@ import type { ChannelOutboundAdapter } from "../types.js";
export const signalOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal;

View File

@@ -21,6 +21,7 @@ function parseThreadId(threadId?: string | number | null) {
export const telegramOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: markdownToTelegramHtmlChunks,
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
const send = deps?.sendTelegram ?? sendMessageTelegram;

View File

@@ -8,6 +8,7 @@ import { missingTargetError } from "../../../infra/outbound/target-errors.js";
export const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "gateway",
chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to, allowFrom, mode }) => {

View File

@@ -84,6 +84,7 @@ export type ChannelOutboundContext = {
export type ChannelOutboundAdapter = {
deliveryMode: "direct" | "gateway" | "hybrid";
chunker?: ((text: string, limit: number) => string[]) | null;
chunkerMode?: "text" | "markdown";
textChunkLimit?: number;
pollMaxOptions?: number;
resolveTarget?: (params: {

View File

@@ -108,6 +108,8 @@ export type DiscordAccountConfig = {
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 2000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */

View File

@@ -78,6 +78,8 @@ export type GoogleChatAccountConfig = {
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;

View File

@@ -54,6 +54,8 @@ export type IMessageAccountConfig = {
mediaMaxMb?: number;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;

View File

@@ -72,6 +72,8 @@ export type MSTeamsConfig = {
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/**

View File

@@ -57,6 +57,8 @@ export type SignalAccountConfig = {
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;

View File

@@ -116,6 +116,8 @@ export type SlackAccountConfig = {
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;

View File

@@ -80,6 +80,8 @@ export type TelegramAccountConfig = {
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Chunking config for draft streaming in `streamMode: "block"`. */

View File

@@ -55,6 +55,8 @@ export type WhatsAppConfig = {
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Maximum media file size in MB. Default: 50. */
mediaMaxMb?: number;
/** Disable block streaming for this account. */
@@ -122,6 +124,8 @@ export type WhatsAppAccountConfig = {
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
mediaMaxMb?: number;
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */

View File

@@ -102,6 +102,7 @@ export const TelegramAccountSchemaBase = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
draftChunk: BlockStreamingChunkSchema.optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
@@ -212,6 +213,7 @@ export const DiscordAccountSchema = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
maxLinesPerMessage: z.number().int().positive().optional(),
@@ -310,6 +312,7 @@ export const GoogleChatAccountSchema = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaMaxMb: z.number().positive().optional(),
@@ -401,6 +404,7 @@ export const SlackAccountSchema = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaMaxMb: z.number().positive().optional(),
@@ -494,6 +498,7 @@ export const SignalAccountSchemaBase = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaMaxMb: z.number().int().positive().optional(),
@@ -554,6 +559,7 @@ export const IMessageAccountSchemaBase = z
includeAttachments: z.boolean().optional(),
mediaMaxMb: z.number().int().positive().optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
groups: z
@@ -712,6 +718,7 @@ export const MSTeamsConfigSchema = z
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaAllowHosts: z.array(z.string()).optional(),
requireMention: z.boolean().optional(),

View File

@@ -30,6 +30,7 @@ export const WhatsAppAccountSchema = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
mediaMaxMb: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
@@ -85,6 +86,7 @@ export const WhatsAppConfigSchema = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
mediaMaxMb: z.number().int().positive().optional().default(50),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { chunkDiscordText } from "./chunk.js";
import { chunkDiscordText, chunkDiscordTextWithMode } from "./chunk.js";
function countLines(text: string) {
return text.split("\n").length;
@@ -51,6 +51,16 @@ describe("chunkDiscordText", () => {
expect(chunks.at(-1)).toContain("Done.");
});
it("keeps fenced blocks intact when chunkMode is newline", () => {
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
const chunks = chunkDiscordTextWithMode(text, {
maxChars: 2000,
maxLines: 50,
chunkMode: "newline",
});
expect(chunks).toEqual(["```js\nconst a = 1;\nconst b = 2;\n```", "After"]);
});
it("reserves space for closing fences when chunking", () => {
const body = "a".repeat(120);
const text = `\`\`\`txt\n${body}\n\`\`\``;

View File

@@ -1,3 +1,5 @@
import { chunkMarkdownTextWithMode, type ChunkMode } from "../auto-reply/chunk.js";
export type ChunkDiscordTextOpts = {
/** Max characters per Discord message. Default: 2000. */
maxChars?: number;
@@ -178,6 +180,31 @@ export function chunkDiscordText(text: string, opts: ChunkDiscordTextOpts = {}):
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,
Math.max(1, Math.floor(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

View File

@@ -22,6 +22,7 @@ import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-di
import type { ReplyPayload } from "../../auto-reply/types.js";
import { recordInboundSession } from "../../channels/session.js";
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
import { resolveChunkMode } from "../../auto-reply/chunk.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
@@ -346,6 +347,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
textLimit,
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
tableMode,
chunkMode: resolveChunkMode(cfg, "discord", accountId),
});
replyReference.markSent();
},

View File

@@ -12,7 +12,7 @@ import {
import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10";
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import {
buildCommandTextFromArgs,
findCommandByNativeName,
@@ -40,7 +40,7 @@ import {
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { loadWebMedia } from "../../web/media.js";
import { chunkDiscordText } from "../chunk.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import {
allowListMatches,
@@ -767,6 +767,7 @@ async function dispatchDiscordCommandInteraction(params: {
}),
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
preferFollowUp: preferFollowUp || didReply,
chunkMode: resolveChunkMode(cfg, "discord", accountId),
});
} catch (error) {
if (isDiscordUnknownInteraction(error)) {
@@ -797,8 +798,9 @@ async function deliverDiscordInteractionReply(params: {
textLimit: number;
maxLinesPerMessage?: number;
preferFollowUp: boolean;
chunkMode: "length" | "newline";
}) {
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp } = params;
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
@@ -838,10 +840,12 @@ async function deliverDiscordInteractionReply(params: {
};
}),
);
const chunks = chunkDiscordText(text, {
const chunks = chunkDiscordTextWithMode(text, {
maxChars: textLimit,
maxLines: maxLinesPerMessage,
chunkMode,
});
if (!chunks.length && text) chunks.push(text);
const caption = chunks[0] ?? "";
await sendMessage(caption, media);
for (const chunk of chunks.slice(1)) {
@@ -852,10 +856,12 @@ async function deliverDiscordInteractionReply(params: {
}
if (!text.trim()) return;
const chunks = chunkDiscordText(text, {
const chunks = chunkDiscordTextWithMode(text, {
maxChars: textLimit,
maxLines: maxLinesPerMessage,
chunkMode,
});
if (!chunks.length && text) chunks.push(text);
for (const chunk of chunks) {
if (!chunk.trim()) continue;
await sendMessage(chunk);

View File

@@ -1,10 +1,11 @@
import type { RequestClient } from "@buape/carbon";
import type { ChunkMode } from "../../auto-reply/chunk.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { MarkdownTableMode } from "../../config/types.base.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
import type { RuntimeEnv } from "../../runtime.js";
import { chunkDiscordText } from "../chunk.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import { sendMessageDiscord } from "../send.js";
export async function deliverDiscordReply(params: {
@@ -18,6 +19,7 @@ export async function deliverDiscordReply(params: {
maxLinesPerMessage?: number;
replyToId?: string;
tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
}) {
const chunkLimit = Math.min(params.textLimit, 2000);
for (const payload of params.replies) {
@@ -30,10 +32,14 @@ export async function deliverDiscordReply(params: {
if (mediaList.length === 0) {
let isFirstChunk = true;
for (const chunk of chunkDiscordText(text, {
const mode = params.chunkMode ?? "length";
const chunks = chunkDiscordTextWithMode(text, {
maxChars: chunkLimit,
maxLines: params.maxLinesPerMessage,
})) {
chunkMode: mode,
});
if (!chunks.length && text) chunks.push(text);
for (const chunk of chunks) {
const trimmed = chunk.trim();
if (!trimmed) continue;
await sendMessageDiscord(params.target, trimmed, {

View File

@@ -1,5 +1,6 @@
import type { RequestClient } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { resolveChunkMode } from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
@@ -45,6 +46,7 @@ export async function sendMessageDiscord(
channel: "discord",
accountId: accountInfo.accountId,
});
const chunkMode = resolveChunkMode(cfg, "discord", accountInfo.accountId);
const textWithTables = convertMarkdownTables(text ?? "", tableMode);
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = parseRecipient(to);
@@ -61,6 +63,7 @@ export async function sendMessageDiscord(
request,
accountInfo.config.maxLinesPerMessage,
opts.embeds,
chunkMode,
);
} else {
result = await sendDiscordText(
@@ -71,6 +74,7 @@ export async function sendMessageDiscord(
request,
accountInfo.config.maxLinesPerMessage,
opts.embeds,
chunkMode,
);
}
} catch (err) {

View File

@@ -9,7 +9,8 @@ import { createDiscordRetryRunner, type RetryRunner } from "../infra/retry-polic
import { normalizePollDurationHours, normalizePollInput, type PollInput } from "../polls.js";
import { loadWebMedia } from "../web/media.js";
import { resolveDiscordAccount } from "./accounts.js";
import { chunkDiscordText } from "./chunk.js";
import type { ChunkMode } from "../auto-reply/chunk.js";
import { chunkDiscordTextWithMode } from "./chunk.js";
import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js";
import { DiscordSendError } from "./send.types.js";
import { parseDiscordTarget } from "./targets.js";
@@ -231,15 +232,18 @@ async function sendDiscordText(
request: DiscordRequest,
maxLinesPerMessage?: number,
embeds?: unknown[],
chunkMode?: ChunkMode,
) {
if (!text.trim()) {
throw new Error("Message must be non-empty for Discord sends");
}
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
const chunks = chunkDiscordText(text, {
const chunks = chunkDiscordTextWithMode(text, {
maxChars: DISCORD_TEXT_LIMIT,
maxLines: maxLinesPerMessage,
chunkMode,
});
if (!chunks.length && text) chunks.push(text);
if (chunks.length === 1) {
const res = (await request(
() =>
@@ -285,14 +289,17 @@ async function sendDiscordMedia(
request: DiscordRequest,
maxLinesPerMessage?: number,
embeds?: unknown[],
chunkMode?: ChunkMode,
) {
const media = await loadWebMedia(mediaUrl);
const chunks = text
? chunkDiscordText(text, {
? chunkDiscordTextWithMode(text, {
maxChars: DISCORD_TEXT_LIMIT,
maxLines: maxLinesPerMessage,
chunkMode,
})
: [];
if (!chunks.length && text) chunks.push(text);
const caption = chunks[0] ?? "";
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
const res = (await request(
@@ -314,7 +321,16 @@ async function sendDiscordMedia(
)) as { id: string; channel_id: string };
for (const chunk of chunks.slice(1)) {
if (!chunk.trim()) continue;
await sendDiscordText(rest, channelId, chunk, undefined, request, maxLinesPerMessage);
await sendDiscordText(
rest,
channelId,
chunk,
undefined,
request,
maxLinesPerMessage,
undefined,
chunkMode,
);
}
return res;
}

View File

@@ -1,4 +1,4 @@
import { chunkText } from "../../auto-reply/chunk.js";
import { chunkTextWithMode, resolveChunkMode } from "../../auto-reply/chunk.js";
import { loadConfig } from "../../config/config.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
@@ -23,13 +23,14 @@ export async function deliverReplies(params: {
channel: "imessage",
accountId,
});
const chunkMode = resolveChunkMode(cfg, "imessage", accountId);
for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const rawText = payload.text ?? "";
const text = convertMarkdownTables(rawText, tableMode);
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
for (const chunk of chunkText(text, textLimit)) {
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
await sendMessageIMessage(target, chunk, {
maxBytes,
client,

View File

@@ -168,6 +168,84 @@ describe("deliverOutboundPayloads", () => {
expect(results.map((r) => r.messageId)).toEqual(["w1", "w2"]);
});
it("respects newline chunk mode for WhatsApp", async () => {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
const cfg: ClawdbotConfig = {
channels: { whatsapp: { textChunkLimit: 4000, chunkMode: "newline" } },
};
await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "Line one\n\nLine two" }],
deps: { sendWhatsApp },
});
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(sendWhatsApp).toHaveBeenNthCalledWith(
1,
"+1555",
"Line one",
expect.objectContaining({ verbose: false }),
);
expect(sendWhatsApp).toHaveBeenNthCalledWith(
2,
"+1555",
"\nLine two",
expect.objectContaining({ verbose: false }),
);
});
it("preserves fenced blocks for markdown chunkers in newline mode", async () => {
const chunker = vi.fn((text: string) => (text ? [text] : []));
const sendText = vi.fn().mockImplementation(async ({ text }: { text: string }) => ({
channel: "matrix" as const,
messageId: text,
roomId: "r1",
}));
const sendMedia = vi.fn().mockImplementation(async ({ text }: { text: string }) => ({
channel: "matrix" as const,
messageId: text,
roomId: "r1",
}));
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: {
deliveryMode: "direct",
chunker,
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText,
sendMedia,
},
}),
},
]),
);
const cfg: ClawdbotConfig = {
channels: { matrix: { textChunkLimit: 4000, chunkMode: "newline" } },
};
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
await deliverOutboundPayloads({
cfg,
channel: "matrix",
to: "!room",
payloads: [{ text }],
});
expect(chunker).toHaveBeenCalledTimes(2);
expect(chunker).toHaveBeenNthCalledWith(1, "```js\nconst a = 1;\nconst b = 2;\n```", 4000);
expect(chunker).toHaveBeenNthCalledWith(2, "After", 4000);
});
it("uses iMessage media maxBytes from agent fallback", async () => {
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1" });
setActivePluginRegistry(

View File

@@ -1,4 +1,9 @@
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import {
chunkByNewline,
chunkMarkdownTextWithMode,
resolveChunkMode,
resolveTextChunkLimit,
} from "../../auto-reply/chunk.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js";
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
@@ -62,6 +67,7 @@ type Chunker = (text: string, limit: number) => string[];
type ChannelHandler = {
chunker: Chunker | null;
chunkerMode?: "text" | "markdown";
textChunkLimit?: number;
sendText: (text: string) => Promise<OutboundDeliveryResult>;
sendMedia: (caption: string, mediaUrl: string) => Promise<OutboundDeliveryResult>;
@@ -121,8 +127,10 @@ function createPluginHandler(params: {
const sendText = outbound.sendText;
const sendMedia = outbound.sendMedia;
const chunker = outbound.chunker ?? null;
const chunkerMode = outbound.chunkerMode;
return {
chunker,
chunkerMode,
textChunkLimit: outbound.textChunkLimit,
sendText: async (text) =>
sendText({
@@ -192,6 +200,7 @@ export async function deliverOutboundPayloads(params: {
fallbackLimit: handler.textChunkLimit,
})
: undefined;
const chunkMode = handler.chunker ? resolveChunkMode(cfg, channel, accountId) : "length";
const isSignalChannel = channel === "signal";
const signalTableMode = isSignalChannel
? resolveMarkdownTableMode({ cfg, channel: "signal", accountId })
@@ -212,6 +221,23 @@ export async function deliverOutboundPayloads(params: {
results.push(await handler.sendText(text));
return;
}
if (chunkMode === "newline") {
const mode = handler.chunkerMode ?? "text";
const lineChunks =
mode === "markdown"
? chunkMarkdownTextWithMode(text, textLimit, "newline")
: chunkByNewline(text, textLimit, { splitLongLines: false });
if (!lineChunks.length && text) lineChunks.push(text);
for (const lineChunk of lineChunks) {
const chunks = handler.chunker(lineChunk, textLimit);
if (!chunks.length && lineChunk) chunks.push(lineChunk);
for (const chunk of chunks) {
throwIfAborted(abortSignal);
results.push(await handler.sendText(chunk));
}
}
return;
}
const chunks = handler.chunker(text, textLimit);
for (const chunk of chunks) {
throwIfAborted(abortSignal);

View File

@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
import {
chunkByNewline,
chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText,
chunkTextWithMode,
resolveChunkMode,
@@ -170,6 +171,7 @@ export function createPluginRuntime(): PluginRuntime {
text: {
chunkByNewline,
chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText,
chunkTextWithMode,
resolveChunkMode,

View File

@@ -37,6 +37,8 @@ type ResolveCommandAuthorizedFromAuthorizers =
type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit;
type ResolveChunkMode = typeof import("../../auto-reply/chunk.js").resolveChunkMode;
type ChunkMarkdownText = typeof import("../../auto-reply/chunk.js").chunkMarkdownText;
type ChunkMarkdownTextWithMode =
typeof import("../../auto-reply/chunk.js").chunkMarkdownTextWithMode;
type ChunkText = typeof import("../../auto-reply/chunk.js").chunkText;
type ChunkTextWithMode = typeof import("../../auto-reply/chunk.js").chunkTextWithMode;
type ChunkByNewline = typeof import("../../auto-reply/chunk.js").chunkByNewline;
@@ -180,6 +182,7 @@ export type PluginRuntime = {
text: {
chunkByNewline: ChunkByNewline;
chunkMarkdownText: ChunkMarkdownText;
chunkMarkdownTextWithMode: ChunkMarkdownTextWithMode;
chunkText: ChunkText;
chunkTextWithMode: ChunkTextWithMode;
resolveChunkMode: ResolveChunkMode;

View File

@@ -1,4 +1,4 @@
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { chunkTextWithMode, resolveChunkMode, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { ClawdbotConfig } from "../config/config.js";
@@ -214,14 +214,16 @@ async function deliverReplies(params: {
runtime: RuntimeEnv;
maxBytes: number;
textLimit: number;
chunkMode: "length" | "newline";
}) {
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit } = params;
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } =
params;
for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
for (const chunk of chunkText(text, textLimit)) {
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
await sendMessageSignal(target, chunk, {
baseUrl,
account,
@@ -262,6 +264,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
);
const groupHistories = new Map<string, HistoryEntry[]>();
const textLimit = resolveTextChunkLimit(cfg, "signal", accountInfo.accountId);
const chunkMode = resolveChunkMode(cfg, "signal", accountInfo.accountId);
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
const account = opts.account?.trim() || accountInfo.config.account?.trim();
const dmPolicy = accountInfo.config.dmPolicy ?? "pairing";
@@ -340,7 +343,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
sendReadReceipts,
readReceiptsViaDaemon,
fetchAttachment,
deliverReplies,
deliverReplies: (params) => deliverReplies({ ...params, chunkMode }),
resolveSignalReactionTargets,
isSignalReactionMessage,
shouldEmitSignalReactionNotification,

View File

@@ -1,5 +1,7 @@
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
import type { ChunkMode } from "../../auto-reply/chunk.js";
import { chunkMarkdownTextWithMode } from "../../auto-reply/chunk.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { MarkdownTableMode } from "../../config/types.base.js";
import type { RuntimeEnv } from "../../runtime.js";
@@ -118,6 +120,7 @@ export async function deliverSlackSlashReplies(params: {
ephemeral: boolean;
textLimit: number;
tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
}) {
const messages: string[] = [];
const chunkLimit = Math.min(params.textLimit, 4000);
@@ -129,9 +132,16 @@ export async function deliverSlackSlashReplies(params: {
.filter(Boolean)
.join("\n");
if (!combined) continue;
for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit, {
tableMode: params.tableMode,
})) {
const chunkMode = params.chunkMode ?? "length";
const markdownChunks =
chunkMode === "newline"
? chunkMarkdownTextWithMode(combined, chunkLimit, chunkMode)
: [combined];
const chunks = markdownChunks.flatMap((markdown) =>
markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode: params.tableMode }),
);
if (!chunks.length && combined) chunks.push(combined);
for (const chunk of chunks) {
messages.push(chunk);
}
}

View File

@@ -1,5 +1,6 @@
import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt";
import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js";
import { resolveChunkMode } from "../../auto-reply/chunk.js";
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import {
buildCommandTextFromArgs,
@@ -429,6 +430,7 @@ export function registerSlackMonitorSlashCommands(params: {
respond,
ephemeral: slashCommand.ephemeral,
textLimit: ctx.textLimit,
chunkMode: resolveChunkMode(cfg, "slack", route.accountId),
tableMode: resolveMarkdownTableMode({
cfg,
channel: "slack",
@@ -448,6 +450,7 @@ export function registerSlackMonitorSlashCommands(params: {
respond,
ephemeral: slashCommand.ephemeral,
textLimit: ctx.textLimit,
chunkMode: resolveChunkMode(cfg, "slack", route.accountId),
tableMode: resolveMarkdownTableMode({
cfg,
channel: "slack",

View File

@@ -1,6 +1,10 @@
import { type FilesUploadV2Arguments, type WebClient } from "@slack/web-api";
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
import {
chunkMarkdownTextWithMode,
resolveChunkMode,
resolveTextChunkLimit,
} from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { loadWebMedia } from "../web/media.js";
@@ -149,7 +153,15 @@ export async function sendMessageSlack(
channel: "slack",
accountId: account.accountId,
});
const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit, { tableMode });
const chunkMode = resolveChunkMode(cfg, "slack", account.accountId);
const markdownChunks =
chunkMode === "newline"
? chunkMarkdownTextWithMode(trimmedMessage, chunkLimit, chunkMode)
: [trimmedMessage];
const chunks = markdownChunks.flatMap((markdown) =>
markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode }),
);
if (!chunks.length && trimmedMessage) chunks.push(trimmedMessage);
const mediaMaxBytes =
typeof account.config.mediaMaxMb === "number"
? account.config.mediaMaxMb * 1024 * 1024

View File

@@ -1,5 +1,6 @@
// @ts-nocheck
import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
import { resolveChunkMode } from "../auto-reply/chunk.js";
import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { removeAckReactionAfterReply } from "../channels/ack-reactions.js";
@@ -125,6 +126,7 @@ export const dispatchTelegramMessage = async ({
channel: "telegram",
accountId: route.accountId,
});
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
@@ -147,6 +149,7 @@ export const dispatchTelegramMessage = async ({
textLimit,
messageThreadId: resolvedThreadId,
tableMode,
chunkMode,
onVoiceRecording: sendRecordVoice,
});
},

View File

@@ -1,6 +1,7 @@
import type { Bot, Context } from "grammy";
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
import { resolveChunkMode } from "../auto-reply/chunk.js";
import {
buildCommandTextFromArgs,
findCommandByNativeName,
@@ -320,6 +321,7 @@ export const registerTelegramNativeCommands = ({
typeof telegramCfg.blockStreaming === "boolean"
? !telegramCfg.blockStreaming
: undefined;
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
@@ -337,6 +339,7 @@ export const registerTelegramNativeCommands = ({
textLimit,
messageThreadId: resolvedThreadId,
tableMode,
chunkMode,
});
},
onError: (err, info) => {

View File

@@ -4,6 +4,7 @@ import {
markdownToTelegramHtml,
renderTelegramHtmlText,
} from "../format.js";
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js";
import { splitTelegramCaption } from "../caption.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { ReplyToMode } from "../../config/config.js";
@@ -32,12 +33,33 @@ export async function deliverReplies(params: {
textLimit: number;
messageThreadId?: number;
tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
/** Callback invoked before sending a voice message to switch typing indicator. */
onVoiceRecording?: () => Promise<void> | void;
}) {
const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId } = params;
const chunkMode = params.chunkMode ?? "length";
const threadParams = buildTelegramThreadParams(messageThreadId);
let hasReplied = false;
const chunkText = (markdown: string) => {
const markdownChunks =
chunkMode === "newline"
? chunkMarkdownTextWithMode(markdown, textLimit, chunkMode)
: [markdown];
const chunks: ReturnType<typeof markdownToTelegramChunks> = [];
for (const chunk of markdownChunks) {
const nested = markdownToTelegramChunks(chunk, textLimit, { tableMode: params.tableMode });
if (!nested.length && chunk) {
chunks.push({
html: markdownToTelegramHtml(chunk, { tableMode: params.tableMode }),
text: chunk,
});
continue;
}
chunks.push(...nested);
}
return chunks;
};
for (const reply of replies) {
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
if (!reply?.text && !hasMedia) {
@@ -55,9 +77,7 @@ export async function deliverReplies(params: {
? [reply.mediaUrl]
: [];
if (mediaList.length === 0) {
const chunks = markdownToTelegramChunks(reply.text || "", textLimit, {
tableMode: params.tableMode,
});
const chunks = chunkText(reply.text || "");
for (const chunk of chunks) {
await sendTelegramText(bot, chatId, chunk.html, runtime, {
replyToMessageId:
@@ -151,9 +171,7 @@ export async function deliverReplies(params: {
// Send deferred follow-up text right after the first media item.
// Chunk it in case it's extremely long (same logic as text-only replies).
if (pendingFollowUpText && isFirstMedia) {
const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit, {
tableMode: params.tableMode,
});
const chunks = chunkText(pendingFollowUpText);
for (const chunk of chunks) {
const replyToMessageIdFollowup =
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;

View File

@@ -22,6 +22,7 @@ export type ResolvedWhatsAppAccount = {
groupPolicy?: GroupPolicy;
dmPolicy?: DmPolicy;
textChunkLimit?: number;
chunkMode?: "length" | "newline";
mediaMaxMb?: number;
blockStreaming?: boolean;
ackReaction?: WhatsAppAccountConfig["ackReaction"];
@@ -150,6 +151,7 @@ export function resolveWhatsAppAccount(params: {
groupAllowFrom: accountCfg?.groupAllowFrom ?? rootCfg?.groupAllowFrom,
groupPolicy: accountCfg?.groupPolicy ?? rootCfg?.groupPolicy,
textChunkLimit: accountCfg?.textChunkLimit ?? rootCfg?.textChunkLimit,
chunkMode: accountCfg?.chunkMode ?? rootCfg?.chunkMode,
mediaMaxMb: accountCfg?.mediaMaxMb ?? rootCfg?.mediaMaxMb,
blockStreaming: accountCfg?.blockStreaming ?? rootCfg?.blockStreaming,
ackReaction: accountCfg?.ackReaction ?? rootCfg?.ackReaction,

View File

@@ -1,4 +1,4 @@
import { chunkMarkdownText } from "../../auto-reply/chunk.js";
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js";
import type { MarkdownTableMode } from "../../config/types.base.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
@@ -15,6 +15,7 @@ export async function deliverWebReply(params: {
msg: WebInboundMsg;
maxMediaBytes: number;
textLimit: number;
chunkMode?: ChunkMode;
replyLogger: {
info: (obj: unknown, msg: string) => void;
warn: (obj: unknown, msg: string) => void;
@@ -26,8 +27,9 @@ export async function deliverWebReply(params: {
const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params;
const replyStarted = Date.now();
const tableMode = params.tableMode ?? "code";
const chunkMode = params.chunkMode ?? "length";
const convertedText = convertMarkdownTables(replyResult.text || "", tableMode);
const textChunks = chunkMarkdownText(convertedText, textLimit);
const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode);
const mediaList = replyResult.mediaUrls?.length
? replyResult.mediaUrls
: replyResult.mediaUrl

View File

@@ -79,6 +79,7 @@ export async function monitorWebChannel(
groupAllowFrom: account.groupAllowFrom,
groupPolicy: account.groupPolicy,
textChunkLimit: account.textChunkLimit,
chunkMode: account.chunkMode,
mediaMaxMb: account.mediaMaxMb,
blockStreaming: account.blockStreaming,
groups: account.groups,

View File

@@ -1,5 +1,5 @@
import { resolveIdentityNamePrefix } from "../../../agents/identity.js";
import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
import { resolveChunkMode, resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
import {
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
@@ -229,6 +229,7 @@ export async function processMessage(params: {
: undefined;
const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp");
const chunkMode = resolveChunkMode(params.cfg, "whatsapp", params.route.accountId);
const tableMode = resolveMarkdownTableMode({
cfg: params.cfg,
channel: "whatsapp",
@@ -338,6 +339,7 @@ export async function processMessage(params: {
msg: params.msg,
maxMediaBytes: params.maxMediaBytes,
textLimit,
chunkMode,
replyLogger: params.replyLogger,
connectionId: params.connectionId,
// Tool + block updates are noisy; skip their log lines.