Skip to content

Instantly share code, notes, and snippets.

@Kenya-West
Created September 9, 2026 14:10
Show Gist options
  • Select an option

  • Save Kenya-West/19fac51bc04c56a29dcc26ee3f7adf24 to your computer and use it in GitHub Desktop.

Select an option

Save Kenya-West/19fac51bc04c56a29dcc26ee3f7adf24 to your computer and use it in GitHub Desktop.
Telegram Bot based on Cloudflare Workers to convert Telegram Messages to Markdown, and vice versa

md4tg

A single-file Cloudflare Worker that bridges Telegram MarkdownV2 and plain text:

  • /to_markdown — reply to a formatted Telegram message (or a caption) and the bot reconstructs the equivalent MarkdownV2 source from the message's text + entities, sent back as plain text (so you can copy-paste it).
  • /to_telegram <MarkdownV2> — send MarkdownV2 source and the bot renders it as a formatted Telegram message.

Your just create a Cloudflare Worker, copy script.js contents, set a secret for your bot token, register the webhook once, and you're done. No database or other storage is needed.

How it works

Telegram never sends bots the markup a user typed — only raw text plus a list of entities (offsets/lengths describing bold, italic, links, code blocks, etc). /to_markdown walks those entities, builds a nesting tree (so e.g. bold-inside-a-link renders correctly), and re-serializes it as escaped MarkdownV2. /to_telegram does the reverse trip through Telegram's Bot API by sending with parse_mode: MarkdownV2.

Supported entity types: bold, italic, underline, strikethrough, spoiler, code, pre (with language), text_link, text_mention, custom_emoji, date_time, blockquote, expandable_blockquote.

Setup

  1. Create a bot via @BotFather and grab its token.

  2. Deploy the Worker (Cloudflare dashboard or wrangler), then set two secrets:

    wrangler secret put BOT_TOKEN
    wrangler secret put BOT_SECRET
    
    • BOT_TOKEN — the Telegram bot token from BotFather (ENV_BOT_TOKEN also works, for compatibility with the original Telegram Cloudflare Worker example).
    • BOT_SECRET — any random string you choose; it's used both as Telegram's webhook secret token and as the admin secret for the registration endpoints (ENV_BOT_SECRET also works).
  3. Register the webhook by visiting, once, in a browser:

    https://<your-worker>.workers.dev/registerWebhook?secret=<BOT_SECRET>
    

    This calls Telegram's setWebhook pointed at /endpoint on your Worker and registers the bot's command list (setMyCommands).

  4. Message your bot. Send /help for usage.

To remove the webhook later:

https://<your-worker>.workers.dev/unRegisterWebhook?secret=<BOT_SECRET>

Endpoints

Path Purpose
/endpoint Telegram webhook target (validates the secret token)
/registerWebhook One-time setup, requires ?secret= or X-Admin-Secret header
/unRegisterWebhook Removes the webhook
/ Health check

Commands

/to_markdown
Reply to a formatted message. Returns Telegram MarkdownV2 source as plain text.

/to_telegram <Telegram MarkdownV2>
Renders the given MarkdownV2 source as a formatted message.

Aliases: /source = /to_markdown, /render = /to_telegram

/to_markdown output longer than Telegram's message limit is automatically split into numbered chunks. /to_telegram input over 4096 UTF-16 code units is rejected with an explanation, and invalid MarkdownV2 (e.g. unescaped reserved characters) returns Telegram's error plus an escaping cheat sheet.

License

MIT

const WEBHOOK = '/endpoint'
const REGISTER_WEBHOOK = '/registerWebhook'
const UNREGISTER_WEBHOOK = '/unRegisterWebhook'
const MAX_TELEGRAM_MESSAGE_LENGTH = 4096
const SAFE_PLAIN_CHUNK_LENGTH = 3900
export default {
async fetch (request, env, ctx) {
const config = getConfig(env)
const url = new URL(request.url)
try {
if (url.pathname === WEBHOOK) {
return handleWebhook(request, config, ctx)
}
if (url.pathname === REGISTER_WEBHOOK) {
return registerWebhook(request, url, config)
}
if (url.pathname === UNREGISTER_WEBHOOK || url.pathname === '/unregisterWebhook') {
return unregisterWebhook(request, url, config)
}
return new Response('Telegram Markdown bot is running. Use /registerWebhook?secret=... once after deployment.', {
status: 200,
headers: { 'content-type': 'text/plain; charset=utf-8' }
})
} catch (error) {
console.error(error)
return new Response(`Worker error: ${error.message}`, {
status: 500,
headers: { 'content-type': 'text/plain; charset=utf-8' }
})
}
}
}
function getConfig (env) {
const token = env.ENV_BOT_TOKEN || env.BOT_TOKEN
const secret = env.ENV_BOT_SECRET || env.BOT_SECRET
if (!token) {
throw new Error('Missing ENV_BOT_TOKEN or BOT_TOKEN Worker secret')
}
if (!secret) {
throw new Error('Missing ENV_BOT_SECRET or BOT_SECRET Worker secret')
}
return { token, secret }
}
async function handleWebhook (request, config, ctx) {
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 })
}
if (request.headers.get('X-Telegram-Bot-Api-Secret-Token') !== config.secret) {
return new Response('Unauthorized', { status: 403 })
}
let update
try {
update = await request.json()
} catch {
return new Response('Bad JSON', { status: 400 })
}
ctx.waitUntil(onUpdate(update, config).catch(error => console.error('onUpdate failed', error)))
return new Response('Ok')
}
async function registerWebhook (request, requestUrl, config) {
if (!isAdminRequest(request, requestUrl, config)) {
return new Response('Unauthorized. Call this endpoint as /registerWebhook?secret=ENV_BOT_SECRET', { status: 403 })
}
const webhookUrl = `${requestUrl.protocol}//${requestUrl.host}${WEBHOOK}`
const webhookResult = await telegramApi('setWebhook', {
url: webhookUrl,
secret_token: config.secret,
allowed_updates: ['message']
}, config)
const commandsResult = await setBotCommands(config)
return jsonResponse({ webhook: webhookResult, commands: commandsResult })
}
async function unregisterWebhook (request, requestUrl, config) {
if (!isAdminRequest(request, requestUrl, config)) {
return new Response('Unauthorized. Call this endpoint as /unRegisterWebhook?secret=ENV_BOT_SECRET', { status: 403 })
}
const result = await telegramApi('deleteWebhook', { drop_pending_updates: false }, config)
return jsonResponse(result)
}
function isAdminRequest (request, url, config) {
return url.searchParams.get('secret') === config.secret ||
request.headers.get('X-Admin-Secret') === config.secret
}
async function setBotCommands (config) {
return telegramApi('setMyCommands', {
commands: [
{ command: 'to_markdown', description: 'Reply: extract Telegram MarkdownV2 source' },
{ command: 'to_telegram', description: 'Render Telegram MarkdownV2 source' },
{ command: 'help', description: 'Show usage' }
]
}, config)
}
async function onUpdate (update, config) {
if (update.message) {
await onMessage(update.message, config)
}
}
async function onMessage (message, config) {
const text = getMessageText(message)
if (!text) {
return sendPlainText(message.chat.id, 'I can process only text messages or media captions.', config, message.message_id)
}
const parsed = parseCommand(text)
if (!parsed.command) {
return sendPlainText(message.chat.id, 'Send /help to see available commands.', config, message.message_id)
}
switch (parsed.command) {
case 'start':
case 'help':
return sendHelp(message.chat.id, config, message.message_id)
case 'to_markdown':
case 'source':
return handleToMarkdown(message, parsed, config)
case 'to_telegram':
case 'render':
return handleToTelegram(message, parsed, config)
default:
return sendPlainText(message.chat.id, `Unknown command: /${parsed.command}\n\nSend /help to see available commands.`, config, message.message_id)
}
}
async function sendHelp (chatId, config, replyToMessageId = null) {
const help = [
'Commands:',
'',
'/to_markdown',
'Reply to a formatted Telegram message. The bot reconstructs Telegram MarkdownV2 source from text + message entities and sends it as plain text.',
'',
'/to_telegram <Telegram MarkdownV2>',
'Send Telegram MarkdownV2 source after the command. The bot sends it back with parse_mode=MarkdownV2 so Telegram renders it.',
'',
'Aliases:',
'/source = /to_markdown',
'/render = /to_telegram',
'',
'Examples:',
'/to_telegram *bold* _italic_ __underline__ ~strike~ ||spoiler||',
'/to_telegram [OpenAI](https://openai.com)',
'',
'Important:',
'Telegram does not send the original typed markup to bots. It sends text plus message entities. /to_markdown reconstructs equivalent MarkdownV2, not necessarily byte-for-byte original input.'
].join('\n')
return sendPlainText(chatId, help, config, replyToMessageId)
}
async function handleToMarkdown (message, parsed, config) {
const input = getCommandInputOrReply(message, parsed)
if (!input) {
return sendPlainText(
message.chat.id,
'Usage: reply to a formatted text message with /to_markdown, or send /to_markdown <formatted text>.',
config,
message.message_id
)
}
const markdown = entitiesToTelegramMarkdownV2(input.text, input.entities)
const response = `Telegram MarkdownV2 source (${input.source}):\n\n${markdown || '(empty)'}`
return sendLongPlainText(message.chat.id, response, config, message.message_id)
}
async function handleToTelegram (message, parsed, config) {
const input = getCommandInputOrReply(message, parsed)
if (!input || !input.text.trim()) {
return sendPlainText(
message.chat.id,
'Usage: /to_telegram <Telegram MarkdownV2 source>\n\nExample:\n/to_telegram *bold* _italic_ [link](https://example.com)',
config,
message.message_id
)
}
if (input.text.length > MAX_TELEGRAM_MESSAGE_LENGTH) {
return sendPlainText(
message.chat.id,
`The input is too long for one Telegram message (${input.text.length} UTF-16 code units). Keep it under ${MAX_TELEGRAM_MESSAGE_LENGTH}.`,
config,
message.message_id
)
}
const result = await sendMarkdownV2Text(message.chat.id, input.text, config, message.message_id)
if (result.ok) {
return result
}
return sendPlainText(
message.chat.id,
[
'Telegram rejected this MarkdownV2 input.',
'',
`Error: ${result.description || 'unknown error'}`,
'',
'Most common reason: a reserved MarkdownV2 character is not escaped.',
'Escape literal characters with a backslash:',
'_ * [ ] ( ) ~ ` > # + - = | { } . ! \\',
'',
'Example:',
'literal dot: \\.',
'literal exclamation: \\!'
].join('\n'),
config,
message.message_id
)
}
function getCommandInputOrReply (message, parsed) {
const currentText = getMessageText(message)
const currentEntities = getMessageEntities(message)
const argumentText = currentText.slice(parsed.argStart)
if (argumentText.trim()) {
return {
source: 'command arguments',
text: argumentText,
entities: sliceEntities(currentEntities, parsed.argStart, currentText.length)
}
}
const reply = message.reply_to_message
if (reply) {
const replyText = getMessageText(reply)
if (replyText) {
return {
source: 'replied message',
text: replyText,
entities: getMessageEntities(reply)
}
}
}
return null
}
function getMessageText (message) {
return message.text || message.caption || ''
}
function getMessageEntities (message) {
return message.entities || message.caption_entities || []
}
function parseCommand (text) {
if (!text.startsWith('/')) {
return { command: null, argStart: 0 }
}
const match = text.match(/^\/([a-zA-Z0-9_]+)(?:@[a-zA-Z0-9_]+)?(\s+)?/)
if (!match) {
return { command: null, argStart: 0 }
}
return {
command: match[1].toLowerCase(),
argStart: match[0].length
}
}
function sliceEntities (entities, from, to) {
return entities
.map(entity => {
const start = entity.offset
const end = entity.offset + entity.length
const newStart = Math.max(start, from)
const newEnd = Math.min(end, to)
if (newEnd <= newStart) {
return null
}
return {
...entity,
offset: newStart - from,
length: newEnd - newStart
}
})
.filter(Boolean)
}
function entitiesToTelegramMarkdownV2 (text, entities) {
const root = buildEntityTree(text, entities)
return renderNodeChildren(root, text)
}
function buildEntityTree (text, entities) {
const root = {
type: 'root',
start: 0,
end: text.length,
children: []
}
const nodes = entities
.map(entity => normalizeEntity(entity, text.length))
.filter(Boolean)
.sort(compareEntities)
for (const node of nodes) {
insertEntityNode(root, node)
}
return root
}
function normalizeEntity (entity, textLength) {
if (!Number.isInteger(entity.offset) || !Number.isInteger(entity.length)) {
return null
}
const start = entity.offset
const end = entity.offset + entity.length
if (start < 0 || end > textLength || start >= end) {
return null
}
return {
...entity,
start,
end,
children: []
}
}
function compareEntities (a, b) {
if (a.start !== b.start) return a.start - b.start
if (a.end !== b.end) return b.end - a.end
return entityPriority(a.type) - entityPriority(b.type)
}
function entityPriority (type) {
const priorities = {
text_link: 10,
text_mention: 10,
custom_emoji: 10,
date_time: 10,
bold: 20,
italic: 21,
underline: 22,
strikethrough: 23,
spoiler: 24,
blockquote: 30,
expandable_blockquote: 30,
code: 40,
pre: 40
}
return priorities[type] || 100
}
function insertEntityNode (parent, node) {
for (const child of parent.children) {
if (containsEntity(child, node)) {
return insertEntityNode(child, node)
}
}
for (const child of parent.children) {
if (overlapsEntity(child, node) && !containsEntity(node, child)) {
return false
}
}
const remainingChildren = []
for (const child of parent.children) {
if (containsEntity(node, child)) {
node.children.push(child)
} else {
remainingChildren.push(child)
}
}
parent.children = remainingChildren
parent.children.push(node)
parent.children.sort(compareEntities)
node.children.sort(compareEntities)
return true
}
function containsEntity (outer, inner) {
return inner.start >= outer.start && inner.end <= outer.end
}
function overlapsEntity (a, b) {
return a.start < b.end && b.start < a.end
}
function renderNodeChildren (node, text) {
let result = ''
let position = node.start
const children = [...node.children].sort(compareEntities)
for (const child of children) {
if (child.start < position || child.end > node.end) {
continue
}
result += escapeMarkdownV2Text(text.slice(position, child.start))
result += renderEntityNode(child, text)
position = child.end
}
result += escapeMarkdownV2Text(text.slice(position, node.end))
return result
}
function renderEntityNode (node, text) {
const raw = text.slice(node.start, node.end)
const inner = renderNodeChildren(node, text)
switch (node.type) {
case 'bold':
return `*${inner}*`
case 'italic':
return `_${inner}_`
case 'underline':
return `__${inner}__`
case 'strikethrough':
return `~${inner}~`
case 'spoiler':
return `||${inner}||`
case 'code':
return `\`${escapeMarkdownV2Code(raw)}\``
case 'pre': {
const language = sanitizePreLanguage(node.language)
const body = escapeMarkdownV2Code(raw)
return `\`\`\`${language}\n${body}${body.endsWith('\n') ? '' : '\n'}\`\`\``
}
case 'text_link':
return `[${inner}](${escapeMarkdownV2LinkUrl(node.url || '')})`
case 'text_mention': {
const userId = node.user && node.user.id ? String(node.user.id) : ''
return userId ? `[${inner}](tg://user?id=${userId})` : inner
}
case 'custom_emoji': {
const emojiId = node.custom_emoji_id
return emojiId ? `![${inner}](tg://emoji?id=${escapeMarkdownV2LinkUrl(String(emojiId))})` : inner
}
case 'date_time': {
const url = buildDateTimeUrl(node)
return url ? `![${inner}](${escapeMarkdownV2LinkUrl(url)})` : inner
}
case 'blockquote':
return renderBlockquote(inner, false)
case 'expandable_blockquote':
return renderBlockquote(inner, true)
default:
return inner
}
}
function renderBlockquote (inner, expandable) {
const quoted = inner
.split('\n')
.map(line => `>${line}`)
.join('\n')
return expandable ? `${quoted}||` : quoted
}
function buildDateTimeUrl (node) {
const unixTime = node.unix_time || node.unix || node.timestamp
if (!unixTime) return null
const params = new URLSearchParams({ unix: String(unixTime) })
if (node.format) params.set('format', String(node.format))
return `tg://time?${params.toString()}`
}
function sanitizePreLanguage (language) {
if (!language) return ''
return String(language).replace(/[^A-Za-z0-9_+\-.#]/g, '').slice(0, 40)
}
function escapeMarkdownV2Text (value) {
return String(value).replace(/[_*\[\]()~`>#+\-=|{}.!\\]/g, '\\$&')
}
function escapeMarkdownV2Code (value) {
return String(value).replace(/[`\\]/g, '\\$&')
}
function escapeMarkdownV2LinkUrl (value) {
return String(value).replace(/[)\\]/g, '\\$&')
}
async function sendPlainText (chatId, text, config, replyToMessageId = null) {
const payload = {
chat_id: chatId,
text,
link_preview_options: { is_disabled: true }
}
if (replyToMessageId) {
payload.reply_parameters = { message_id: replyToMessageId, allow_sending_without_reply: true }
}
return telegramApi('sendMessage', payload, config)
}
async function sendLongPlainText (chatId, text, config, replyToMessageId = null) {
const chunks = splitText(text, SAFE_PLAIN_CHUNK_LENGTH)
let lastResult = null
for (let index = 0; index < chunks.length; index++) {
const prefix = chunks.length > 1 ? `[${index + 1}/${chunks.length}]\n` : ''
lastResult = await sendPlainText(chatId, `${prefix}${chunks[index]}`, config, index === 0 ? replyToMessageId : null)
}
return lastResult
}
async function sendMarkdownV2Text (chatId, text, config, replyToMessageId = null) {
const payload = {
chat_id: chatId,
text,
parse_mode: 'MarkdownV2',
link_preview_options: { is_disabled: true }
}
if (replyToMessageId) {
payload.reply_parameters = { message_id: replyToMessageId, allow_sending_without_reply: true }
}
return telegramApi('sendMessage', payload, config)
}
function splitText (text, maxLength) {
const chunks = []
let rest = text
while (rest.length > maxLength) {
let cut = rest.lastIndexOf('\n', maxLength)
if (cut < maxLength * 0.5) cut = maxLength
chunks.push(rest.slice(0, cut))
rest = rest.slice(cut)
}
if (rest) chunks.push(rest)
return chunks
}
async function telegramApi (method, payload, config) {
let response
try {
response = await fetch(`https://api.telegram.org/bot${config.token}/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
})
} catch (error) {
return { ok: false, description: `Network error: ${error.message}` }
}
let data
try {
data = await response.json()
} catch {
return { ok: false, description: `Telegram returned HTTP ${response.status} with a non-JSON body` }
}
if (!response.ok && data.ok !== false) {
return { ok: false, description: `Telegram returned HTTP ${response.status}` }
}
return data
}
function jsonResponse (value, status = 200) {
return new Response(JSON.stringify(value, null, 2), {
status,
headers: { 'content-type': 'application/json; charset=utf-8' }
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment