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

@@ -374,6 +374,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
deliveryMode: "direct",
chunker: (text, limit) =>
getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
resolveTarget: ({ to, allowFrom, mode }) => {
const trimmed = to?.trim() ?? "";

View File

@@ -684,6 +684,7 @@ async function processMessageWithPipeline(params: {
spaceId,
runtime,
core,
config,
statusSink,
typingMessageName,
});
@@ -725,10 +726,11 @@ async function deliverGoogleChatReply(params: {
spaceId: string;
runtime: GoogleChatRuntimeEnv;
core: GoogleChatCoreRuntime;
config: ClawdbotConfig;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
typingMessageName?: string;
}): Promise<void> {
const { payload, account, spaceId, runtime, core, statusSink, typingMessageName } = params;
const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
@@ -799,7 +801,16 @@ async function deliverGoogleChatReply(params: {
if (payload.text) {
const chunkLimit = account.config.textChunkLimit ?? 4000;
const chunks = core.channel.text.chunkMarkdownText(payload.text, chunkLimit);
const chunkMode = core.channel.text.resolveChunkMode(
config,
"googlechat",
account.accountId,
);
const chunks = core.channel.text.chunkMarkdownTextWithMode(
payload.text,
chunkLimit,
chunkMode,
);
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
try {

View File

@@ -186,6 +186,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),
chunkerMode: "text",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;

View File

@@ -50,6 +50,7 @@ export const MatrixConfigSchema = z.object({
replyToMode: z.enum(["off", "first", "all"]).optional(),
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
textChunkLimit: z.number().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
mediaMaxMb: z.number().optional(),
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
autoJoinAllowlist: z.array(allowFromEntry).optional(),

View File

@@ -16,10 +16,11 @@ export async function deliverMatrixReplies(params: {
tableMode?: MarkdownTableMode;
}): Promise<void> {
const core = getMatrixRuntime();
const cfg = core.config.loadConfig();
const tableMode =
params.tableMode ??
core.channel.text.resolveMarkdownTableMode({
cfg: core.config.loadConfig(),
cfg,
channel: "matrix",
accountId: params.accountId,
});
@@ -29,6 +30,7 @@ export async function deliverMatrixReplies(params: {
}
};
const chunkLimit = Math.min(params.textLimit, 4000);
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId);
let hasReplied = false;
for (const reply of params.replies) {
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
@@ -54,7 +56,11 @@ export async function deliverMatrixReplies(params: {
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
if (mediaList.length === 0) {
for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) {
for (const chunk of core.channel.text.chunkMarkdownTextWithMode(
text,
chunkLimit,
chunkMode,
)) {
const trimmed = chunk.trim();
if (!trimmed) continue;
await sendMessageMatrix(params.roomId, trimmed, {

View File

@@ -42,7 +42,9 @@ const runtimeStub = {
channel: {
text: {
resolveTextChunkLimit: () => 4000,
resolveChunkMode: () => "length",
chunkMarkdownText: (text: string) => (text ? [text] : []),
chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []),
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},

View File

@@ -61,7 +61,12 @@ export async function sendMessageMatrix(
);
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit);
const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId);
const chunks = getCore().channel.text.chunkMarkdownTextWithMode(
convertedMessage,
chunkLimit,
chunkMode,
);
const threadId = normalizeThreadId(opts.threadId);
const relation = threadId
? buildThreadRelation(threadId, opts.replyToId)

View File

@@ -6,6 +6,7 @@ import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
export const matrixOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ to, text, deps, replyToId, threadId }) => {
const send = deps?.sendMatrix ?? sendMessageMatrix;

View File

@@ -69,6 +69,8 @@ export type MatrixConfig = {
threadReplies?: "off" | "inbound" | "always";
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Max outbound media size in MB. */
mediaMaxMb?: number;
/** Auto-join invites (always|allowlist|off). Default: always. */

View File

@@ -158,6 +158,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();

View File

@@ -25,6 +25,7 @@ const MattermostAccountSchemaBase = z
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
})

View File

@@ -738,7 +738,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
if (mediaUrls.length === 0) {
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
const chunkMode = core.channel.text.resolveChunkMode(
cfg,
"mattermost",
account.accountId,
);
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
for (const chunk of chunks.length > 0 ? chunks : [text]) {
if (!chunk) continue;
await sendMessageMattermost(to, chunk, {

View File

@@ -36,6 +36,8 @@ export type MattermostAccountConfig = {
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";
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */

View File

@@ -9,18 +9,21 @@ import {
} from "./messenger.js";
import { setMSTeamsRuntime } from "./runtime.js";
const chunkMarkdownText = (text: string, limit: number) => {
if (!text) return [];
if (limit <= 0 || text.length <= limit) return [text];
const chunks: string[] = [];
for (let index = 0; index < text.length; index += limit) {
chunks.push(text.slice(index, index + limit));
}
return chunks;
};
const runtimeStub = {
channel: {
text: {
chunkMarkdownText: (text: string, limit: number) => {
if (!text) return [];
if (limit <= 0 || text.length <= limit) return [text];
const chunks: string[] = [];
for (let index = 0; index < text.length; index += limit) {
chunks.push(text.slice(index, index + limit));
}
return chunks;
},
chunkMarkdownText,
chunkMarkdownTextWithMode: chunkMarkdownText,
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},

View File

@@ -1,4 +1,5 @@
import {
type ChunkMode,
isSilentReplyText,
loadWebMedia,
type MarkdownTableMode,
@@ -63,6 +64,7 @@ export type MSTeamsReplyRenderOptions = {
chunkText?: boolean;
mediaMode?: "split" | "inline";
tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
};
/**
@@ -129,11 +131,16 @@ function pushTextMessages(
opts: {
chunkText: boolean;
chunkLimit: number;
chunkMode: ChunkMode;
},
) {
if (!text) return;
if (opts.chunkText) {
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownTextWithMode(
text,
opts.chunkLimit,
opts.chunkMode,
)) {
const trimmed = chunk.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
out.push({ text: trimmed });
@@ -197,6 +204,7 @@ export function renderReplyPayloadsToMessages(
const out: MSTeamsRenderedMessage[] = [];
const chunkLimit = Math.min(options.textChunkLimit, 4000);
const chunkText = options.chunkText !== false;
const chunkMode = options.chunkMode ?? "length";
const mediaMode = options.mediaMode ?? "split";
const tableMode =
options.tableMode ??
@@ -215,7 +223,7 @@ export function renderReplyPayloadsToMessages(
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
pushTextMessages(out, text, { chunkText, chunkLimit });
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
continue;
}
@@ -229,13 +237,13 @@ export function renderReplyPayloadsToMessages(
if (mediaList[i]) out.push({ mediaUrl: mediaList[i] });
}
} else {
pushTextMessages(out, text, { chunkText, chunkLimit });
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
}
continue;
}
// mediaMode === "split"
pushTextMessages(out, text, { chunkText, chunkLimit });
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
for (const mediaUrl of mediaList) {
if (!mediaUrl) continue;
out.push({ mediaUrl });

View File

@@ -7,6 +7,7 @@ import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
export const msteamsOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
pollMaxOptions: 12,
sendText: async ({ cfg, to, text, deps }) => {

View File

@@ -59,6 +59,7 @@ export function createMSTeamsReplyDispatcher(params: {
cfg: params.cfg,
agentId: params.agentId,
});
const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "msteams");
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
@@ -75,6 +76,7 @@ export function createMSTeamsReplyDispatcher(params: {
chunkText: true,
mediaMode: "split",
tableMode,
chunkMode,
});
const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,

View File

@@ -247,6 +247,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, replyToId }) => {
const result = await sendMessageNextcloudTalk(to, text, {

View File

@@ -44,6 +44,7 @@ export const NextcloudTalkAccountSchemaBase = 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(),

View File

@@ -62,6 +62,8 @@ export type NextcloudTalkAccountConfig = {
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;
/** Merge streamed block replies before sending. */

View File

@@ -207,6 +207,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit),
chunkerMode: "text",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;

View File

@@ -251,6 +251,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
const send =

View File

@@ -276,6 +276,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
outbound: {
deliveryMode: "gateway",
chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit),
chunkerMode: "text",
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to, allowFrom, mode }) => {

View File

@@ -288,6 +288,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
if (remaining.length) chunks.push(remaining);
return chunks;
},
chunkerMode: "text",
textChunkLimit: 2000,
sendText: async ({ to, text, accountId, cfg }) => {
const result = await sendMessageZalo(to, text, {

View File

@@ -596,6 +596,8 @@ async function processMessageWithPipeline(params: {
chatId,
runtime,
core,
config,
accountId: account.accountId,
statusSink,
fetcher,
tableMode,
@@ -614,11 +616,13 @@ async function deliverZaloReply(params: {
chatId: string;
runtime: ZaloRuntimeEnv;
core: ZaloCoreRuntime;
config: ClawdbotConfig;
accountId?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
@@ -644,7 +648,12 @@ async function deliverZaloReply(params: {
}
if (text) {
const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT);
const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
const chunks = core.channel.text.chunkMarkdownTextWithMode(
text,
ZALO_TEXT_LIMIT,
chunkMode,
);
for (const chunk of chunks) {
try {
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);

View File

@@ -506,6 +506,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
if (remaining.length) chunks.push(remaining);
return chunks;
},
chunkerMode: "text",
textChunkLimit: 2000,
sendText: async ({ to, text, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });

View File

@@ -332,6 +332,8 @@ async function processMessage(
isGroup,
runtime,
core,
config,
accountId: account.accountId,
statusSink,
tableMode: core.channel.text.resolveMarkdownTableMode({
cfg: config,
@@ -356,10 +358,13 @@ async function deliverZalouserReply(params: {
isGroup: boolean;
runtime: RuntimeEnv;
core: ZalouserCoreRuntime;
config: ClawdbotConfig;
accountId?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
const { payload, profile, chatId, isGroup, runtime, core, config, accountId, statusSink } =
params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
@@ -390,7 +395,12 @@ async function deliverZalouserReply(params: {
}
if (text) {
const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT);
const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
const chunks = core.channel.text.chunkMarkdownTextWithMode(
text,
ZALOUSER_TEXT_LIMIT,
chunkMode,
);
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
for (const chunk of chunks) {
try {