@yaebal/split
send text of any length as a chain of telegram-sized messages. adds ctx.sendLong, ctx.replyLong and ctx.sendPhotoLong to the
context; splits on newlines and spaces, carries format entities across part
boundaries, keeps keyboards on the right message, and reports partial failures.
installation
pnpm add @yaebal/splitbasic usage
install the splitter() plugin with bot.install(). it adds sendLong / replyLong / sendPhotoLong to every handler's
context — same shape as ctx.send / ctx.reply, but returning Promise<Message[]>, one Message per part.
import { bold, createBot, format } from "yaebal";
import { splitter } from "@yaebal/split";
const bot = createBot(process.env.BOT_TOKEN!)
.install(splitter({ max: 160 })); // tiny limit so the split is visible here
bot.command("report", (ctx) => {
const lines = Array.from({ length: 8 }, (_, i) => `shard ${i}: ok, queue clear, cache warm`);
// entities survive the split — the heading stays bold in part one
return ctx.replyLong(format`${bold("nightly report")}\n${lines.join("\n")}`);
});
bot.start();formatted text
build the text with format (or @yaebal/fmt) and entities are split
correctly: an entity crossing a boundary is clipped on one side and re-based on the other.
import { bold, format } from "@yaebal/core";
bot.command("changelog", async (ctx) => {
// bold spans the whole 10 000-character text; entities are clipped
// and re-based per part, so the formatting survives every boundary
const messages = await ctx.sendLong(format`${bold(changelog)}`);
// messages: Message[] — one per part, sent in order
});parse_mode markup cannot survive a split — a <b> opened in part
one would 400 in part two — so a multi-part send with parse_mode in extra throws up front, before anything hits the network. single-part sends pass it
through untouched. prefer entity-based formatting; that is what it is for.keyboards, quoting, pacing
the third argument tunes delivery. by default the reply_markup from extra lands only on the last part (a keyboard belongs on the final message), and replyLong quotes the origin only with its first part.
await ctx.replyLong(text, { reply_markup: keyboard }, {
markup: "last", // default — keyboard goes on the final message ("first" | "all")
replyTo: "first", // default — only the first part quotes the origin ("all")
delayMs: 300, // pause between parts — plays nice with flood limits
signal, // AbortSignal — stop sending the remaining parts
});
// plugin-level defaults, overridable per call; a bare number means { max }
bot.install(splitter({ max: 2000, delayMs: 300 }));captions
sendPhotoLong implements the caption strategy — the first part fits the media
caption limit, the rest are plain messages. caption entities included.
// caption strategy: first part becomes the photo caption (≤ 1024),
// the rest go out as regular messages (≤ 4096)
await ctx.sendPhotoLong(media.url(poster), format`${bold(longDescription)}`);partial failure
parts are sent sequentially; if one fails midway the promise rejects with SplitSendError carrying everything that already went out, so you can resume, edit,
or clean up instead of guessing.
import { SplitSendError } from "@yaebal/split";
try {
await ctx.sendLong(text);
} catch (error) {
if (error instanceof SplitSendError) {
error.sent; // Message[] — the parts that were delivered
error.part; // index of the part that failed
error.cause; // the underlying error (network, 429, …)
}
}outside the context
the splitters are pure and exported — use them in tests, pipelines, or other frameworks. splitSend is the delivery loop without the context sugar.
import { split, splitParts, splitSend, splitText } from "@yaebal/split";
// framework-agnostic delivery: each part is { text, entities } —
// a valid format result, so (part) => ctx.send(part) just works
const results = await splitSend(longText, ({ text, entities }) =>
someOtherFramework.sendMessage(chatId, text, { entities }),
);
splitText(formatResult); // eager: { text, entities }[]
split("plain\ntext"); // plain strings: string[]
for (const part of splitParts(text)) { /* lazy generator */ }splitting rules
- text at or under the limit is a single part, returned as-is
- the cut prefers the last newline in the window, then the last space or tab
- an overlong word is hard-cut — never through a surrogate pair, so emoji stay intact
- boundary whitespace is trimmed; whitespace-only parts are dropped entirely
- limits count utf-16 code units — the same units telegram uses for entity offsets
import { split } from "@yaebal/split";
// prefers newline boundaries, then spaces, then a hard cut
split("line1\nline2\nline3", 12); // → ["line1\nline2", "line3"]
// a single overlong word is hard-split — but never through a surrogate
// pair, so emoji survive intact
split("a".repeat(250), 100); // → [100 a's, 100 a's, 50 a's]
// whitespace-only parts are dropped (telegram rejects empty messages)
split(""); // → []api
| export | kind | description |
|---|---|---|
splitter(max? | options?) | Plugin | installs sendLong / replyLong / sendPhotoLong on the context |
splitSend(text, action, options?) | function | framework-agnostic sequential delivery — returns the action results |
splitText(text, max?) | function | eager split into { text, entities } parts |
splitParts(text, max?) | generator | lazy splitText |
split(text, max?) | function | plain-string split — string[] |
splitCaption(text, options?) | function | first part ≤ captionMax, the rest ≤ max |
SplitSendError | class | mid-chain failure — sent, part, parts, cause |
MAX_MESSAGE_LENGTH | const | 4096 — telegram's per-message text limit |
MAX_CAPTION_LENGTH | const | 1024 — telegram's media caption limit |
SplitControl | interface | the shape added to the context by splitter() |
SplitControl interface
| method | returns | description |
|---|---|---|
sendLong(text, extra?, options?) | Promise<Message[]> | send a string or format result split into parts via ctx.send |
replyLong(text, extra?, options?) | Promise<Message[]> | like sendLong; the first part quotes the triggering message |
sendPhotoLong(photo, caption, extra?, options?) | Promise<Message[]> | photo with the first part as caption, the rest as messages |
options
| option | default | description |
|---|---|---|
max | 4096 | per-part limit in utf-16 code units |
captionMax | 1024 | first-part limit for sendPhotoLong |
markup | "last" | which parts get extra.reply_markup — "last" | "first" | "all" |
replyTo | "first" | which parts quote the origin in replyLong — "first" | "all" |
delayMs | 0 | pause between parts in milliseconds |
signal | — | AbortSignal — abort the remaining parts |
production notes
delayMs, or install @yaebal/again so a mid-chain 429 is retried transparently — SplitSendError.sent tells you what got
through when it isn't.testing
the splitters are pure — assert on splitText(...) directly. for the context
methods, drive a bot with @yaebal/test and assert on the recorded sendMessage calls; see packages/split/src/index.test.ts for the full
pattern.