// Telegram Bot API adapter.
//
// Mirrors Hermes `plugins/platforms/telegram/adapter.py`: text sends use
// `sendMessage` (chunked at 4096 chars), sends retry once on rate limits, and
// a markdown send that fails with a formatting error falls back to plain text.
// Inbound updates arrive either through the webhook or through `getUpdates`
// long polling.
///|
/// Bot API root, e.g. `https://api.telegram.org/bot`.
pub fn telegram_api_base(cfg : PlatformConfig, token : String) -> String {
let base = cfg.extra_str("api_base", "https://api.telegram.org")
let trimmed = if base.has_suffix("/") {
base[:base.length() - 1].to_owned()
} else {
base
}
"\{trimmed}/bot\{token}"
}
///|
/// Parse mode to request, from `[telegram.extra] parse_mode`.
/// `""` (default) sends plain text; `MarkdownV2`/`HTML` opt in.
pub fn telegram_parse_mode(cfg : PlatformConfig) -> String? {
let pm = cfg.extra_str("parse_mode", "")
if pm.is_empty() {
None
} else {
Some(pm)
}
}
///|
/// `sendMessage` payload.
pub fn telegram_send_payload(
chat_id : String,
text : String,
reply_to : String?,
thread_id : String?,
parse_mode : String?,
) -> Json {
let payload = json_obj([
("chat_id", Json::string(chat_id)),
("text", Json::string(text)),
])
let payload = obj_with_opt(payload, "parse_mode", parse_mode)
let payload = obj_with_opt(payload, "reply_to_message_id", reply_to)
obj_with_opt(payload, "message_thread_id", thread_id)
}
///|
/// `sendChatAction` payload.
pub fn telegram_typing_payload(chat_id : String, thread_id : String?) -> Json {
let payload = json_obj([
("chat_id", Json::string(chat_id)),
("action", Json::string("typing")),
])
obj_with_opt(payload, "message_thread_id", thread_id)
}
///|
/// `send` payload for media by URL.
pub fn telegram_media_payload(
chat_id : String,
kind : String,
url : String,
caption : String?,
parse_mode : String?,
) -> Json {
let field = match kind {
"photo" | "image" => "photo"
"video" => "video"
"audio" => "audio"
"voice" => "voice"
"document" | "file" => "document"
"sticker" => "sticker"
_ => "document"
}
let payload = json_obj([
("chat_id", Json::string(chat_id)),
(field, Json::string(url)),
])
let payload = obj_with_opt(payload, "caption", caption)
obj_with_opt(payload, "parse_mode", parse_mode)
}
///|
/// Telegram method name for a media kind (`sendPhoto`, `sendDocument`, ...).
pub fn telegram_media_method(kind : String) -> String {
let field = match kind {
"photo" | "image" => "photo"
"video" => "video"
"audio" => "audio"
"voice" => "voice"
"document" | "file" => "document"
"sticker" => "sticker"
_ => "document"
}
"send\{field[:1].to_owned().to_upper()}\{field[1:].to_owned()}"
}
///|
/// Message id from a `{"ok":true,"result":{"message_id":N}}` response.
fn telegram_result_id(j : Json) -> String? {
match json_field_obj(j, "result") {
Some(r) => json_id(r, "message_id")
None => json_id(j, "message_id")
}
}
///|
/// Human-readable error from a Telegram error response
/// (`{"ok":false,"description":"…","error_code":429,"parameters":{"retry_after":n}}`).
fn telegram_error(j : Json) -> (String, Double?) {
let desc = json_field_string(j, "description").unwrap_or(j.stringify())
let retry_after = match json_field_obj(j, "parameters") {
Some(p) =>
match p {
Object(m) =>
match m.get("retry_after") {
Some(Number(n, ..)) => Some(n)
_ => None
}
_ => None
}
None => None
}
(desc, retry_after)
}
///|
/// Call a Bot API method and map the response onto a `SendResult`.
async fn telegram_call(
cfg : PlatformConfig,
token : String,
api_method : String,
payload : Json,
) -> SendResult {
let url = "\{telegram_api_base(cfg, token)}/\{api_method}"
match post_json(url, payload, json_headers()) {
Err(e) => send_failure("telegram \{api_method}", e)
Ok(j) =>
if json_field_bool(j, "ok").unwrap_or(false) {
send_ok(telegram_result_id(j), j)
} else {
let (desc, retry_after) = telegram_error(j)
SendResult::failed(
"telegram \{api_method}: \{desc}",
retryable=classify_send_error(desc) == RateLimited ||
classify_send_error(desc) == Transient,
retry_after?,
kind=classify_send_error(desc),
raw=j.stringify(),
)
}
}
}
///|
/// Send one chunk with an optional parse mode, falling back to plain text when
/// Telegram rejects the formatting (Hermes `_send_chunk_markdown_or_plain`).
async fn telegram_send_chunk(
cfg : PlatformConfig,
token : String,
chat_id : String,
text : String,
reply_to : String?,
thread_id : String?,
) -> SendResult {
let parse_mode = telegram_parse_mode(cfg)
send_with_retry(() => {
let payload = telegram_send_payload(
chat_id, text, reply_to, thread_id, parse_mode,
)
let r = telegram_call(cfg, token, "sendMessage", payload)
if r.success {
return r
}
match parse_mode {
Some(_) =>
if r.error_kind == Some(BadFormat) || r.error_kind == Some(TooLong) {
let plain = telegram_send_payload(
chat_id,
text,
reply_to,
thread_id,
None,
)
telegram_call(cfg, token, "sendMessage", plain)
} else {
r
}
None => r
}
})
}
///|
/// Send ``content`` to ``chat_id``, chunked at the platform limit. Only the
/// first chunk replies to ``reply_to``; every chunk stays in ``thread_id``.
pub async fn telegram_send(
cfg : PlatformConfig,
chat_id : String,
content : String,
reply_to? : String,
thread_id? : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Telegram, "TELEGRAM_BOT_TOKEN")
Some(token) =>
if content.is_empty() {
return SendResult::ok()
} else {
let mut first = true
let mut last = SendResult::ok()
let mut first_id : String? = None
for chunk in chunk_text(content, Platform::max_message_length(Telegram)) {
let reply = if first { reply_to } else { None }
let r = telegram_send_chunk(
cfg, token, chat_id, chunk, reply, thread_id,
)
first = false
if !r.success {
return r
}
if first_id is None {
first_id = r.message_id
}
last = r
}
{ ..last, message_id: first_id, }
}
}
}
///|
/// Typing indicator (`sendChatAction`), errors are ignored.
pub async fn telegram_typing(
cfg : PlatformConfig,
chat_id : String,
thread_id? : String,
) -> Unit {
match cfg.token_of() {
None => ()
Some(token) =>
ignore(
telegram_call(
cfg,
token,
"sendChatAction",
telegram_typing_payload(chat_id, thread_id),
),
)
}
}
///|
/// Send a media URL with an optional caption.
pub async fn telegram_send_media(
cfg : PlatformConfig,
chat_id : String,
kind : String,
url : String,
caption? : String,
reply_to? : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Telegram, "TELEGRAM_BOT_TOKEN")
Some(token) =>
send_with_retry(() => {
let payload = obj_with_opt(
telegram_media_payload(
chat_id,
kind,
url,
caption,
telegram_parse_mode(cfg),
),
"reply_to_message_id",
reply_to,
)
telegram_call(cfg, token, telegram_media_method(kind), payload)
})
}
}
///|
/// Edit a previously sent message.
pub async fn telegram_edit(
cfg : PlatformConfig,
chat_id : String,
message_id : String,
text : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Telegram, "TELEGRAM_BOT_TOKEN")
Some(token) => {
let payload = json_obj([
("chat_id", Json::string(chat_id)),
("message_id", Json::string(message_id)),
("text", Json::string(text)),
])
telegram_call(
cfg,
token,
"editMessageText",
obj_with_opt(payload, "parse_mode", telegram_parse_mode(cfg)),
)
}
}
}
///|
/// Delete a message.
pub async fn telegram_delete(
cfg : PlatformConfig,
chat_id : String,
message_id : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Telegram, "TELEGRAM_BOT_TOKEN")
Some(token) => {
let payload = json_obj([
("chat_id", Json::string(chat_id)),
("message_id", Json::string(message_id)),
])
telegram_call(cfg, token, "deleteMessage", payload)
}
}
}
///|
/// React to a message with an emoji (Bot API 7.0 `setMessageReaction`).
pub async fn telegram_react(
cfg : PlatformConfig,
chat_id : String,
message_id : String,
emoji : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Telegram, "TELEGRAM_BOT_TOKEN")
Some(token) => {
let reaction = Json::array([
json_obj([
("type", Json::string("emoji")),
("emoji", Json::string(emoji)),
]),
])
let payload = json_obj([
("chat_id", Json::string(chat_id)),
("message_id", Json::string(message_id)),
("reaction", reaction),
])
telegram_call(cfg, token, "setMessageReaction", payload)
}
}
}
///|
/// Chat metadata via `getChat`.
pub async fn telegram_get_chat(
cfg : PlatformConfig,
chat_id : String,
) -> ChatInfo? {
match cfg.token_of() {
None => None
Some(token) => {
let url = "\{telegram_api_base(cfg, token)}/getChat"
let payload = json_obj([("chat_id", Json::string(chat_id))])
match post_json(url, payload, json_headers()) {
Err(_) => None
Ok(j) =>
match json_field_obj(j, "result") {
Some(r) =>
Some({
name: or_opt(
or_opt(
json_field_string(r, "title"),
json_field_string(r, "username"),
),
json_field_string(r, "first_name"),
).unwrap_or(chat_id),
chat_type: json_field_string(r, "type").unwrap_or("private"),
chat_id,
})
None => None
}
}
}
}
}
///|
/// Fetch updates (`getUpdates`) for long-polling transports, surfacing
/// transport/API failures so pollers can back off instead of hammering a
/// broken endpoint. ``offset`` acknowledges every earlier update.
pub async fn telegram_get_updates_result(
cfg : PlatformConfig,
token : String,
offset : Int32,
timeout_s : Int32,
) -> Result[Array[Json], ApiError] {
let url = "\{telegram_api_base(cfg, token)}/getUpdates"
let payload = json_obj([
("offset", Json::number(offset.to_double())),
("timeout", Json::number(timeout_s.to_double())),
(
"allowed_updates",
Json::array([
Json::string("message"),
Json::string("edited_message"),
Json::string("channel_post"),
Json::string("callback_query"),
]),
),
])
match post_json(url, payload, json_headers()) {
Err(e) => Err(e)
Ok(j) =>
match json_field_array(j, "result") {
Some(a) => Ok(a)
None => Ok([])
}
}
}
///|
/// Fetch updates (`getUpdates`) for long-polling transports; failures are
/// reported as an empty batch (see `telegram_get_updates_result`).
pub async fn telegram_get_updates(
cfg : PlatformConfig,
token : String,
offset : Int32,
timeout_s : Int32,
) -> Array[Json] {
match telegram_get_updates_result(cfg, token, offset, timeout_s) {
Ok(a) => a
Err(_) => []
}
}
///|
/// Whether a Telegram webhook carries the configured
/// `X-Telegram-Bot-Api-Secret-Token` (constant-time; Telegram echoes the
/// `secret_token` passed to `setWebhook`).
pub fn telegram_webhook_authorized(
expected : String,
provided : String?,
) -> Bool {
if expected.is_empty() {
return false
}
match provided {
Some(p) => constant_time_eq(expected, p)
None => false
}
}
///|
/// Register a webhook.
pub async fn telegram_set_webhook(
cfg : PlatformConfig,
token : String,
url : String,
secret_token? : String,
) -> SendResult {
let api = "\{telegram_api_base(cfg, token)}/setWebhook"
let payload = json_obj([
("url", Json::string(url)),
(
"allowed_updates",
Json::array([
Json::string("message"),
Json::string("edited_message"),
Json::string("channel_post"),
]),
),
])
let payload = obj_with_opt(payload, "secret_token", secret_token)
match post_json(api, payload, json_headers()) {
Err(e) => send_failure("telegram setWebhook", e)
Ok(j) =>
if json_field_bool(j, "ok").unwrap_or(false) {
SendResult::ok()
} else {
let (desc, _) = telegram_error(j)
send_body_failure("telegram setWebhook", desc)
}
}
}
///|
/// Remove any registered webhook (required before long polling).
pub async fn telegram_delete_webhook(
cfg : PlatformConfig,
token : String,
) -> SendResult {
let api = "\{telegram_api_base(cfg, token)}/deleteWebhook"
let payload = json_obj([("drop_pending_updates", Json::boolean(false))])
match post_json(api, payload, json_headers()) {
Err(e) => send_failure("telegram deleteWebhook", e)
Ok(_) => SendResult::ok()
}
}
///|
/// Convert one Telegram `Message`/`ChannelPost` object into a `MessageEvent`.
pub fn telegram_parse_message(msg : Json) -> MessageEvent? {
let chat = json_field_obj(msg, "chat")
let chat_id = match chat {
Some(c) => json_id(c, "id")
None => None
}
match chat_id {
None => None
Some(chat_id) => {
let from = json_field_obj(msg, "from")
let user_id = match from {
Some(f) => json_id(f, "id")
None => None
}
let user_name = match from {
Some(f) =>
or_opt(
json_field_string(f, "username"),
json_field_string(f, "first_name"),
)
None => None
}
let text = or_opt(
json_field_string(msg, "text"),
json_field_string(msg, "caption"),
).unwrap_or("")
let (mtype, media) = telegram_media_of(msg)
let reply = json_field_obj(msg, "reply_to_message")
let reply_id = match reply {
Some(r) => json_id(r, "message_id")
None => None
}
let reply_text = match reply {
Some(r) => json_field_string(r, "text")
None => None
}
let message_type = if text.has_prefix("/") { Command } else { mtype }
Some(
MessageEvent::new(
text,
message_type,
Telegram,
chat_id,
user_id?,
user_name?,
message_id?=json_id(msg, "message_id"),
reply_to_message_id?=reply_id,
reply_to_text?=reply_text,
media~,
timestamp=json_field_int(msg, "date").unwrap_or(0).to_int64() * 1000L,
),
)
}
}
}
///|
/// Classify the media carried by a Telegram message.
fn telegram_media_of(msg : Json) -> (MessageType, Array[MediaAttachment]) {
let out : Array[MediaAttachment] = []
if json_field_array(msg, "photo") is Some(photos) {
if photos.length() > 0 {
let best = photos[photos.length() - 1]
match json_field_string(best, "file_id") {
Some(fid) => out.push({ url: fid, kind: "photo", })
None => ()
}
}
return (Photo, out)
}
if json_field_obj(msg, "voice") is Some(v) {
match json_field_string(v, "file_id") {
Some(fid) => out.push({ url: fid, kind: "voice", })
None => ()
}
return (Voice, out)
}
if json_field_obj(msg, "audio") is Some(a) {
match json_field_string(a, "file_id") {
Some(fid) => out.push({ url: fid, kind: "audio", })
None => ()
}
return (Audio, out)
}
if json_field_obj(msg, "video") is Some(v) {
match json_field_string(v, "file_id") {
Some(fid) => out.push({ url: fid, kind: "video", })
None => ()
}
return (Video, out)
}
if json_field_obj(msg, "document") is Some(d) {
match json_field_string(d, "file_id") {
Some(fid) => out.push({ url: fid, kind: "document", })
None => ()
}
return (Document, out)
}
if json_field_obj(msg, "sticker") is Some(s) {
match json_field_string(s, "file_id") {
Some(fid) => out.push({ url: fid, kind: "sticker", })
None => ()
}
return (Sticker, out)
}
if json_field_obj(msg, "location") is Some(_) {
return (Location, out)
}
(Text, out)
}
///|
/// Parse a `{"update_id":N,"message":{…}}` webhook body into events.
pub fn telegram_parse_update(update : Json) -> Array[MessageEvent] {
let out : Array[MessageEvent] = []
for key in ["message", "edited_message", "channel_post"] {
match json_field_obj(update, key) {
Some(msg) =>
match telegram_parse_message(msg) {
Some(ev) => out.push(ev)
None => ()
}
None => ()
}
}
if json_field_obj(update, "callback_query") is Some(cb) {
let from = json_field_obj(cb, "from")
let user_id = match from {
Some(f) => json_id(f, "id")
None => None
}
let user_name = match from {
Some(f) => json_field_string(f, "username")
None => None
}
let chat_id = match json_field_obj(cb, "message") {
Some(m) =>
match json_field_obj(m, "chat") {
Some(c) => json_id(c, "id")
None => None
}
None => None
}
match chat_id {
Some(chat_id) =>
out.push(
MessageEvent::new(
json_field_string(cb, "data").unwrap_or(""),
Command,
Telegram,
chat_id,
user_id?,
user_name?,
message_id?=json_field_string(cb, "id"),
media=[],
timestamp=now_ms(),
),
)
None => ()
}
}
out
}
///|
/// Parse a whole `getUpdates` result array.
pub fn telegram_parse_updates(updates : Array[Json]) -> Array[MessageEvent] {
let out : Array[MessageEvent] = []
for u in updates {
for ev in telegram_parse_update(u) {
out.push(ev)
}
}
out
}