fix: newline chunking across channels
This commit is contained in:
@@ -36,6 +36,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||||
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||||
- Google Chat: normalize space targets without double `spaces/` prefix.
|
- Google Chat: normalize space targets without double `spaces/` prefix.
|
||||||
|
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
||||||
|
|
||||||
## 2026.1.23-1
|
## 2026.1.23-1
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ Notes:
|
|||||||
## Capabilities & limits
|
## Capabilities & limits
|
||||||
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
||||||
- Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17).
|
- Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17).
|
||||||
|
- Optional newline chunking: set `channels.discord.chunkMode="newline"` to split on each line before length chunking.
|
||||||
- File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB).
|
- File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB).
|
||||||
- Mention-gated guild replies by default to avoid noisy bots.
|
- Mention-gated guild replies by default to avoid noisy bots.
|
||||||
- Reply context is injected when a message references another message (quoted content + ids).
|
- Reply context is injected when a message references another message (quoted content + ids).
|
||||||
@@ -306,6 +307,7 @@ ack reaction after the bot replies.
|
|||||||
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||||
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
||||||
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
|
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
|
||||||
|
- `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline before length chunking.
|
||||||
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
|
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
|
||||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
|
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ This is useful when you want an isolated personality/model for a specific thread
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000).
|
||||||
|
- Optional newline chunking: set `channels.imessage.chunkMode="newline"` to split on each line before length chunking.
|
||||||
- Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16).
|
- Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16).
|
||||||
|
|
||||||
## Addressing / delivery targets
|
## Addressing / delivery targets
|
||||||
@@ -253,6 +254,7 @@ Provider options:
|
|||||||
- `channels.imessage.includeAttachments`: ingest attachments into context.
|
- `channels.imessage.includeAttachments`: ingest attachments into context.
|
||||||
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
- `channels.imessage.textChunkLimit`: outbound chunk size (chars).
|
- `channels.imessage.textChunkLimit`: outbound chunk size (chars).
|
||||||
|
- `channels.imessage.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||||
|
|
||||||
Related global options:
|
Related global options:
|
||||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
|
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ Provider options:
|
|||||||
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
||||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
||||||
|
- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||||
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||||
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
||||||
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
||||||
|
|||||||
@@ -415,6 +415,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
|||||||
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
||||||
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
|
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
|
||||||
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
||||||
|
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||||
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
||||||
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
|
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
|
||||||
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ Provider options:
|
|||||||
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
|
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
|
||||||
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
|
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
|
||||||
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
|
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
|
||||||
|
- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||||
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
|
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
|
||||||
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
|
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
|
||||||
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
|
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ Groups:
|
|||||||
|
|
||||||
## Media + limits
|
## Media + limits
|
||||||
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
|
||||||
|
- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on each line before length chunking.
|
||||||
- Attachments supported (base64 fetched from `signal-cli`).
|
- Attachments supported (base64 fetched from `signal-cli`).
|
||||||
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
|
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
|
||||||
- Use `channels.signal.ignoreAttachments` to skip downloading media.
|
- Use `channels.signal.ignoreAttachments` to skip downloading media.
|
||||||
@@ -152,6 +153,7 @@ Provider options:
|
|||||||
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
||||||
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
|
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
|
||||||
- `channels.signal.textChunkLimit`: outbound chunk size (chars).
|
- `channels.signal.textChunkLimit`: outbound chunk size (chars).
|
||||||
|
- `channels.signal.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||||
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
|
|
||||||
Related global options:
|
Related global options:
|
||||||
|
|||||||
@@ -349,6 +349,7 @@ ack reaction after the bot replies.
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000).
|
||||||
|
- Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on each line before length chunking.
|
||||||
- Media uploads are capped by `channels.slack.mediaMaxMb` (default 20).
|
- Media uploads are capped by `channels.slack.mediaMaxMb` (default 20).
|
||||||
|
|
||||||
## Reply threading
|
## Reply threading
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ Notes:
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
||||||
|
- Optional newline chunking: set `channels.telegram.chunkMode="newline"` to split on each line before length chunking.
|
||||||
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
|
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
|
||||||
- Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs.
|
- Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs.
|
||||||
- Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
- Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||||
@@ -516,6 +517,7 @@ Provider options:
|
|||||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
||||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||||
|
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
|
||||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||||
|
|||||||
@@ -271,12 +271,13 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000).
|
||||||
|
- Optional newline chunking: set `channels.whatsapp.chunkMode="newline"` to split on each line before length chunking.
|
||||||
- Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB).
|
- Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB).
|
||||||
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
|
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
|
||||||
|
|
||||||
## Outbound send (text + media)
|
## Outbound send (text + media)
|
||||||
- Uses active web listener; error if gateway not running.
|
- Uses active web listener; error if gateway not running.
|
||||||
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`).
|
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`, optional `channels.whatsapp.chunkMode`).
|
||||||
- Media:
|
- Media:
|
||||||
- Image/video/audio/document supported.
|
- Image/video/audio/document supported.
|
||||||
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
|
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ Legend:
|
|||||||
- `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
|
- `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
|
||||||
- `agents.defaults.blockStreamingCoalesce`: `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send).
|
- `agents.defaults.blockStreamingCoalesce`: `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send).
|
||||||
- Channel hard cap: `*.textChunkLimit` (e.g., `channels.whatsapp.textChunkLimit`).
|
- Channel hard cap: `*.textChunkLimit` (e.g., `channels.whatsapp.textChunkLimit`).
|
||||||
|
- Channel chunk mode: `*.chunkMode` (`length` default, `newline` splits on each line before length chunking).
|
||||||
- Discord soft cap: `channels.discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping.
|
- Discord soft cap: `channels.discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping.
|
||||||
|
|
||||||
**Boundary semantics:**
|
**Boundary semantics:**
|
||||||
|
|||||||
@@ -504,6 +504,7 @@ For groups, use `channels.whatsapp.groupPolicy` + `channels.whatsapp.groupAllowF
|
|||||||
dmPolicy: "pairing", // pairing | allowlist | open | disabled
|
dmPolicy: "pairing", // pairing | allowlist | open | disabled
|
||||||
allowFrom: ["+15555550123", "+447700900123"],
|
allowFrom: ["+15555550123", "+447700900123"],
|
||||||
textChunkLimit: 4000, // optional outbound chunk size (chars)
|
textChunkLimit: 4000, // optional outbound chunk size (chars)
|
||||||
|
chunkMode: "length", // optional chunking mode (length | newline)
|
||||||
mediaMaxMb: 50 // optional inbound media cap (MB)
|
mediaMaxMb: 50 // optional inbound media cap (MB)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1105,6 +1106,7 @@ Multi-account support lives under `channels.discord.accounts` (see the multi-acc
|
|||||||
},
|
},
|
||||||
historyLimit: 20, // include last N guild messages as context
|
historyLimit: 20, // include last N guild messages as context
|
||||||
textChunkLimit: 2000, // optional outbound text chunk size (chars)
|
textChunkLimit: 2000, // optional outbound text chunk size (chars)
|
||||||
|
chunkMode: "length", // optional chunking mode (length | newline)
|
||||||
maxLinesPerMessage: 17, // soft max lines per message (Discord UI clipping)
|
maxLinesPerMessage: 17, // soft max lines per message (Discord UI clipping)
|
||||||
retry: { // outbound retry policy
|
retry: { // outbound retry policy
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
@@ -1125,7 +1127,7 @@ Reaction notification modes:
|
|||||||
- `own`: reactions on the bot's own messages (default).
|
- `own`: reactions on the bot's own messages (default).
|
||||||
- `all`: all reactions on all messages.
|
- `all`: all reactions on all messages.
|
||||||
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
||||||
Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars.
|
Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Set `channels.discord.chunkMode="newline"` to split on line boundaries before length chunking. Discord clients can clip very tall messages, so `channels.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).
|
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
|
||||||
|
|
||||||
### `channels.googlechat` (Chat API webhook)
|
### `channels.googlechat` (Chat API webhook)
|
||||||
@@ -1218,6 +1220,7 @@ Slack runs in Socket Mode and requires both a bot token and app token:
|
|||||||
ephemeral: true
|
ephemeral: true
|
||||||
},
|
},
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
|
chunkMode: "length",
|
||||||
mediaMaxMb: 20
|
mediaMaxMb: 20
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1267,7 +1270,8 @@ Mattermost requires a bot token plus the base URL for your server:
|
|||||||
dmPolicy: "pairing",
|
dmPolicy: "pairing",
|
||||||
chatmode: "oncall", // oncall | onmessage | onchar
|
chatmode: "oncall", // oncall | onmessage | onchar
|
||||||
oncharPrefixes: [">", "!"],
|
oncharPrefixes: [">", "!"],
|
||||||
textChunkLimit: 4000
|
textChunkLimit: 4000,
|
||||||
|
chunkMode: "length"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,6 +374,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) =>
|
chunker: (text, limit) =>
|
||||||
getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),
|
getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
resolveTarget: ({ to, allowFrom, mode }) => {
|
||||||
const trimmed = to?.trim() ?? "";
|
const trimmed = to?.trim() ?? "";
|
||||||
|
|||||||
@@ -684,6 +684,7 @@ async function processMessageWithPipeline(params: {
|
|||||||
spaceId,
|
spaceId,
|
||||||
runtime,
|
runtime,
|
||||||
core,
|
core,
|
||||||
|
config,
|
||||||
statusSink,
|
statusSink,
|
||||||
typingMessageName,
|
typingMessageName,
|
||||||
});
|
});
|
||||||
@@ -725,10 +726,11 @@ async function deliverGoogleChatReply(params: {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
runtime: GoogleChatRuntimeEnv;
|
runtime: GoogleChatRuntimeEnv;
|
||||||
core: GoogleChatCoreRuntime;
|
core: GoogleChatCoreRuntime;
|
||||||
|
config: ClawdbotConfig;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
typingMessageName?: string;
|
typingMessageName?: string;
|
||||||
}): Promise<void> {
|
}): 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
|
const mediaList = payload.mediaUrls?.length
|
||||||
? payload.mediaUrls
|
? payload.mediaUrls
|
||||||
: payload.mediaUrl
|
: payload.mediaUrl
|
||||||
@@ -799,7 +801,16 @@ async function deliverGoogleChatReply(params: {
|
|||||||
|
|
||||||
if (payload.text) {
|
if (payload.text) {
|
||||||
const chunkLimit = account.config.textChunkLimit ?? 4000;
|
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++) {
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
const chunk = chunks[i];
|
const chunk = chunks[i];
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),
|
chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),
|
||||||
|
chunkerMode: "text",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||||
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
|
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const MatrixConfigSchema = z.object({
|
|||||||
replyToMode: z.enum(["off", "first", "all"]).optional(),
|
replyToMode: z.enum(["off", "first", "all"]).optional(),
|
||||||
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
|
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
|
||||||
textChunkLimit: z.number().optional(),
|
textChunkLimit: z.number().optional(),
|
||||||
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
mediaMaxMb: z.number().optional(),
|
mediaMaxMb: z.number().optional(),
|
||||||
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
||||||
autoJoinAllowlist: z.array(allowFromEntry).optional(),
|
autoJoinAllowlist: z.array(allowFromEntry).optional(),
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ export async function deliverMatrixReplies(params: {
|
|||||||
tableMode?: MarkdownTableMode;
|
tableMode?: MarkdownTableMode;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const core = getMatrixRuntime();
|
const core = getMatrixRuntime();
|
||||||
|
const cfg = core.config.loadConfig();
|
||||||
const tableMode =
|
const tableMode =
|
||||||
params.tableMode ??
|
params.tableMode ??
|
||||||
core.channel.text.resolveMarkdownTableMode({
|
core.channel.text.resolveMarkdownTableMode({
|
||||||
cfg: core.config.loadConfig(),
|
cfg,
|
||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
@@ -29,6 +30,7 @@ export async function deliverMatrixReplies(params: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||||
|
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId);
|
||||||
let hasReplied = false;
|
let hasReplied = false;
|
||||||
for (const reply of params.replies) {
|
for (const reply of params.replies) {
|
||||||
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
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);
|
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
||||||
|
|
||||||
if (mediaList.length === 0) {
|
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();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
await sendMessageMatrix(params.roomId, trimmed, {
|
await sendMessageMatrix(params.roomId, trimmed, {
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ const runtimeStub = {
|
|||||||
channel: {
|
channel: {
|
||||||
text: {
|
text: {
|
||||||
resolveTextChunkLimit: () => 4000,
|
resolveTextChunkLimit: () => 4000,
|
||||||
|
resolveChunkMode: () => "length",
|
||||||
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
||||||
|
chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []),
|
||||||
resolveMarkdownTableMode: () => "code",
|
resolveMarkdownTableMode: () => "code",
|
||||||
convertMarkdownTables: (text: string) => text,
|
convertMarkdownTables: (text: string) => text,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ export async function sendMessageMatrix(
|
|||||||
);
|
);
|
||||||
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
|
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||||
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
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 threadId = normalizeThreadId(opts.threadId);
|
||||||
const relation = threadId
|
const relation = threadId
|
||||||
? buildThreadRelation(threadId, opts.replyToId)
|
? buildThreadRelation(threadId, opts.replyToId)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
|
|||||||
export const matrixOutbound: ChannelOutboundAdapter = {
|
export const matrixOutbound: ChannelOutboundAdapter = {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
|
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ to, text, deps, replyToId, threadId }) => {
|
sendText: async ({ to, text, deps, replyToId, threadId }) => {
|
||||||
const send = deps?.sendMatrix ?? sendMessageMatrix;
|
const send = deps?.sendMatrix ?? sendMessageMatrix;
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export type MatrixConfig = {
|
|||||||
threadReplies?: "off" | "inbound" | "always";
|
threadReplies?: "off" | "inbound" | "always";
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
/** Max outbound media size in MB. */
|
/** Max outbound media size in MB. */
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
/** Auto-join invites (always|allowlist|off). Default: always. */
|
/** Auto-join invites (always|allowlist|off). Default: always. */
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit),
|
chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
resolveTarget: ({ to }) => {
|
resolveTarget: ({ to }) => {
|
||||||
const trimmed = to?.trim();
|
const trimmed = to?.trim();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const MattermostAccountSchemaBase = z
|
|||||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -738,7 +738,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||||
if (mediaUrls.length === 0) {
|
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]) {
|
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
||||||
if (!chunk) continue;
|
if (!chunk) continue;
|
||||||
await sendMessageMattermost(to, chunk, {
|
await sendMessageMattermost(to, chunk, {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export type MattermostAccountConfig = {
|
|||||||
groupPolicy?: GroupPolicy;
|
groupPolicy?: GroupPolicy;
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
/** Disable block streaming for this account. */
|
/** Disable block streaming for this account. */
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
|
|||||||
@@ -9,18 +9,21 @@ import {
|
|||||||
} from "./messenger.js";
|
} from "./messenger.js";
|
||||||
import { setMSTeamsRuntime } from "./runtime.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 = {
|
const runtimeStub = {
|
||||||
channel: {
|
channel: {
|
||||||
text: {
|
text: {
|
||||||
chunkMarkdownText: (text: string, limit: number) => {
|
chunkMarkdownText,
|
||||||
if (!text) return [];
|
chunkMarkdownTextWithMode: chunkMarkdownText,
|
||||||
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;
|
|
||||||
},
|
|
||||||
resolveMarkdownTableMode: () => "code",
|
resolveMarkdownTableMode: () => "code",
|
||||||
convertMarkdownTables: (text: string) => text,
|
convertMarkdownTables: (text: string) => text,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
type ChunkMode,
|
||||||
isSilentReplyText,
|
isSilentReplyText,
|
||||||
loadWebMedia,
|
loadWebMedia,
|
||||||
type MarkdownTableMode,
|
type MarkdownTableMode,
|
||||||
@@ -63,6 +64,7 @@ export type MSTeamsReplyRenderOptions = {
|
|||||||
chunkText?: boolean;
|
chunkText?: boolean;
|
||||||
mediaMode?: "split" | "inline";
|
mediaMode?: "split" | "inline";
|
||||||
tableMode?: MarkdownTableMode;
|
tableMode?: MarkdownTableMode;
|
||||||
|
chunkMode?: ChunkMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,11 +131,16 @@ function pushTextMessages(
|
|||||||
opts: {
|
opts: {
|
||||||
chunkText: boolean;
|
chunkText: boolean;
|
||||||
chunkLimit: number;
|
chunkLimit: number;
|
||||||
|
chunkMode: ChunkMode;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (opts.chunkText) {
|
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();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
||||||
out.push({ text: trimmed });
|
out.push({ text: trimmed });
|
||||||
@@ -197,6 +204,7 @@ export function renderReplyPayloadsToMessages(
|
|||||||
const out: MSTeamsRenderedMessage[] = [];
|
const out: MSTeamsRenderedMessage[] = [];
|
||||||
const chunkLimit = Math.min(options.textChunkLimit, 4000);
|
const chunkLimit = Math.min(options.textChunkLimit, 4000);
|
||||||
const chunkText = options.chunkText !== false;
|
const chunkText = options.chunkText !== false;
|
||||||
|
const chunkMode = options.chunkMode ?? "length";
|
||||||
const mediaMode = options.mediaMode ?? "split";
|
const mediaMode = options.mediaMode ?? "split";
|
||||||
const tableMode =
|
const tableMode =
|
||||||
options.tableMode ??
|
options.tableMode ??
|
||||||
@@ -215,7 +223,7 @@ export function renderReplyPayloadsToMessages(
|
|||||||
if (!text && mediaList.length === 0) continue;
|
if (!text && mediaList.length === 0) continue;
|
||||||
|
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
pushTextMessages(out, text, { chunkText, chunkLimit });
|
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,13 +237,13 @@ export function renderReplyPayloadsToMessages(
|
|||||||
if (mediaList[i]) out.push({ mediaUrl: mediaList[i] });
|
if (mediaList[i]) out.push({ mediaUrl: mediaList[i] });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pushTextMessages(out, text, { chunkText, chunkLimit });
|
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// mediaMode === "split"
|
// mediaMode === "split"
|
||||||
pushTextMessages(out, text, { chunkText, chunkLimit });
|
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
|
||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of mediaList) {
|
||||||
if (!mediaUrl) continue;
|
if (!mediaUrl) continue;
|
||||||
out.push({ mediaUrl });
|
out.push({ mediaUrl });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
|
|||||||
export const msteamsOutbound: ChannelOutboundAdapter = {
|
export const msteamsOutbound: ChannelOutboundAdapter = {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
|
chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
pollMaxOptions: 12,
|
pollMaxOptions: 12,
|
||||||
sendText: async ({ cfg, to, text, deps }) => {
|
sendText: async ({ cfg, to, text, deps }) => {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
});
|
});
|
||||||
|
const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "msteams");
|
||||||
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
@@ -75,6 +76,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
chunkText: true,
|
chunkText: true,
|
||||||
mediaMode: "split",
|
mediaMode: "split",
|
||||||
tableMode,
|
tableMode,
|
||||||
|
chunkMode,
|
||||||
});
|
});
|
||||||
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
|||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
|
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ to, text, accountId, replyToId }) => {
|
sendText: async ({ to, text, accountId, replyToId }) => {
|
||||||
const result = await sendMessageNextcloudTalk(to, text, {
|
const result = await sendMessageNextcloudTalk(to, text, {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const NextcloudTalkAccountSchemaBase = z
|
|||||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export type NextcloudTalkAccountConfig = {
|
|||||||
dms?: Record<string, DmConfig>;
|
dms?: Record<string, DmConfig>;
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
/** Disable block streaming for this account. */
|
/** Disable block streaming for this account. */
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
|||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit),
|
chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit),
|
||||||
|
chunkerMode: "text",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||||
const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
|
const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
|||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit),
|
chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||||
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
|
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
|
||||||
const send =
|
const send =
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
|||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "gateway",
|
deliveryMode: "gateway",
|
||||||
chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit),
|
chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit),
|
||||||
|
chunkerMode: "text",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
pollMaxOptions: 12,
|
pollMaxOptions: 12,
|
||||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
resolveTarget: ({ to, allowFrom, mode }) => {
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
if (remaining.length) chunks.push(remaining);
|
if (remaining.length) chunks.push(remaining);
|
||||||
return chunks;
|
return chunks;
|
||||||
},
|
},
|
||||||
|
chunkerMode: "text",
|
||||||
textChunkLimit: 2000,
|
textChunkLimit: 2000,
|
||||||
sendText: async ({ to, text, accountId, cfg }) => {
|
sendText: async ({ to, text, accountId, cfg }) => {
|
||||||
const result = await sendMessageZalo(to, text, {
|
const result = await sendMessageZalo(to, text, {
|
||||||
|
|||||||
@@ -596,6 +596,8 @@ async function processMessageWithPipeline(params: {
|
|||||||
chatId,
|
chatId,
|
||||||
runtime,
|
runtime,
|
||||||
core,
|
core,
|
||||||
|
config,
|
||||||
|
accountId: account.accountId,
|
||||||
statusSink,
|
statusSink,
|
||||||
fetcher,
|
fetcher,
|
||||||
tableMode,
|
tableMode,
|
||||||
@@ -614,11 +616,13 @@ async function deliverZaloReply(params: {
|
|||||||
chatId: string;
|
chatId: string;
|
||||||
runtime: ZaloRuntimeEnv;
|
runtime: ZaloRuntimeEnv;
|
||||||
core: ZaloCoreRuntime;
|
core: ZaloCoreRuntime;
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
accountId?: string;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
fetcher?: ZaloFetch;
|
fetcher?: ZaloFetch;
|
||||||
tableMode?: MarkdownTableMode;
|
tableMode?: MarkdownTableMode;
|
||||||
}): Promise<void> {
|
}): 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 tableMode = params.tableMode ?? "code";
|
||||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||||
|
|
||||||
@@ -644,7 +648,12 @@ async function deliverZaloReply(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (text) {
|
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) {
|
for (const chunk of chunks) {
|
||||||
try {
|
try {
|
||||||
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
|
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
|
||||||
|
|||||||
@@ -506,6 +506,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
if (remaining.length) chunks.push(remaining);
|
if (remaining.length) chunks.push(remaining);
|
||||||
return chunks;
|
return chunks;
|
||||||
},
|
},
|
||||||
|
chunkerMode: "text",
|
||||||
textChunkLimit: 2000,
|
textChunkLimit: 2000,
|
||||||
sendText: async ({ to, text, accountId, cfg }) => {
|
sendText: async ({ to, text, accountId, cfg }) => {
|
||||||
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
|
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
|
||||||
|
|||||||
@@ -332,6 +332,8 @@ async function processMessage(
|
|||||||
isGroup,
|
isGroup,
|
||||||
runtime,
|
runtime,
|
||||||
core,
|
core,
|
||||||
|
config,
|
||||||
|
accountId: account.accountId,
|
||||||
statusSink,
|
statusSink,
|
||||||
tableMode: core.channel.text.resolveMarkdownTableMode({
|
tableMode: core.channel.text.resolveMarkdownTableMode({
|
||||||
cfg: config,
|
cfg: config,
|
||||||
@@ -356,10 +358,13 @@ async function deliverZalouserReply(params: {
|
|||||||
isGroup: boolean;
|
isGroup: boolean;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
core: ZalouserCoreRuntime;
|
core: ZalouserCoreRuntime;
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
accountId?: string;
|
||||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
tableMode?: MarkdownTableMode;
|
tableMode?: MarkdownTableMode;
|
||||||
}): Promise<void> {
|
}): 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 tableMode = params.tableMode ?? "code";
|
||||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||||
|
|
||||||
@@ -390,7 +395,12 @@ async function deliverZalouserReply(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (text) {
|
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}`);
|
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
chunkByNewline,
|
chunkByNewline,
|
||||||
chunkMarkdownText,
|
chunkMarkdownText,
|
||||||
|
chunkMarkdownTextWithMode,
|
||||||
chunkText,
|
chunkText,
|
||||||
chunkTextWithMode,
|
chunkTextWithMode,
|
||||||
resolveChunkMode,
|
resolveChunkMode,
|
||||||
@@ -246,10 +247,10 @@ describe("chunkByNewline", () => {
|
|||||||
expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
|
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 text = "Line one\n\n\nLine two\n\nLine three";
|
||||||
const chunks = chunkByNewline(text, 1000);
|
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", () => {
|
it("trims whitespace from lines", () => {
|
||||||
@@ -258,6 +259,12 @@ describe("chunkByNewline", () => {
|
|||||||
expect(chunks).toEqual(["Line one", "Line two"]);
|
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", () => {
|
it("falls back to length-based for long lines", () => {
|
||||||
const text = "Short line\n" + "a".repeat(50) + "\nAnother short";
|
const text = "Short line\n" + "a".repeat(50) + "\nAnother short";
|
||||||
const chunks = chunkByNewline(text, 20);
|
const chunks = chunkByNewline(text, 20);
|
||||||
@@ -269,6 +276,12 @@ describe("chunkByNewline", () => {
|
|||||||
expect(chunks[4]).toBe("Another short");
|
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", () => {
|
it("returns empty array for empty input", () => {
|
||||||
expect(chunkByNewline("", 100)).toEqual([]);
|
expect(chunkByNewline("", 100)).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -276,6 +289,18 @@ describe("chunkByNewline", () => {
|
|||||||
it("returns empty array for whitespace-only input", () => {
|
it("returns empty array for whitespace-only input", () => {
|
||||||
expect(chunkByNewline(" \n\n ", 100)).toEqual([]);
|
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", () => {
|
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", () => {
|
describe("resolveChunkMode", () => {
|
||||||
it("returns length as default", () => {
|
it("returns length as default", () => {
|
||||||
expect(resolveChunkMode(undefined, "telegram")).toBe("length");
|
expect(resolveChunkMode(undefined, "telegram")).toBe("length");
|
||||||
@@ -304,16 +349,16 @@ describe("resolveChunkMode", () => {
|
|||||||
expect(resolveChunkMode(cfg, "__internal__")).toBe("length");
|
expect(resolveChunkMode(cfg, "__internal__")).toBe("length");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports provider-level overrides for bluebubbles", () => {
|
it("supports provider-level overrides for slack", () => {
|
||||||
const cfg = { channels: { bluebubbles: { chunkMode: "newline" as const } } };
|
const cfg = { channels: { slack: { chunkMode: "newline" as const } } };
|
||||||
expect(resolveChunkMode(cfg, "bluebubbles")).toBe("newline");
|
expect(resolveChunkMode(cfg, "slack")).toBe("newline");
|
||||||
expect(resolveChunkMode(cfg, "discord")).toBe("length");
|
expect(resolveChunkMode(cfg, "discord")).toBe("length");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports account-level overrides for bluebubbles", () => {
|
it("supports account-level overrides for slack", () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
bluebubbles: {
|
slack: {
|
||||||
chunkMode: "length" as const,
|
chunkMode: "length" as const,
|
||||||
accounts: {
|
accounts: {
|
||||||
primary: { chunkMode: "newline" as const },
|
primary: { chunkMode: "newline" as const },
|
||||||
@@ -321,12 +366,7 @@ describe("resolveChunkMode", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
expect(resolveChunkMode(cfg, "bluebubbles", "primary")).toBe("newline");
|
expect(resolveChunkMode(cfg, "slack", "primary")).toBe("newline");
|
||||||
expect(resolveChunkMode(cfg, "bluebubbles", "other")).toBe("length");
|
expect(resolveChunkMode(cfg, "slack", "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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,8 +101,6 @@ export function resolveChunkMode(
|
|||||||
accountId?: string | null,
|
accountId?: string | null,
|
||||||
): ChunkMode {
|
): ChunkMode {
|
||||||
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return DEFAULT_CHUNK_MODE;
|
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 channelsConfig = cfg?.channels as Record<string, unknown> | undefined;
|
||||||
const providerConfig = (channelsConfig?.[provider] ??
|
const providerConfig = (channelsConfig?.[provider] ??
|
||||||
(cfg as Record<string, unknown> | undefined)?.[provider]) as ProviderChunkConfig | undefined;
|
(cfg as Record<string, unknown> | undefined)?.[provider]) as ProviderChunkConfig | undefined;
|
||||||
@@ -111,25 +109,56 @@ export function resolveChunkMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split text on newlines, filtering empty lines.
|
* Split text on newlines, trimming line whitespace.
|
||||||
* Lines exceeding maxLineLength are further split using length-based chunking.
|
* 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 [];
|
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[] = [];
|
const chunks: string[] = [];
|
||||||
|
let pendingBlankLines = 0;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue; // skip empty lines
|
if (!trimmed) {
|
||||||
|
pendingBlankLines += 1;
|
||||||
if (trimmed.length <= maxLineLength) {
|
continue;
|
||||||
chunks.push(trimmed);
|
|
||||||
} else {
|
|
||||||
// Long line: fall back to length-based chunking
|
|
||||||
const subChunks = chunkText(trimmed, maxLineLength);
|
|
||||||
chunks.push(...subChunks);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
return chunks;
|
||||||
@@ -140,11 +169,59 @@ export function chunkByNewline(text: string, maxLineLength: number): string[] {
|
|||||||
*/
|
*/
|
||||||
export function chunkTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
|
export function chunkTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
|
||||||
if (mode === "newline") {
|
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);
|
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[] {
|
export function chunkText(text: string, limit: number): string[] {
|
||||||
if (!text) return [];
|
if (!text) return [];
|
||||||
if (limit <= 0) return [text];
|
if (limit <= 0) return [text];
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function resolveBlockStreamingChunking(
|
|||||||
});
|
});
|
||||||
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
|
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);
|
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
|
||||||
if (channelChunkMode === "newline") {
|
if (channelChunkMode === "newline") {
|
||||||
// For newline mode: use very low minChars to flush quickly on newlines
|
// For newline mode: use very low minChars to flush quickly on newlines
|
||||||
@@ -103,7 +103,7 @@ export function resolveBlockStreamingCoalescing(
|
|||||||
): BlockStreamingCoalescing | undefined {
|
): BlockStreamingCoalescing | undefined {
|
||||||
const providerKey = normalizeChunkProvider(provider);
|
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);
|
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
|
||||||
if (channelChunkMode === "newline") {
|
if (channelChunkMode === "newline") {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ChannelOutboundAdapter } from "../types.js";
|
|||||||
export const imessageOutbound: ChannelOutboundAdapter = {
|
export const imessageOutbound: ChannelOutboundAdapter = {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: chunkText,
|
chunker: chunkText,
|
||||||
|
chunkerMode: "text",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||||
const send = deps?.sendIMessage ?? sendMessageIMessage;
|
const send = deps?.sendIMessage ?? sendMessageIMessage;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ChannelOutboundAdapter } from "../types.js";
|
|||||||
export const signalOutbound: ChannelOutboundAdapter = {
|
export const signalOutbound: ChannelOutboundAdapter = {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: chunkText,
|
chunker: chunkText,
|
||||||
|
chunkerMode: "text",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||||
const send = deps?.sendSignal ?? sendMessageSignal;
|
const send = deps?.sendSignal ?? sendMessageSignal;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ function parseThreadId(threadId?: string | number | null) {
|
|||||||
export const telegramOutbound: ChannelOutboundAdapter = {
|
export const telegramOutbound: ChannelOutboundAdapter = {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: markdownToTelegramHtmlChunks,
|
chunker: markdownToTelegramHtmlChunks,
|
||||||
|
chunkerMode: "markdown",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
|
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
|
||||||
const send = deps?.sendTelegram ?? sendMessageTelegram;
|
const send = deps?.sendTelegram ?? sendMessageTelegram;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { missingTargetError } from "../../../infra/outbound/target-errors.js";
|
|||||||
export const whatsappOutbound: ChannelOutboundAdapter = {
|
export const whatsappOutbound: ChannelOutboundAdapter = {
|
||||||
deliveryMode: "gateway",
|
deliveryMode: "gateway",
|
||||||
chunker: chunkText,
|
chunker: chunkText,
|
||||||
|
chunkerMode: "text",
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
pollMaxOptions: 12,
|
pollMaxOptions: 12,
|
||||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
resolveTarget: ({ to, allowFrom, mode }) => {
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export type ChannelOutboundContext = {
|
|||||||
export type ChannelOutboundAdapter = {
|
export type ChannelOutboundAdapter = {
|
||||||
deliveryMode: "direct" | "gateway" | "hybrid";
|
deliveryMode: "direct" | "gateway" | "hybrid";
|
||||||
chunker?: ((text: string, limit: number) => string[]) | null;
|
chunker?: ((text: string, limit: number) => string[]) | null;
|
||||||
|
chunkerMode?: "text" | "markdown";
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
pollMaxOptions?: number;
|
pollMaxOptions?: number;
|
||||||
resolveTarget?: (params: {
|
resolveTarget?: (params: {
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export type DiscordAccountConfig = {
|
|||||||
groupPolicy?: GroupPolicy;
|
groupPolicy?: GroupPolicy;
|
||||||
/** Outbound text chunk size (chars). Default: 2000. */
|
/** Outbound text chunk size (chars). Default: 2000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
/** Disable block streaming for this account. */
|
/** Disable block streaming for this account. */
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export type GoogleChatAccountConfig = {
|
|||||||
dms?: Record<string, DmConfig>;
|
dms?: Record<string, DmConfig>;
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export type IMessageAccountConfig = {
|
|||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export type MSTeamsConfig = {
|
|||||||
groupPolicy?: GroupPolicy;
|
groupPolicy?: GroupPolicy;
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export type SignalAccountConfig = {
|
|||||||
dms?: Record<string, DmConfig>;
|
dms?: Record<string, DmConfig>;
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ export type SlackAccountConfig = {
|
|||||||
/** Per-DM config overrides keyed by user ID. */
|
/** Per-DM config overrides keyed by user ID. */
|
||||||
dms?: Record<string, DmConfig>;
|
dms?: Record<string, DmConfig>;
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export type TelegramAccountConfig = {
|
|||||||
dms?: Record<string, DmConfig>;
|
dms?: Record<string, DmConfig>;
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
/** Disable block streaming for this account. */
|
/** Disable block streaming for this account. */
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
/** Chunking config for draft streaming in `streamMode: "block"`. */
|
/** Chunking config for draft streaming in `streamMode: "block"`. */
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export type WhatsAppConfig = {
|
|||||||
dms?: Record<string, DmConfig>;
|
dms?: Record<string, DmConfig>;
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
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. */
|
/** Maximum media file size in MB. Default: 50. */
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
/** Disable block streaming for this account. */
|
/** Disable block streaming for this account. */
|
||||||
@@ -122,6 +124,8 @@ export type WhatsAppAccountConfig = {
|
|||||||
/** Per-DM config overrides keyed by user ID. */
|
/** Per-DM config overrides keyed by user ID. */
|
||||||
dms?: Record<string, DmConfig>;
|
dms?: Record<string, DmConfig>;
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const TelegramAccountSchemaBase = z
|
|||||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
draftChunk: BlockStreamingChunkSchema.optional(),
|
draftChunk: BlockStreamingChunkSchema.optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
@@ -212,6 +213,7 @@ export const DiscordAccountSchema = z
|
|||||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
maxLinesPerMessage: z.number().int().positive().optional(),
|
maxLinesPerMessage: z.number().int().positive().optional(),
|
||||||
@@ -310,6 +312,7 @@ export const GoogleChatAccountSchema = z
|
|||||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
@@ -401,6 +404,7 @@ export const SlackAccountSchema = z
|
|||||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
@@ -494,6 +498,7 @@ export const SignalAccountSchemaBase = z
|
|||||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
mediaMaxMb: z.number().int().positive().optional(),
|
mediaMaxMb: z.number().int().positive().optional(),
|
||||||
@@ -554,6 +559,7 @@ export const IMessageAccountSchemaBase = z
|
|||||||
includeAttachments: z.boolean().optional(),
|
includeAttachments: z.boolean().optional(),
|
||||||
mediaMaxMb: z.number().int().positive().optional(),
|
mediaMaxMb: z.number().int().positive().optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
groups: z
|
groups: z
|
||||||
@@ -712,6 +718,7 @@ export const MSTeamsConfigSchema = z
|
|||||||
groupAllowFrom: z.array(z.string()).optional(),
|
groupAllowFrom: z.array(z.string()).optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
mediaAllowHosts: z.array(z.string()).optional(),
|
mediaAllowHosts: z.array(z.string()).optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const WhatsAppAccountSchema = z
|
|||||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
mediaMaxMb: z.number().int().positive().optional(),
|
mediaMaxMb: z.number().int().positive().optional(),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
@@ -85,6 +86,7 @@ export const WhatsAppConfigSchema = z
|
|||||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
mediaMaxMb: z.number().int().positive().optional().default(50),
|
mediaMaxMb: z.number().int().positive().optional().default(50),
|
||||||
blockStreaming: z.boolean().optional(),
|
blockStreaming: z.boolean().optional(),
|
||||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { chunkDiscordText } from "./chunk.js";
|
import { chunkDiscordText, chunkDiscordTextWithMode } from "./chunk.js";
|
||||||
|
|
||||||
function countLines(text: string) {
|
function countLines(text: string) {
|
||||||
return text.split("\n").length;
|
return text.split("\n").length;
|
||||||
@@ -51,6 +51,16 @@ describe("chunkDiscordText", () => {
|
|||||||
expect(chunks.at(-1)).toContain("Done.");
|
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", () => {
|
it("reserves space for closing fences when chunking", () => {
|
||||||
const body = "a".repeat(120);
|
const body = "a".repeat(120);
|
||||||
const text = `\`\`\`txt\n${body}\n\`\`\``;
|
const text = `\`\`\`txt\n${body}\n\`\`\``;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { chunkMarkdownTextWithMode, type ChunkMode } from "../auto-reply/chunk.js";
|
||||||
|
|
||||||
export type ChunkDiscordTextOpts = {
|
export type ChunkDiscordTextOpts = {
|
||||||
/** Max characters per Discord message. Default: 2000. */
|
/** Max characters per Discord message. Default: 2000. */
|
||||||
maxChars?: number;
|
maxChars?: number;
|
||||||
@@ -178,6 +180,31 @@ export function chunkDiscordText(text: string, opts: ChunkDiscordTextOpts = {}):
|
|||||||
return rebalanceReasoningItalics(text, chunks);
|
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 `_…_`.
|
// Keep italics intact for reasoning payloads that are wrapped once with `_…_`.
|
||||||
// When Discord chunking splits the message, we close italics at the end of
|
// 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
|
// each chunk and reopen at the start of the next so every chunk renders
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-di
|
|||||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
import { recordInboundSession } from "../../channels/session.js";
|
import { recordInboundSession } from "../../channels/session.js";
|
||||||
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
|
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
|
||||||
|
import { resolveChunkMode } from "../../auto-reply/chunk.js";
|
||||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||||
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
|
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
|
||||||
@@ -346,6 +347,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
textLimit,
|
textLimit,
|
||||||
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||||
tableMode,
|
tableMode,
|
||||||
|
chunkMode: resolveChunkMode(cfg, "discord", accountId),
|
||||||
});
|
});
|
||||||
replyReference.markSent();
|
replyReference.markSent();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10";
|
import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10";
|
||||||
|
|
||||||
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
|
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
|
||||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||||
import {
|
import {
|
||||||
buildCommandTextFromArgs,
|
buildCommandTextFromArgs,
|
||||||
findCommandByNativeName,
|
findCommandByNativeName,
|
||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
} from "../../pairing/pairing-store.js";
|
} from "../../pairing/pairing-store.js";
|
||||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||||
import { loadWebMedia } from "../../web/media.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 { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
||||||
import {
|
import {
|
||||||
allowListMatches,
|
allowListMatches,
|
||||||
@@ -767,6 +767,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
}),
|
}),
|
||||||
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||||
preferFollowUp: preferFollowUp || didReply,
|
preferFollowUp: preferFollowUp || didReply,
|
||||||
|
chunkMode: resolveChunkMode(cfg, "discord", accountId),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isDiscordUnknownInteraction(error)) {
|
if (isDiscordUnknownInteraction(error)) {
|
||||||
@@ -797,8 +798,9 @@ async function deliverDiscordInteractionReply(params: {
|
|||||||
textLimit: number;
|
textLimit: number;
|
||||||
maxLinesPerMessage?: number;
|
maxLinesPerMessage?: number;
|
||||||
preferFollowUp: boolean;
|
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 mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
const text = payload.text ?? "";
|
const text = payload.text ?? "";
|
||||||
|
|
||||||
@@ -838,10 +840,12 @@ async function deliverDiscordInteractionReply(params: {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const chunks = chunkDiscordText(text, {
|
const chunks = chunkDiscordTextWithMode(text, {
|
||||||
maxChars: textLimit,
|
maxChars: textLimit,
|
||||||
maxLines: maxLinesPerMessage,
|
maxLines: maxLinesPerMessage,
|
||||||
|
chunkMode,
|
||||||
});
|
});
|
||||||
|
if (!chunks.length && text) chunks.push(text);
|
||||||
const caption = chunks[0] ?? "";
|
const caption = chunks[0] ?? "";
|
||||||
await sendMessage(caption, media);
|
await sendMessage(caption, media);
|
||||||
for (const chunk of chunks.slice(1)) {
|
for (const chunk of chunks.slice(1)) {
|
||||||
@@ -852,10 +856,12 @@ async function deliverDiscordInteractionReply(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!text.trim()) return;
|
if (!text.trim()) return;
|
||||||
const chunks = chunkDiscordText(text, {
|
const chunks = chunkDiscordTextWithMode(text, {
|
||||||
maxChars: textLimit,
|
maxChars: textLimit,
|
||||||
maxLines: maxLinesPerMessage,
|
maxLines: maxLinesPerMessage,
|
||||||
|
chunkMode,
|
||||||
});
|
});
|
||||||
|
if (!chunks.length && text) chunks.push(text);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
if (!chunk.trim()) continue;
|
if (!chunk.trim()) continue;
|
||||||
await sendMessage(chunk);
|
await sendMessage(chunk);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { RequestClient } from "@buape/carbon";
|
import type { RequestClient } from "@buape/carbon";
|
||||||
|
|
||||||
|
import type { ChunkMode } from "../../auto-reply/chunk.js";
|
||||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
import type { MarkdownTableMode } from "../../config/types.base.js";
|
import type { MarkdownTableMode } from "../../config/types.base.js";
|
||||||
import { convertMarkdownTables } from "../../markdown/tables.js";
|
import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import { chunkDiscordText } from "../chunk.js";
|
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||||
import { sendMessageDiscord } from "../send.js";
|
import { sendMessageDiscord } from "../send.js";
|
||||||
|
|
||||||
export async function deliverDiscordReply(params: {
|
export async function deliverDiscordReply(params: {
|
||||||
@@ -18,6 +19,7 @@ export async function deliverDiscordReply(params: {
|
|||||||
maxLinesPerMessage?: number;
|
maxLinesPerMessage?: number;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
tableMode?: MarkdownTableMode;
|
tableMode?: MarkdownTableMode;
|
||||||
|
chunkMode?: ChunkMode;
|
||||||
}) {
|
}) {
|
||||||
const chunkLimit = Math.min(params.textLimit, 2000);
|
const chunkLimit = Math.min(params.textLimit, 2000);
|
||||||
for (const payload of params.replies) {
|
for (const payload of params.replies) {
|
||||||
@@ -30,10 +32,14 @@ export async function deliverDiscordReply(params: {
|
|||||||
|
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
let isFirstChunk = true;
|
let isFirstChunk = true;
|
||||||
for (const chunk of chunkDiscordText(text, {
|
const mode = params.chunkMode ?? "length";
|
||||||
|
const chunks = chunkDiscordTextWithMode(text, {
|
||||||
maxChars: chunkLimit,
|
maxChars: chunkLimit,
|
||||||
maxLines: params.maxLinesPerMessage,
|
maxLines: params.maxLinesPerMessage,
|
||||||
})) {
|
chunkMode: mode,
|
||||||
|
});
|
||||||
|
if (!chunks.length && text) chunks.push(text);
|
||||||
|
for (const chunk of chunks) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
await sendMessageDiscord(params.target, trimmed, {
|
await sendMessageDiscord(params.target, trimmed, {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { RequestClient } from "@buape/carbon";
|
import type { RequestClient } from "@buape/carbon";
|
||||||
import { Routes } from "discord-api-types/v10";
|
import { Routes } from "discord-api-types/v10";
|
||||||
|
import { resolveChunkMode } from "../auto-reply/chunk.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||||
@@ -45,6 +46,7 @@ export async function sendMessageDiscord(
|
|||||||
channel: "discord",
|
channel: "discord",
|
||||||
accountId: accountInfo.accountId,
|
accountId: accountInfo.accountId,
|
||||||
});
|
});
|
||||||
|
const chunkMode = resolveChunkMode(cfg, "discord", accountInfo.accountId);
|
||||||
const textWithTables = convertMarkdownTables(text ?? "", tableMode);
|
const textWithTables = convertMarkdownTables(text ?? "", tableMode);
|
||||||
const { token, rest, request } = createDiscordClient(opts, cfg);
|
const { token, rest, request } = createDiscordClient(opts, cfg);
|
||||||
const recipient = parseRecipient(to);
|
const recipient = parseRecipient(to);
|
||||||
@@ -61,6 +63,7 @@ export async function sendMessageDiscord(
|
|||||||
request,
|
request,
|
||||||
accountInfo.config.maxLinesPerMessage,
|
accountInfo.config.maxLinesPerMessage,
|
||||||
opts.embeds,
|
opts.embeds,
|
||||||
|
chunkMode,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
result = await sendDiscordText(
|
result = await sendDiscordText(
|
||||||
@@ -71,6 +74,7 @@ export async function sendMessageDiscord(
|
|||||||
request,
|
request,
|
||||||
accountInfo.config.maxLinesPerMessage,
|
accountInfo.config.maxLinesPerMessage,
|
||||||
opts.embeds,
|
opts.embeds,
|
||||||
|
chunkMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { createDiscordRetryRunner, type RetryRunner } from "../infra/retry-polic
|
|||||||
import { normalizePollDurationHours, normalizePollInput, type PollInput } from "../polls.js";
|
import { normalizePollDurationHours, normalizePollInput, type PollInput } from "../polls.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
import { resolveDiscordAccount } from "./accounts.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 { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js";
|
||||||
import { DiscordSendError } from "./send.types.js";
|
import { DiscordSendError } from "./send.types.js";
|
||||||
import { parseDiscordTarget } from "./targets.js";
|
import { parseDiscordTarget } from "./targets.js";
|
||||||
@@ -231,15 +232,18 @@ async function sendDiscordText(
|
|||||||
request: DiscordRequest,
|
request: DiscordRequest,
|
||||||
maxLinesPerMessage?: number,
|
maxLinesPerMessage?: number,
|
||||||
embeds?: unknown[],
|
embeds?: unknown[],
|
||||||
|
chunkMode?: ChunkMode,
|
||||||
) {
|
) {
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
throw new Error("Message must be non-empty for Discord sends");
|
throw new Error("Message must be non-empty for Discord sends");
|
||||||
}
|
}
|
||||||
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
||||||
const chunks = chunkDiscordText(text, {
|
const chunks = chunkDiscordTextWithMode(text, {
|
||||||
maxChars: DISCORD_TEXT_LIMIT,
|
maxChars: DISCORD_TEXT_LIMIT,
|
||||||
maxLines: maxLinesPerMessage,
|
maxLines: maxLinesPerMessage,
|
||||||
|
chunkMode,
|
||||||
});
|
});
|
||||||
|
if (!chunks.length && text) chunks.push(text);
|
||||||
if (chunks.length === 1) {
|
if (chunks.length === 1) {
|
||||||
const res = (await request(
|
const res = (await request(
|
||||||
() =>
|
() =>
|
||||||
@@ -285,14 +289,17 @@ async function sendDiscordMedia(
|
|||||||
request: DiscordRequest,
|
request: DiscordRequest,
|
||||||
maxLinesPerMessage?: number,
|
maxLinesPerMessage?: number,
|
||||||
embeds?: unknown[],
|
embeds?: unknown[],
|
||||||
|
chunkMode?: ChunkMode,
|
||||||
) {
|
) {
|
||||||
const media = await loadWebMedia(mediaUrl);
|
const media = await loadWebMedia(mediaUrl);
|
||||||
const chunks = text
|
const chunks = text
|
||||||
? chunkDiscordText(text, {
|
? chunkDiscordTextWithMode(text, {
|
||||||
maxChars: DISCORD_TEXT_LIMIT,
|
maxChars: DISCORD_TEXT_LIMIT,
|
||||||
maxLines: maxLinesPerMessage,
|
maxLines: maxLinesPerMessage,
|
||||||
|
chunkMode,
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
if (!chunks.length && text) chunks.push(text);
|
||||||
const caption = chunks[0] ?? "";
|
const caption = chunks[0] ?? "";
|
||||||
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
||||||
const res = (await request(
|
const res = (await request(
|
||||||
@@ -314,7 +321,16 @@ async function sendDiscordMedia(
|
|||||||
)) as { id: string; channel_id: string };
|
)) as { id: string; channel_id: string };
|
||||||
for (const chunk of chunks.slice(1)) {
|
for (const chunk of chunks.slice(1)) {
|
||||||
if (!chunk.trim()) continue;
|
if (!chunk.trim()) continue;
|
||||||
await sendDiscordText(rest, channelId, chunk, undefined, request, maxLinesPerMessage);
|
await sendDiscordText(
|
||||||
|
rest,
|
||||||
|
channelId,
|
||||||
|
chunk,
|
||||||
|
undefined,
|
||||||
|
request,
|
||||||
|
maxLinesPerMessage,
|
||||||
|
undefined,
|
||||||
|
chunkMode,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { loadConfig } from "../../config/config.js";
|
||||||
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||||
import { convertMarkdownTables } from "../../markdown/tables.js";
|
import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||||
@@ -23,13 +23,14 @@ export async function deliverReplies(params: {
|
|||||||
channel: "imessage",
|
channel: "imessage",
|
||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
|
const chunkMode = resolveChunkMode(cfg, "imessage", accountId);
|
||||||
for (const payload of replies) {
|
for (const payload of replies) {
|
||||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
const rawText = payload.text ?? "";
|
const rawText = payload.text ?? "";
|
||||||
const text = convertMarkdownTables(rawText, tableMode);
|
const text = convertMarkdownTables(rawText, tableMode);
|
||||||
if (!text && mediaList.length === 0) continue;
|
if (!text && mediaList.length === 0) continue;
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
for (const chunk of chunkText(text, textLimit)) {
|
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
|
||||||
await sendMessageIMessage(target, chunk, {
|
await sendMessageIMessage(target, chunk, {
|
||||||
maxBytes,
|
maxBytes,
|
||||||
client,
|
client,
|
||||||
|
|||||||
@@ -168,6 +168,84 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
expect(results.map((r) => r.messageId)).toEqual(["w1", "w2"]);
|
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 () => {
|
it("uses iMessage media maxBytes from agent fallback", async () => {
|
||||||
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1" });
|
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1" });
|
||||||
setActivePluginRegistry(
|
setActivePluginRegistry(
|
||||||
|
|||||||
@@ -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 type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js";
|
import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js";
|
||||||
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
|
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
|
||||||
@@ -62,6 +67,7 @@ type Chunker = (text: string, limit: number) => string[];
|
|||||||
|
|
||||||
type ChannelHandler = {
|
type ChannelHandler = {
|
||||||
chunker: Chunker | null;
|
chunker: Chunker | null;
|
||||||
|
chunkerMode?: "text" | "markdown";
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
sendText: (text: string) => Promise<OutboundDeliveryResult>;
|
sendText: (text: string) => Promise<OutboundDeliveryResult>;
|
||||||
sendMedia: (caption: string, mediaUrl: string) => Promise<OutboundDeliveryResult>;
|
sendMedia: (caption: string, mediaUrl: string) => Promise<OutboundDeliveryResult>;
|
||||||
@@ -121,8 +127,10 @@ function createPluginHandler(params: {
|
|||||||
const sendText = outbound.sendText;
|
const sendText = outbound.sendText;
|
||||||
const sendMedia = outbound.sendMedia;
|
const sendMedia = outbound.sendMedia;
|
||||||
const chunker = outbound.chunker ?? null;
|
const chunker = outbound.chunker ?? null;
|
||||||
|
const chunkerMode = outbound.chunkerMode;
|
||||||
return {
|
return {
|
||||||
chunker,
|
chunker,
|
||||||
|
chunkerMode,
|
||||||
textChunkLimit: outbound.textChunkLimit,
|
textChunkLimit: outbound.textChunkLimit,
|
||||||
sendText: async (text) =>
|
sendText: async (text) =>
|
||||||
sendText({
|
sendText({
|
||||||
@@ -192,6 +200,7 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
fallbackLimit: handler.textChunkLimit,
|
fallbackLimit: handler.textChunkLimit,
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const chunkMode = handler.chunker ? resolveChunkMode(cfg, channel, accountId) : "length";
|
||||||
const isSignalChannel = channel === "signal";
|
const isSignalChannel = channel === "signal";
|
||||||
const signalTableMode = isSignalChannel
|
const signalTableMode = isSignalChannel
|
||||||
? resolveMarkdownTableMode({ cfg, channel: "signal", accountId })
|
? resolveMarkdownTableMode({ cfg, channel: "signal", accountId })
|
||||||
@@ -212,6 +221,23 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
results.push(await handler.sendText(text));
|
results.push(await handler.sendText(text));
|
||||||
return;
|
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);
|
const chunks = handler.chunker(text, textLimit);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
throwIfAborted(abortSignal);
|
throwIfAborted(abortSignal);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
|
|||||||
import {
|
import {
|
||||||
chunkByNewline,
|
chunkByNewline,
|
||||||
chunkMarkdownText,
|
chunkMarkdownText,
|
||||||
|
chunkMarkdownTextWithMode,
|
||||||
chunkText,
|
chunkText,
|
||||||
chunkTextWithMode,
|
chunkTextWithMode,
|
||||||
resolveChunkMode,
|
resolveChunkMode,
|
||||||
@@ -170,6 +171,7 @@ export function createPluginRuntime(): PluginRuntime {
|
|||||||
text: {
|
text: {
|
||||||
chunkByNewline,
|
chunkByNewline,
|
||||||
chunkMarkdownText,
|
chunkMarkdownText,
|
||||||
|
chunkMarkdownTextWithMode,
|
||||||
chunkText,
|
chunkText,
|
||||||
chunkTextWithMode,
|
chunkTextWithMode,
|
||||||
resolveChunkMode,
|
resolveChunkMode,
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ type ResolveCommandAuthorizedFromAuthorizers =
|
|||||||
type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit;
|
type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit;
|
||||||
type ResolveChunkMode = typeof import("../../auto-reply/chunk.js").resolveChunkMode;
|
type ResolveChunkMode = typeof import("../../auto-reply/chunk.js").resolveChunkMode;
|
||||||
type ChunkMarkdownText = typeof import("../../auto-reply/chunk.js").chunkMarkdownText;
|
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 ChunkText = typeof import("../../auto-reply/chunk.js").chunkText;
|
||||||
type ChunkTextWithMode = typeof import("../../auto-reply/chunk.js").chunkTextWithMode;
|
type ChunkTextWithMode = typeof import("../../auto-reply/chunk.js").chunkTextWithMode;
|
||||||
type ChunkByNewline = typeof import("../../auto-reply/chunk.js").chunkByNewline;
|
type ChunkByNewline = typeof import("../../auto-reply/chunk.js").chunkByNewline;
|
||||||
@@ -180,6 +182,7 @@ export type PluginRuntime = {
|
|||||||
text: {
|
text: {
|
||||||
chunkByNewline: ChunkByNewline;
|
chunkByNewline: ChunkByNewline;
|
||||||
chunkMarkdownText: ChunkMarkdownText;
|
chunkMarkdownText: ChunkMarkdownText;
|
||||||
|
chunkMarkdownTextWithMode: ChunkMarkdownTextWithMode;
|
||||||
chunkText: ChunkText;
|
chunkText: ChunkText;
|
||||||
chunkTextWithMode: ChunkTextWithMode;
|
chunkTextWithMode: ChunkTextWithMode;
|
||||||
resolveChunkMode: ResolveChunkMode;
|
resolveChunkMode: ResolveChunkMode;
|
||||||
|
|||||||
@@ -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 { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
@@ -214,14 +214,16 @@ async function deliverReplies(params: {
|
|||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
textLimit: 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) {
|
for (const payload of replies) {
|
||||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
const text = payload.text ?? "";
|
const text = payload.text ?? "";
|
||||||
if (!text && mediaList.length === 0) continue;
|
if (!text && mediaList.length === 0) continue;
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
for (const chunk of chunkText(text, textLimit)) {
|
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
|
||||||
await sendMessageSignal(target, chunk, {
|
await sendMessageSignal(target, chunk, {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
account,
|
account,
|
||||||
@@ -262,6 +264,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
|||||||
);
|
);
|
||||||
const groupHistories = new Map<string, HistoryEntry[]>();
|
const groupHistories = new Map<string, HistoryEntry[]>();
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "signal", accountInfo.accountId);
|
const textLimit = resolveTextChunkLimit(cfg, "signal", accountInfo.accountId);
|
||||||
|
const chunkMode = resolveChunkMode(cfg, "signal", accountInfo.accountId);
|
||||||
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
|
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
|
||||||
const account = opts.account?.trim() || accountInfo.config.account?.trim();
|
const account = opts.account?.trim() || accountInfo.config.account?.trim();
|
||||||
const dmPolicy = accountInfo.config.dmPolicy ?? "pairing";
|
const dmPolicy = accountInfo.config.dmPolicy ?? "pairing";
|
||||||
@@ -340,7 +343,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
|||||||
sendReadReceipts,
|
sendReadReceipts,
|
||||||
readReceiptsViaDaemon,
|
readReceiptsViaDaemon,
|
||||||
fetchAttachment,
|
fetchAttachment,
|
||||||
deliverReplies,
|
deliverReplies: (params) => deliverReplies({ ...params, chunkMode }),
|
||||||
resolveSignalReactionTargets,
|
resolveSignalReactionTargets,
|
||||||
isSignalReactionMessage,
|
isSignalReactionMessage,
|
||||||
shouldEmitSignalReactionNotification,
|
shouldEmitSignalReactionNotification,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
|
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
|
||||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.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 { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
import type { MarkdownTableMode } from "../../config/types.base.js";
|
import type { MarkdownTableMode } from "../../config/types.base.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
@@ -118,6 +120,7 @@ export async function deliverSlackSlashReplies(params: {
|
|||||||
ephemeral: boolean;
|
ephemeral: boolean;
|
||||||
textLimit: number;
|
textLimit: number;
|
||||||
tableMode?: MarkdownTableMode;
|
tableMode?: MarkdownTableMode;
|
||||||
|
chunkMode?: ChunkMode;
|
||||||
}) {
|
}) {
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||||
@@ -129,9 +132,16 @@ export async function deliverSlackSlashReplies(params: {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
if (!combined) continue;
|
if (!combined) continue;
|
||||||
for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit, {
|
const chunkMode = params.chunkMode ?? "length";
|
||||||
tableMode: params.tableMode,
|
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);
|
messages.push(chunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt";
|
import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt";
|
||||||
import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js";
|
import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js";
|
||||||
|
import { resolveChunkMode } from "../../auto-reply/chunk.js";
|
||||||
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
||||||
import {
|
import {
|
||||||
buildCommandTextFromArgs,
|
buildCommandTextFromArgs,
|
||||||
@@ -429,6 +430,7 @@ export function registerSlackMonitorSlashCommands(params: {
|
|||||||
respond,
|
respond,
|
||||||
ephemeral: slashCommand.ephemeral,
|
ephemeral: slashCommand.ephemeral,
|
||||||
textLimit: ctx.textLimit,
|
textLimit: ctx.textLimit,
|
||||||
|
chunkMode: resolveChunkMode(cfg, "slack", route.accountId),
|
||||||
tableMode: resolveMarkdownTableMode({
|
tableMode: resolveMarkdownTableMode({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "slack",
|
channel: "slack",
|
||||||
@@ -448,6 +450,7 @@ export function registerSlackMonitorSlashCommands(params: {
|
|||||||
respond,
|
respond,
|
||||||
ephemeral: slashCommand.ephemeral,
|
ephemeral: slashCommand.ephemeral,
|
||||||
textLimit: ctx.textLimit,
|
textLimit: ctx.textLimit,
|
||||||
|
chunkMode: resolveChunkMode(cfg, "slack", route.accountId),
|
||||||
tableMode: resolveMarkdownTableMode({
|
tableMode: resolveMarkdownTableMode({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "slack",
|
channel: "slack",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { type FilesUploadV2Arguments, type WebClient } from "@slack/web-api";
|
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 { loadConfig } from "../config/config.js";
|
||||||
import { logVerbose } from "../globals.js";
|
import { logVerbose } from "../globals.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
@@ -149,7 +153,15 @@ export async function sendMessageSlack(
|
|||||||
channel: "slack",
|
channel: "slack",
|
||||||
accountId: account.accountId,
|
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 =
|
const mediaMaxBytes =
|
||||||
typeof account.config.mediaMaxMb === "number"
|
typeof account.config.mediaMaxMb === "number"
|
||||||
? account.config.mediaMaxMb * 1024 * 1024
|
? account.config.mediaMaxMb * 1024 * 1024
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
|
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 { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js";
|
||||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||||
import { removeAckReactionAfterReply } from "../channels/ack-reactions.js";
|
import { removeAckReactionAfterReply } from "../channels/ack-reactions.js";
|
||||||
@@ -125,6 +126,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
});
|
});
|
||||||
|
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
|
||||||
|
|
||||||
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
@@ -147,6 +149,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
textLimit,
|
textLimit,
|
||||||
messageThreadId: resolvedThreadId,
|
messageThreadId: resolvedThreadId,
|
||||||
tableMode,
|
tableMode,
|
||||||
|
chunkMode,
|
||||||
onVoiceRecording: sendRecordVoice,
|
onVoiceRecording: sendRecordVoice,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Bot, Context } from "grammy";
|
import type { Bot, Context } from "grammy";
|
||||||
|
|
||||||
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
||||||
|
import { resolveChunkMode } from "../auto-reply/chunk.js";
|
||||||
import {
|
import {
|
||||||
buildCommandTextFromArgs,
|
buildCommandTextFromArgs,
|
||||||
findCommandByNativeName,
|
findCommandByNativeName,
|
||||||
@@ -320,6 +321,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
typeof telegramCfg.blockStreaming === "boolean"
|
typeof telegramCfg.blockStreaming === "boolean"
|
||||||
? !telegramCfg.blockStreaming
|
? !telegramCfg.blockStreaming
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
|
||||||
|
|
||||||
await dispatchReplyWithBufferedBlockDispatcher({
|
await dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
@@ -337,6 +339,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
textLimit,
|
textLimit,
|
||||||
messageThreadId: resolvedThreadId,
|
messageThreadId: resolvedThreadId,
|
||||||
tableMode,
|
tableMode,
|
||||||
|
chunkMode,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
markdownToTelegramHtml,
|
markdownToTelegramHtml,
|
||||||
renderTelegramHtmlText,
|
renderTelegramHtmlText,
|
||||||
} from "../format.js";
|
} from "../format.js";
|
||||||
|
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js";
|
||||||
import { splitTelegramCaption } from "../caption.js";
|
import { splitTelegramCaption } from "../caption.js";
|
||||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
import type { ReplyToMode } from "../../config/config.js";
|
import type { ReplyToMode } from "../../config/config.js";
|
||||||
@@ -32,12 +33,33 @@ export async function deliverReplies(params: {
|
|||||||
textLimit: number;
|
textLimit: number;
|
||||||
messageThreadId?: number;
|
messageThreadId?: number;
|
||||||
tableMode?: MarkdownTableMode;
|
tableMode?: MarkdownTableMode;
|
||||||
|
chunkMode?: ChunkMode;
|
||||||
/** Callback invoked before sending a voice message to switch typing indicator. */
|
/** Callback invoked before sending a voice message to switch typing indicator. */
|
||||||
onVoiceRecording?: () => Promise<void> | void;
|
onVoiceRecording?: () => Promise<void> | void;
|
||||||
}) {
|
}) {
|
||||||
const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId } = params;
|
const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId } = params;
|
||||||
|
const chunkMode = params.chunkMode ?? "length";
|
||||||
const threadParams = buildTelegramThreadParams(messageThreadId);
|
const threadParams = buildTelegramThreadParams(messageThreadId);
|
||||||
let hasReplied = false;
|
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) {
|
for (const reply of replies) {
|
||||||
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
||||||
if (!reply?.text && !hasMedia) {
|
if (!reply?.text && !hasMedia) {
|
||||||
@@ -55,9 +77,7 @@ export async function deliverReplies(params: {
|
|||||||
? [reply.mediaUrl]
|
? [reply.mediaUrl]
|
||||||
: [];
|
: [];
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
const chunks = markdownToTelegramChunks(reply.text || "", textLimit, {
|
const chunks = chunkText(reply.text || "");
|
||||||
tableMode: params.tableMode,
|
|
||||||
});
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
||||||
replyToMessageId:
|
replyToMessageId:
|
||||||
@@ -151,9 +171,7 @@ export async function deliverReplies(params: {
|
|||||||
// Send deferred follow-up text right after the first media item.
|
// 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).
|
// Chunk it in case it's extremely long (same logic as text-only replies).
|
||||||
if (pendingFollowUpText && isFirstMedia) {
|
if (pendingFollowUpText && isFirstMedia) {
|
||||||
const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit, {
|
const chunks = chunkText(pendingFollowUpText);
|
||||||
tableMode: params.tableMode,
|
|
||||||
});
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
const replyToMessageIdFollowup =
|
const replyToMessageIdFollowup =
|
||||||
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
|
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type ResolvedWhatsAppAccount = {
|
|||||||
groupPolicy?: GroupPolicy;
|
groupPolicy?: GroupPolicy;
|
||||||
dmPolicy?: DmPolicy;
|
dmPolicy?: DmPolicy;
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
ackReaction?: WhatsAppAccountConfig["ackReaction"];
|
ackReaction?: WhatsAppAccountConfig["ackReaction"];
|
||||||
@@ -150,6 +151,7 @@ export function resolveWhatsAppAccount(params: {
|
|||||||
groupAllowFrom: accountCfg?.groupAllowFrom ?? rootCfg?.groupAllowFrom,
|
groupAllowFrom: accountCfg?.groupAllowFrom ?? rootCfg?.groupAllowFrom,
|
||||||
groupPolicy: accountCfg?.groupPolicy ?? rootCfg?.groupPolicy,
|
groupPolicy: accountCfg?.groupPolicy ?? rootCfg?.groupPolicy,
|
||||||
textChunkLimit: accountCfg?.textChunkLimit ?? rootCfg?.textChunkLimit,
|
textChunkLimit: accountCfg?.textChunkLimit ?? rootCfg?.textChunkLimit,
|
||||||
|
chunkMode: accountCfg?.chunkMode ?? rootCfg?.chunkMode,
|
||||||
mediaMaxMb: accountCfg?.mediaMaxMb ?? rootCfg?.mediaMaxMb,
|
mediaMaxMb: accountCfg?.mediaMaxMb ?? rootCfg?.mediaMaxMb,
|
||||||
blockStreaming: accountCfg?.blockStreaming ?? rootCfg?.blockStreaming,
|
blockStreaming: accountCfg?.blockStreaming ?? rootCfg?.blockStreaming,
|
||||||
ackReaction: accountCfg?.ackReaction ?? rootCfg?.ackReaction,
|
ackReaction: accountCfg?.ackReaction ?? rootCfg?.ackReaction,
|
||||||
|
|||||||
@@ -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 type { MarkdownTableMode } from "../../config/types.base.js";
|
||||||
import { convertMarkdownTables } from "../../markdown/tables.js";
|
import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
@@ -15,6 +15,7 @@ export async function deliverWebReply(params: {
|
|||||||
msg: WebInboundMsg;
|
msg: WebInboundMsg;
|
||||||
maxMediaBytes: number;
|
maxMediaBytes: number;
|
||||||
textLimit: number;
|
textLimit: number;
|
||||||
|
chunkMode?: ChunkMode;
|
||||||
replyLogger: {
|
replyLogger: {
|
||||||
info: (obj: unknown, msg: string) => void;
|
info: (obj: unknown, msg: string) => void;
|
||||||
warn: (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 { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params;
|
||||||
const replyStarted = Date.now();
|
const replyStarted = Date.now();
|
||||||
const tableMode = params.tableMode ?? "code";
|
const tableMode = params.tableMode ?? "code";
|
||||||
|
const chunkMode = params.chunkMode ?? "length";
|
||||||
const convertedText = convertMarkdownTables(replyResult.text || "", tableMode);
|
const convertedText = convertMarkdownTables(replyResult.text || "", tableMode);
|
||||||
const textChunks = chunkMarkdownText(convertedText, textLimit);
|
const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode);
|
||||||
const mediaList = replyResult.mediaUrls?.length
|
const mediaList = replyResult.mediaUrls?.length
|
||||||
? replyResult.mediaUrls
|
? replyResult.mediaUrls
|
||||||
: replyResult.mediaUrl
|
: replyResult.mediaUrl
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export async function monitorWebChannel(
|
|||||||
groupAllowFrom: account.groupAllowFrom,
|
groupAllowFrom: account.groupAllowFrom,
|
||||||
groupPolicy: account.groupPolicy,
|
groupPolicy: account.groupPolicy,
|
||||||
textChunkLimit: account.textChunkLimit,
|
textChunkLimit: account.textChunkLimit,
|
||||||
|
chunkMode: account.chunkMode,
|
||||||
mediaMaxMb: account.mediaMaxMb,
|
mediaMaxMb: account.mediaMaxMb,
|
||||||
blockStreaming: account.blockStreaming,
|
blockStreaming: account.blockStreaming,
|
||||||
groups: account.groups,
|
groups: account.groups,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { resolveIdentityNamePrefix } from "../../../agents/identity.js";
|
import { resolveIdentityNamePrefix } from "../../../agents/identity.js";
|
||||||
import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
|
import { resolveChunkMode, resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
|
||||||
import {
|
import {
|
||||||
formatInboundEnvelope,
|
formatInboundEnvelope,
|
||||||
resolveEnvelopeFormatOptions,
|
resolveEnvelopeFormatOptions,
|
||||||
@@ -229,6 +229,7 @@ export async function processMessage(params: {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp");
|
const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp");
|
||||||
|
const chunkMode = resolveChunkMode(params.cfg, "whatsapp", params.route.accountId);
|
||||||
const tableMode = resolveMarkdownTableMode({
|
const tableMode = resolveMarkdownTableMode({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
@@ -338,6 +339,7 @@ export async function processMessage(params: {
|
|||||||
msg: params.msg,
|
msg: params.msg,
|
||||||
maxMediaBytes: params.maxMediaBytes,
|
maxMediaBytes: params.maxMediaBytes,
|
||||||
textLimit,
|
textLimit,
|
||||||
|
chunkMode,
|
||||||
replyLogger: params.replyLogger,
|
replyLogger: params.replyLogger,
|
||||||
connectionId: params.connectionId,
|
connectionId: params.connectionId,
|
||||||
// Tool + block updates are noisy; skip their log lines.
|
// Tool + block updates are noisy; skip their log lines.
|
||||||
|
|||||||
Reference in New Issue
Block a user