@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

terminal
pnpm add @yaebal/split

basic 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.

bot.ts
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.

changelog.ts
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.

options.ts
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.

poster.ts
// 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.

failure.ts
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.

anywhere.ts
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
split-rules.ts
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

exportkinddescription
splitter(max? | options?)Plugininstalls sendLong / replyLong / sendPhotoLong on the context
splitSend(text, action, options?)functionframework-agnostic sequential delivery — returns the action results
splitText(text, max?)functioneager split into { text, entities } parts
splitParts(text, max?)generatorlazy splitText
split(text, max?)functionplain-string split — string[]
splitCaption(text, options?)functionfirst part ≤ captionMax, the rest ≤ max
SplitSendErrorclassmid-chain failure — sent, part, parts, cause
MAX_MESSAGE_LENGTHconst4096 — telegram's per-message text limit
MAX_CAPTION_LENGTHconst1024 — telegram's media caption limit
SplitControlinterfacethe shape added to the context by splitter()

SplitControl interface

methodreturnsdescription
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

optiondefaultdescription
max4096per-part limit in utf-16 code units
captionMax1024first-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"
delayMs0pause between parts in milliseconds
signalAbortSignal — abort the remaining parts

production notes

telegram allows roughly one message per second per chat; a very long text is a burst of messages. set 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.