nyx-bot-entities
v0.1.0
Published
1:1 replication of Telegram Bot API's text entity parsing, validation, and encoding rules (Markdown, MarkdownV2, HTML) - ported from the TDLib / telegram-bot-api reference implementation.
Maintainers
Readme
nyx-bot-entities
A from-scratch, faithful port of the Telegram Bot API's text-entity engine: parsing MarkdownV2 / Markdown (legacy) / HTML parse_mode source, validating and repairing a raw entities array, auto-detecting @mentions/#hashtags/$cashtags/bot_commands/URLs/emails, and encoding FormattedText back into MarkdownV2/HTML source.
Every parser and validator here is a direct, line-by-line port of the actual reference implementation - tdlib/td's MessageEntity.cpp, LinkManager.cpp, HttpUrl.cpp and tdlib/telegram-bot-api's Client.cpp - not a reimplementation from the public docs. If a text passes validateFormattedText, sending it is not a guess.
Install
bun add nyx-bot-entitiesZero runtime dependencies, part of the nyx-bot ecosystem but usable standalone with any Telegram Bot API client.
Quick start
import { parseMarkdownV2, validateFormattedText, isSendable } from "nyx-bot-entities";
const formatted = parseMarkdownV2("*bold* _italic_ [a link](https://example.com)");
// { text: "bold italic a link", entities: [...] }
validateFormattedText(formatted); // throws EntityParseError if it wouldn't be accepted
isSendable(formatted); // -> true, non-throwing form
// pass straight through to your Bot API client:
await bot.sendMessage(chatId, formatted.text, { entities: formatted.entities });API
Parsing
parseMarkdownV2(source, options?) // MarkdownV2 parse_mode
parseMarkdown(source, options?) // legacy "Markdown" (v1) parse_mode
parseHTML(source, options?) // HTML parse_mode
parseFormattedText(text, parseMode, options?) // dispatches by parse_mode name, "" | undefined | "none" = plain textAll four return { text: string, entities: MessageEntity[] } and throw EntityParseError (same wording as the Bot API's "Can't parse entities" error) on invalid syntax - an unescaped reserved character, an unclosed entity, a malformed URL inside link syntax, etc.
options:
autoDetect(defaulttrue) - merge in auto-detected mentions/hashtags/cashtags/bot_commands/URLs/emails, exactly like the real server does after any parse_mode.skipBotCommands(defaultfalse) - skipbot_commandauto-detection specifically.fixEntities(defaulttrue) - apply the same canonicalization pass the server applies before storing (see Entity splitting below). Turn off to see your raw, un-repaired markup.
Validating a raw entities array
validateEntities(text, entities) // throws EntityParseError on anything that would get the request rejected
isValidEntities(text, entities) // non-throwing boolean form
fixMessageEntities(text, entities) // returns entities repaired into the exact shape the server would store
isCanonicalEntities(text, entities) // advanced: the server's conservative "does this need repair" fast-path checkvalidateEntities checks bounds (offset/length in range, never splitting a UTF-16 surrogate pair) and per-type payload validity (text_link needs a checkable url, custom_emoji needs a numeric id, text_mention needs a user, etc) - the things that actually get a request rejected with a 400. It deliberately does not reject non-canonical nesting shapes, because the real server doesn't either - it silently repairs them (see below). Use fixMessageEntities to see what will actually be stored.
The one-call check
validateFormattedText(formatted, { limit?, allowEmpty? }) // throws
isSendable(formatted, { limit?, allowEmpty? }) // booleanValidates text length (MESSAGE_TEXT_LIMIT = 4096 by default; pass CAPTION_LIMIT = 1024 for captions/descriptions/quotes) and entities in one call.
Auto-detection
findEntities(text, { skipBotCommands? }) // -> MessageEntity[]Encoding back to source
toMarkdownV2(formatted) // -> string, reparses via parseMarkdownV2 to equivalent entities
toHTML(formatted) // -> string, reparses via parseHTML to equivalent entities
escapeMarkdownV2(text) // escape for use outside any entity
escapeMarkdownV2Code(text) // escape for use inside a code/pre entity
escapeMarkdownV2Url(text) // escape for use inside a link/emoji/date URL's (...)
escapeMarkdown(text) // legacy Markdown (v1) escaping
escapeHtml(text) // &, <, >
escapeHtmlAttribute(text) // + "Entity splitting: the one surprising thing you need to know
Every FormattedText - whether it came from a parse_mode or a raw entities array - passes through the server's fix_entities() repair pass before being stored. Whenever a splittable entity (bold/italic/underline/strikethrough/spoiler) has any other entity nested inside it, the pair is not in canonical shape, and the splittable entity is split into pieces around its child instead of staying as one entity that contains it:
const r = parseMarkdownV2("*bold [a link](https://example.com) tail*");
// r.entities is 3 entities, not 2:
// { type: "bold", offset: 0, length: 5 } "bold "
// { type: "text_link", offset: 5, length: 6, url: "..." } "a link" (also still bold)
// { type: "bold", offset: 11, length: 5 } " tail"This is not a bug in this library - it is exactly what the real Bot API server stores, verified against the reference implementation. parseMarkdownV2/parseMarkdown/parseHTML apply this by default; pass { fixEntities: false } if you want the raw, unrepaired shape (e.g. for debugging your markup), but that is not what gets sent.
What "1:1" does and doesn't cover
- Parsing (MarkdownV2/Markdown/HTML) and entity repair/splitting: byte-for-byte port of the reference grammar and
fix_entities(), including every documented and undocumented edge case found while porting (the greedy__vs_..._ambiguity rule, the||expandable-blockquote marker, pre/code escaping rules, tg://user/emoji/time link parsing, etc). - Bounds/payload validation: ported from
clean_input_string_with_entities()andget_message_entities()- what actually gets a 400. - Auto-detection (mentions/hashtags/cashtags/bot_commands/URLs/emails): ported from
find_entities()and friends, including the full IANA TLD allowlist used for scheme-less URL detection. This doesn't gate whether a message is accepted (only affects what entities come back), so it's held to a slightly lower fidelity bar than parsing/validation. - URL validation (
internal/link.ts): full RFC 3986 port except IPv6 literal hosts, which are accepted by character class rather than TDLib's full structuralIPAddressvalidation - noted in that file.
Scripts
bun run lint # tsc --noEmit && biome check --write --unsafe
bun test # bun:test suite
bun run build # tsc -p tsconfig.build.json -> dist/Part of the nyx-bot ecosystem, published privately alongside nyx-bot-client/nyx-bot-utils.
