|
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 |
|
} |
|
|
|
case 'date_time': { |
|
const url = buildDateTimeUrl(node) |
|
return 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' } |
|
}) |
|
} |