
Converts CommonMark (GFM tables, strikethrough) into Telegram MarkdownV2, escapes characters, downgrades unsupported constructs, and performs structure-safe truncation or lossless chunking.
Kotlin Multiplatform 库:把 CommonMark(含 GFM 表格与删除线)转换为 Telegram MarkdownV2、HTML 或 entities 协议,并对超长内容做保持结构完整的截断或无损分片(Telegram 消息上限 4096 字符,分片按序发送即可完整送达——文本不丢字符,接缝处格式化按段收口,每段独立可发送)。为 LLM 机器人输出而生——模型回复是任意长度、任意结构的 Markdown,还经常带 Telegram 渲染不了的构造。
发布于 Maven Central,版本跟随 v* git tag。支持 Android(minSdk 23)、JVM 11+、JS、Wasm、Linux(x64/Arm64)、macOS(Arm64)、Windows(mingwX64)、iOS(设备与模拟器)。
repositories { mavenCentral() }
dependencies {
// KMP 消费方在 commonMain 引用根坐标(Gradle module metadata 自动解析平台变体);
// 需要钉住具体变体时加后缀:-android / -jvm / -js / -wasm-js / -linuxx64 /
// -linuxarm64 / -macosarm64 / -mingwx64 / -iosarm64 / -iosx64 / -iossimulatorarm64
implementation("io.github.kamiiroawase:telegram-markdownv2:3.1.3")
}import io.github.kamiiroawase.markdownv2.ChunkDecoration
import io.github.kamiiroawase.markdownv2.MarkdownV2
import io.github.kamiiroawase.markdownv2.TelegramEntities
import io.github.kamiiroawase.markdownv2.TelegramHtml
// parse_mode = MarkdownV2
val text = MarkdownV2.render("**hello** world") // 直接发送
val clipped = MarkdownV2.render(reply, MarkdownV2.MAX_MESSAGE_LENGTH) // 超限截断,仍是合法 MarkdownV2
val parts = MarkdownV2.renderChunked(reply) // 无损分片,每段 ≤ 4096 且各自合法,按序发送
val literal = MarkdownV2.escape(userInput) // 手动拼 MarkdownV2 时转义动态文本
val fallback = MarkdownV2.toPlainText(rejectedPart) // 被拒收时还原纯文本、不带 parse_mode 重发
// parse_mode = HTML —— 三个入口共享解析器、RenderOptions 与截断/分片语义
val html = TelegramHtml.render("**hello** <tg-spoiler>world</tg-spoiler>") // tg-spoiler、tg-emoji 原样透传
val htmlParts = TelegramHtml.renderChunked(reply) // 同样无损分片,每段独立合法 HTML
val htmlLiteral = TelegramHtml.escape(userInput) // 文本转义 & < >;属性值由渲染器另转义 "
// 不带 parse_mode —— entities 协议:纯文本 + 格式化区间,从根上消灭 400
val message = TelegramEntities.render(reply) // EntityText(text = 纯显示文本, entities = 区间列表)
sendMessage(text = message.text, entities = message.entities) // 裸调 REST 时 entitiesJson() 即 entities 字段现成值
val messageParts = TelegramEntities.renderChunked(reply) // 同样无损分片,每段实体的偏移按段重基准
// 分片装饰:每片带头尾(页码 / 签名),三个入口都有对应重载
val pages = MarkdownV2.renderChunked(
reply,
decoration = ChunkDecoration(header = { i, n -> "第 ${i + 1}/$n 页\n\n" }),
)<tg-spoiler> 映射为原生 ||…|| 剧透标记,<tg-emoji emoji-id="…"> 与 Bot API 自身的自定义 emoji 写法 (图片语法)都映射为 ——三种输出格式均支持自定义 emoji 与剧透RenderOptions 定制;单元格文本里的裸 | 以全角 | 代替——管道布局无法转义,裸管道会被误读为列边界)、HTML 块映射为实体或字面文本、列表项内的引用块降级为原内容(Bot API 只接受行首的未转义 >,跟在列表标记或续行缩进后的 > 会导致整条消息被 400 拒收)、会与相邻 _/__ 标记粘连的下划线族强调降级(解析器会把紧贴的 _/__ 重新配对成无法闭合的实体)- [ ]/- [x],标记按字面回写)、autolink(裸 URL/邮箱自动成链接)与脚注(引用与定义标签保持可见);需要更多扩展(heading-anchor、ins 等)时在 MarkdownV2.defaultExtensions 之上传入自建 Parser,未知扩展节点按纯内容遍历,不崩溃& < >,属性值中另加 "——没有 MarkdownV2 那套按上下文收窄的规则<blockquote> 包装标签而非行首 > 前缀,因此列表项内的引用保留引用格式(MarkdownV2 端只能降级);全空的引用不输出(MarkdownV2 端保留一个裸 > 标记)<tg-spoiler> 与带数字 emoji-id 的 <tg-emoji> 原样透传,图片写法 (Bot API 自身的自定义 emoji 语法)映射为 <tg-emoji>;代码围栏带语言时映射为 <pre><code class="language-x">(Telegram 据此做语法高亮,语言名限 ASCII——超出该属性语法的语言降级为无语言 <pre>)& 的长度,对应 TelegramHtml.MIN_CHUNK_LENGTH);低于它返回完整渲染单片<tg-spoiler>/<tg-emoji> 映射为原生 spoiler/custom_emoji 实体,带语言的代码块映射为带 language 的 pre 实体EntityText 可直接进 sendMessage(text, entities);entitiesJson() 输出请求体格式的 JSON 数组(手写序列化,不引依赖)TelegramEntities.MIN_CHUNK_LENGTH)ktgram(eu.vendeli:telegram-bot)的 action 上可以直接接线三种格式——options { parseMode = ... } 或 entities(...)(版本与 DSL 细节以 ktgram 文档为准):
import eu.vendeli.tgbot.api.message.sendMessage
import eu.vendeli.tgbot.types.component.ParseMode
import eu.vendeli.tgbot.types.msg.EntityType
import eu.vendeli.tgbot.types.msg.MessageEntity
import io.github.kamiiroawase.markdownv2.MessageEntity as TmMessageEntity
import io.github.kamiiroawase.markdownv2.MessageEntityType
import io.github.kamiiroawase.markdownv2.TelegramEntities
import io.github.kamiiroawase.markdownv2.TelegramHtml
// HTML:parse_mode = HTML
sendMessage(TelegramHtml.render(reply))
.options { parseMode = ParseMode.HTML }
.send(user, bot)
// entities:不发 parse_mode,直接传区间(字段与 Bot API 一致)
fun TmMessageEntity.toKtgram() = MessageEntity(
type = when (type) {
MessageEntityType.BOLD -> EntityType.Bold
MessageEntityType.ITALIC -> EntityType.Italic
MessageEntityType.UNDERLINE -> EntityType.Underline
MessageEntityType.STRIKETHROUGH -> EntityType.Strikethrough
MessageEntityType.SPOILER -> EntityType.Spoiler
MessageEntityType.CODE -> EntityType.Code
MessageEntityType.PRE -> EntityType.Pre
MessageEntityType.TEXT_LINK -> EntityType.TextLink
MessageEntityType.BLOCKQUOTE -> EntityType.Blockquote
MessageEntityType.EXPANDABLE_BLOCKQUOTE -> EntityType.ExpandableBlockquote
MessageEntityType.CUSTOM_EMOJI -> EntityType.CustomEmoji
},
offset = offset,
length = length,
url = url,
language = language,
customEmojiId = customEmojiId,
)
val formatted = TelegramEntities.render(reply)
sendMessage(formatted.text)
.entities(formatted.entities.map { it.toKtgram() })
.send(user, bot)分页编辑模式:把分片 + 装饰与 editMessageText 组合,一条消息原地翻页,而不是连发一串——先发第一页,把返回的 Message 存下,翻页时编辑它:
val pages = TelegramEntities.renderChunked(
reply,
decoration = ChunkDecoration(footer = { i, n -> if (n > 1) "\n\n第 ${i + 1}/$n 页" else "" }),
)
var page = 0
val first = sendMessage(pages[0].text)
.entities(pages[0].entities.map { it.toKtgram() })
.sendReturning(user, bot)
.getOrNull() ?: return
// 翻页按钮的回调里:
editMessageText(first.messageId, pages[++page].text)
.entities(pages[page].entities.map { it.toKtgram() })
.send(user, bot)其他框架同理:任何能设 parse_mode 或传 entities 的 Bot API 客户端(ktor、ktor-client 裸调 REST 也行)都可以直接消费这三个入口的输出——entitiesJson() 就是请求体里 entities 字段的现成值。
<!-- -->、<!DOCTYPE> 按段落内联处理);强调内部的闭合 HTML 标签(如 *a<b>x</b>* 中的 </b>)不被识别为行内 HTML__ 是下划线不是粗体,不要把渲染结果再当普通 Markdown 二次加工<div> 等)仍降级为 <pre> 转义文本,不透传;唯一例外是块级的 <blockquote> / <blockquote expandable>(开标签独占一行、闭标签是块内最后一行,内容按 Markdown 内联解析)——映射为真正的引用块,三格式分别输出 > / **> 行首标记(** 只在首行,符合官方语法)、<blockquote> / <blockquote expandable> 包装标签与 blockquote / expandable_blockquote 实体;引用嵌套按各格式既有规则展平,entities 端沿用"引用套引用"的已知重叠(见下);render(document) 系列不重写调用方自带的 AST,该拼写只对库内解析的字符串入口生效> > x)产出嵌套的 blockquote 区间——Bot API 格式化规则禁止 blockquote 嵌套,但服务端发送时静默修复(丢弃内层区间),消息照常送达;对原始区间数组做严格校验的消费方需自行去重• 、10. )或标题井号丢弃以保住正文——正文文本永不丢弃,与三格式一致的 lead-drop 取舍(各入口 renderChunked 的 KDoc 已注明);可展开引用的首行 ** 标记属同一族:分片放得下就随首片携带,极小限额放不下时让位(三格式在该档位一致让位)git clone https://github.com/kamiiroawase/telegram-markdownv2.git
cd telegram-markdownv2
./gradlew build # JDK 21+:编译全部 target + 宿主可执行测试 + 格式检查
./gradlew jvmTest # 单独跑某个平台
./gradlew benchmark # 性能基准:常规消息 ~0.1ms 量级,百万字符 ~10ms;发版时 CI 复跑输出附在 GitHub ReleaseAPI 参考(Dokka)随 main 分支推送自动部署到 GitHub Pages。
788 个测试——787 个行为级(输入/输出断言)外加 1 个种子化随机文档的性质测试(150 个种子,生成器覆盖强调/代码/链接/引用/列表/任务列表/表格/脚注/autolink/原始 HTML/emoji 的随机组合,对三格式断言全部不变量),全部平台运行;其中 HTML 端的每条输出过一道 tdlib HTML 解析器的规则移植(assertTelegramHtmlParseable),MarkdownV2 端同理(assertTelegramParseable),entities 端过结构校验(区间在界内、按包含嵌套、按 close order 排列、不劈开代理对)。质量门禁全挂在 build 上:Spotless 格式、explicitApi() 显式 API、binary-compatibility-validator 公开 API 快照(有意变更时跑 ./gradlew apiDump)。
The Unlicense —— 公共领域,随意使用。
Kotlin Multiplatform 库:把 CommonMark(含 GFM 表格与删除线)转换为 Telegram MarkdownV2、HTML 或 entities 协议,并对超长内容做保持结构完整的截断或无损分片(Telegram 消息上限 4096 字符,分片按序发送即可完整送达——文本不丢字符,接缝处格式化按段收口,每段独立可发送)。为 LLM 机器人输出而生——模型回复是任意长度、任意结构的 Markdown,还经常带 Telegram 渲染不了的构造。
发布于 Maven Central,版本跟随 v* git tag。支持 Android(minSdk 23)、JVM 11+、JS、Wasm、Linux(x64/Arm64)、macOS(Arm64)、Windows(mingwX64)、iOS(设备与模拟器)。
repositories { mavenCentral() }
dependencies {
// KMP 消费方在 commonMain 引用根坐标(Gradle module metadata 自动解析平台变体);
// 需要钉住具体变体时加后缀:-android / -jvm / -js / -wasm-js / -linuxx64 /
// -linuxarm64 / -macosarm64 / -mingwx64 / -iosarm64 / -iosx64 / -iossimulatorarm64
implementation("io.github.kamiiroawase:telegram-markdownv2:3.1.3")
}import io.github.kamiiroawase.markdownv2.ChunkDecoration
import io.github.kamiiroawase.markdownv2.MarkdownV2
import io.github.kamiiroawase.markdownv2.TelegramEntities
import io.github.kamiiroawase.markdownv2.TelegramHtml
// parse_mode = MarkdownV2
val text = MarkdownV2.render("**hello** world") // 直接发送
val clipped = MarkdownV2.render(reply, MarkdownV2.MAX_MESSAGE_LENGTH) // 超限截断,仍是合法 MarkdownV2
val parts = MarkdownV2.renderChunked(reply) // 无损分片,每段 ≤ 4096 且各自合法,按序发送
val literal = MarkdownV2.escape(userInput) // 手动拼 MarkdownV2 时转义动态文本
val fallback = MarkdownV2.toPlainText(rejectedPart) // 被拒收时还原纯文本、不带 parse_mode 重发
// parse_mode = HTML —— 三个入口共享解析器、RenderOptions 与截断/分片语义
val html = TelegramHtml.render("**hello** <tg-spoiler>world</tg-spoiler>") // tg-spoiler、tg-emoji 原样透传
val htmlParts = TelegramHtml.renderChunked(reply) // 同样无损分片,每段独立合法 HTML
val htmlLiteral = TelegramHtml.escape(userInput) // 文本转义 & < >;属性值由渲染器另转义 "
// 不带 parse_mode —— entities 协议:纯文本 + 格式化区间,从根上消灭 400
val message = TelegramEntities.render(reply) // EntityText(text = 纯显示文本, entities = 区间列表)
sendMessage(text = message.text, entities = message.entities) // 裸调 REST 时 entitiesJson() 即 entities 字段现成值
val messageParts = TelegramEntities.renderChunked(reply) // 同样无损分片,每段实体的偏移按段重基准
// 分片装饰:每片带头尾(页码 / 签名),三个入口都有对应重载
val pages = MarkdownV2.renderChunked(
reply,
decoration = ChunkDecoration(header = { i, n -> "第 ${i + 1}/$n 页\n\n" }),
)<tg-spoiler> 映射为原生 ||…|| 剧透标记,<tg-emoji emoji-id="…"> 与 Bot API 自身的自定义 emoji 写法 (图片语法)都映射为 ——三种输出格式均支持自定义 emoji 与剧透RenderOptions 定制;单元格文本里的裸 | 以全角 | 代替——管道布局无法转义,裸管道会被误读为列边界)、HTML 块映射为实体或字面文本、列表项内的引用块降级为原内容(Bot API 只接受行首的未转义 >,跟在列表标记或续行缩进后的 > 会导致整条消息被 400 拒收)、会与相邻 _/__ 标记粘连的下划线族强调降级(解析器会把紧贴的 _/__ 重新配对成无法闭合的实体)- [ ]/- [x],标记按字面回写)、autolink(裸 URL/邮箱自动成链接)与脚注(引用与定义标签保持可见);需要更多扩展(heading-anchor、ins 等)时在 MarkdownV2.defaultExtensions 之上传入自建 Parser,未知扩展节点按纯内容遍历,不崩溃& < >,属性值中另加 "——没有 MarkdownV2 那套按上下文收窄的规则<blockquote> 包装标签而非行首 > 前缀,因此列表项内的引用保留引用格式(MarkdownV2 端只能降级);全空的引用不输出(MarkdownV2 端保留一个裸 > 标记)<tg-spoiler> 与带数字 emoji-id 的 <tg-emoji> 原样透传,图片写法 (Bot API 自身的自定义 emoji 语法)映射为 <tg-emoji>;代码围栏带语言时映射为 <pre><code class="language-x">(Telegram 据此做语法高亮,语言名限 ASCII——超出该属性语法的语言降级为无语言 <pre>)& 的长度,对应 TelegramHtml.MIN_CHUNK_LENGTH);低于它返回完整渲染单片<tg-spoiler>/<tg-emoji> 映射为原生 spoiler/custom_emoji 实体,带语言的代码块映射为带 language 的 pre 实体EntityText 可直接进 sendMessage(text, entities);entitiesJson() 输出请求体格式的 JSON 数组(手写序列化,不引依赖)TelegramEntities.MIN_CHUNK_LENGTH)ktgram(eu.vendeli:telegram-bot)的 action 上可以直接接线三种格式——options { parseMode = ... } 或 entities(...)(版本与 DSL 细节以 ktgram 文档为准):
import eu.vendeli.tgbot.api.message.sendMessage
import eu.vendeli.tgbot.types.component.ParseMode
import eu.vendeli.tgbot.types.msg.EntityType
import eu.vendeli.tgbot.types.msg.MessageEntity
import io.github.kamiiroawase.markdownv2.MessageEntity as TmMessageEntity
import io.github.kamiiroawase.markdownv2.MessageEntityType
import io.github.kamiiroawase.markdownv2.TelegramEntities
import io.github.kamiiroawase.markdownv2.TelegramHtml
// HTML:parse_mode = HTML
sendMessage(TelegramHtml.render(reply))
.options { parseMode = ParseMode.HTML }
.send(user, bot)
// entities:不发 parse_mode,直接传区间(字段与 Bot API 一致)
fun TmMessageEntity.toKtgram() = MessageEntity(
type = when (type) {
MessageEntityType.BOLD -> EntityType.Bold
MessageEntityType.ITALIC -> EntityType.Italic
MessageEntityType.UNDERLINE -> EntityType.Underline
MessageEntityType.STRIKETHROUGH -> EntityType.Strikethrough
MessageEntityType.SPOILER -> EntityType.Spoiler
MessageEntityType.CODE -> EntityType.Code
MessageEntityType.PRE -> EntityType.Pre
MessageEntityType.TEXT_LINK -> EntityType.TextLink
MessageEntityType.BLOCKQUOTE -> EntityType.Blockquote
MessageEntityType.EXPANDABLE_BLOCKQUOTE -> EntityType.ExpandableBlockquote
MessageEntityType.CUSTOM_EMOJI -> EntityType.CustomEmoji
},
offset = offset,
length = length,
url = url,
language = language,
customEmojiId = customEmojiId,
)
val formatted = TelegramEntities.render(reply)
sendMessage(formatted.text)
.entities(formatted.entities.map { it.toKtgram() })
.send(user, bot)分页编辑模式:把分片 + 装饰与 editMessageText 组合,一条消息原地翻页,而不是连发一串——先发第一页,把返回的 Message 存下,翻页时编辑它:
val pages = TelegramEntities.renderChunked(
reply,
decoration = ChunkDecoration(footer = { i, n -> if (n > 1) "\n\n第 ${i + 1}/$n 页" else "" }),
)
var page = 0
val first = sendMessage(pages[0].text)
.entities(pages[0].entities.map { it.toKtgram() })
.sendReturning(user, bot)
.getOrNull() ?: return
// 翻页按钮的回调里:
editMessageText(first.messageId, pages[++page].text)
.entities(pages[page].entities.map { it.toKtgram() })
.send(user, bot)其他框架同理:任何能设 parse_mode 或传 entities 的 Bot API 客户端(ktor、ktor-client 裸调 REST 也行)都可以直接消费这三个入口的输出——entitiesJson() 就是请求体里 entities 字段的现成值。
<!-- -->、<!DOCTYPE> 按段落内联处理);强调内部的闭合 HTML 标签(如 *a<b>x</b>* 中的 </b>)不被识别为行内 HTML__ 是下划线不是粗体,不要把渲染结果再当普通 Markdown 二次加工<div> 等)仍降级为 <pre> 转义文本,不透传;唯一例外是块级的 <blockquote> / <blockquote expandable>(开标签独占一行、闭标签是块内最后一行,内容按 Markdown 内联解析)——映射为真正的引用块,三格式分别输出 > / **> 行首标记(** 只在首行,符合官方语法)、<blockquote> / <blockquote expandable> 包装标签与 blockquote / expandable_blockquote 实体;引用嵌套按各格式既有规则展平,entities 端沿用"引用套引用"的已知重叠(见下);render(document) 系列不重写调用方自带的 AST,该拼写只对库内解析的字符串入口生效> > x)产出嵌套的 blockquote 区间——Bot API 格式化规则禁止 blockquote 嵌套,但服务端发送时静默修复(丢弃内层区间),消息照常送达;对原始区间数组做严格校验的消费方需自行去重• 、10. )或标题井号丢弃以保住正文——正文文本永不丢弃,与三格式一致的 lead-drop 取舍(各入口 renderChunked 的 KDoc 已注明);可展开引用的首行 ** 标记属同一族:分片放得下就随首片携带,极小限额放不下时让位(三格式在该档位一致让位)git clone https://github.com/kamiiroawase/telegram-markdownv2.git
cd telegram-markdownv2
./gradlew build # JDK 21+:编译全部 target + 宿主可执行测试 + 格式检查
./gradlew jvmTest # 单独跑某个平台
./gradlew benchmark # 性能基准:常规消息 ~0.1ms 量级,百万字符 ~10ms;发版时 CI 复跑输出附在 GitHub ReleaseAPI 参考(Dokka)随 main 分支推送自动部署到 GitHub Pages。
788 个测试——787 个行为级(输入/输出断言)外加 1 个种子化随机文档的性质测试(150 个种子,生成器覆盖强调/代码/链接/引用/列表/任务列表/表格/脚注/autolink/原始 HTML/emoji 的随机组合,对三格式断言全部不变量),全部平台运行;其中 HTML 端的每条输出过一道 tdlib HTML 解析器的规则移植(assertTelegramHtmlParseable),MarkdownV2 端同理(assertTelegramParseable),entities 端过结构校验(区间在界内、按包含嵌套、按 close order 排列、不劈开代理对)。质量门禁全挂在 build 上:Spotless 格式、explicitApi() 显式 API、binary-compatibility-validator 公开 API 快照(有意变更时跑 ./gradlew apiDump)。
The Unlicense —— 公共领域,随意使用。