Services

Telegram bots

A Telegram bot through the Bot API: messages split at 4096 characters, photos by URL or upload, inline keyboards checked before they are sent, updates by webhook or long polling read into one shape, the webhook's secret token compared, and the Login Widget's hash verified.

Version 1.0.0 · 13 functions · 5 crates

#chat#bots#webhooks#signing

Functions

What use telegram.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use telegram_call, telegram_send from telegram (Quillo 0.6).

telegram_call(method: str, params: any, options: any) → anyor an error

Calls any Bot API method with its parameters (a map), giving its result. A 429 is waited out and tried again, twice at most. Options: token (else TELEGRAM_BOT_TOKEN).

telegram_send(chat_id: any, text: str, options: any) → listor an error

Sends text to chat_id (a number or @channel), split into several messages over 4096 characters, a keyboard going on the last. Options: parse_mode ("HTML", "MarkdownV2"), reply_markup, disable_notification, protect_content, message_thread_id, reply_parameters, link_preview_options, token. Gives the messages sent.

telegram_photo(chat_id: any, photo: str, caption: str, options: any) → anyor an error

Sends a photo by URL or by a file_id Telegram already has, with a caption (or ""). Options as for telegram_send.

telegram_photo_upload(chat_id: any, path: str, caption: str, options: any) → anyor an error

Uploads a photo from a file on disk, as multipart/form-data (10 MB at most).

telegram_keyboard(rows: any) → anyor an error

An inline keyboard for reply_markup: rows is a list of rows, each a list of { text, url } or { text, callback_data } (at most 64 bytes). Checked here, as Telegram's own error does not say which button is wrong.

telegram_answer_callback(callback_id: str, text: str, show_alert: bool, options: any) → boolor an error

Answers a button press: without this the button keeps spinning. text ("" for none) shows as a note, or an alert with show_alert.

telegram_edit(chat_id: any, message_id: int, text: str, options: any) → boolor an error

Changes a message the bot sent. Telegram's "message is not modified" is not an error here, as the message already says this.

telegram_set_webhook(url: str, secret: str, options: any) → boolor an error

Tells Telegram where to post updates: an https url, and a secret of 1 to 256 of A-Z, a-z, 0-9, _ and - that comes back in a header. Options: allowed_updates, max_connections, drop_pending_updates.

telegram_webhook_ok(headers: map, secret: str) → bool

Whether a webhook request is really from Telegram: its X-Telegram-Bot-Api-Secret-Token header (headers are the request's) against the secret given to setWebhook, compared in constant time.

telegram_updates(offset: int, wait_s: int, options: any) → listor an error

Long polls getUpdates from offset (the last update_id + 1, 0 at first) for up to wait_s seconds; each update read as telegram_read_update does. Fails with a 409 while a webhook is set.

telegram_read_update(body: str) → anyor an error

An update from a webhook's body read into { update_id, kind, chat_id, message_id, user, text, photo, command } for a message, or { kind: "callback", callback_id, data, user, chat_id, message_id } for a button.

telegram_escape(text: str) → str

MarkdownV2 text with every special character escaped, as the mode refuses them bare.

telegram_login_check(fields: map, max_age_s: int, options: any) → anyor an error

Checks a Login Widget sign-in: fields are the query it sent (id, first_name, username, photo_url, auth_date, hash). The hash is an HMAC-SHA256 with the SHA-256 of the bot token as its key, and sign-ins older than max_age_s are refused (0: no limit). Gives the user's fields without the hash.

Get it

$ quillo install telegram               # telegram.qwer into this folder$ quillo install telegram --example     # and its example, telegram.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/telegram.qwer$ curl -O https://quillolang.org/plugins/files/telegram.qwe

Then, in your program, all of it or only what you call:

main.qwe
use telegram.qweruse telegram_call, telegram_send from telegram      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
ureq = "2"hmac = "0.12"sha2 = "0.10"hex = "0.4"serde_json = { version = "1", features = ["preserve_order"] }

Example

telegram.qwe
// A Telegram bot: a message, a long one split, a photo by URL and by upload, buttons, a webhook whose// secret is checked before its update is believed, and the Login Widget's hash verified.////   TELEGRAM_BOT_TOKEN=... TELEGRAM_CHAT_ID=... TELEGRAM_WEBHOOK_SECRET=... quillo run telegram.qwe//   then open http://localhost:8080/send, /long, /photo, /buttons and /login//// The token comes from @BotFather. Point the bot at POST /telegram/hook with setWebhook, or see// /poll. TELEGRAM_API_URL reaches a local Bot API server or a test.use "telegram.qwer"let CHAT = env("TELEGRAM_CHAT_ID", "")let SECRET = env("TELEGRAM_WEBHOOK_SECRET", "")route GET /send {    let sent = telegram_send(CHAT, `*Order ${telegram_escape("#1001")}* shipped`, { parse_mode: "MarkdownV2" })?    { message_id: sent[0].message_id, chat: sent[0].chat.id }}route GET /long {    let sent = telegram_send(CHAT, repeat("A line of the report.\n", 400), {})?    { parts: len(sent), lengths: map(sent, (m) => len(m.text)) }}route GET /photo {    write_file("data/telegram-demo.jpg", "not really a jpeg")    {        by_url: telegram_photo(CHAT, "https://example.com/cat.jpg", "A cat", {})?.caption,        uploaded: telegram_photo_upload(CHAT, "data/telegram-demo.jpg", "From disk", {})?.caption,        long_caption: telegram_photo(CHAT, "https://example.com/cat.jpg", repeat("x", 1025), {}).error,    }}route GET /buttons {    let keys = telegram_keyboard([        [{ text: "Approve", callback_data: "approve:1001" }, { text: "Reject", callback_data: "reject:1001" }],        [{ text: "Open", url: "https://example.com/orders/1001" }],    ])?    let sent = telegram_send(CHAT, "Order 1001 needs a decision", { reply_markup: keys })?    let id = sent[0].message_id    {        buttons: len(sent[0].reply_markup.inline_keyboard),        edited: telegram_edit(CHAT, id, "Order 1001: approved", {})?,        same_again: telegram_edit(CHAT, id, "Order 1001: approved", {})?,        too_much: telegram_keyboard([[{ text: "x", callback_data: repeat("y", 65) }]]).error,        no_action: telegram_keyboard([[{ text: "x" }]]).error,    }}route GET /failures = {    no_chat: telegram_send("-1", "Hi", {}).error,    blocked: telegram_send("403", "Hi", {}).error,    empty: telegram_send(CHAT, " ", {}).error,    retried: telegram_send(CHAT, "flood", {})?[0].text,}route GET /hook-setup = {    set: telegram_set_webhook("https://example.com/telegram/hook", SECRET, { allowed_updates: ["message", "callback_query"] })?,    bad_secret: telegram_set_webhook("https://example.com/telegram/hook", "no spaces allowed", {}).error,}route GET /poll {    let got = telegram_updates(0, 0, {})?    { count: len(got), first: got[0].kind, text: got[0].text, command: got[0].command, press: got[1].data }}// Telegram posts here: the secret first, then the update.route POST /telegram/hook {    if !telegram_webhook_ok(request.headers, SECRET) { reply 401 "not from Telegram" }    let u = telegram_read_update(request.body)?    if u.kind == "callback" { telegram_answer_callback(u.callback_id, "Done", false, {})? }    { kind: u.kind, text: u.text ?? u.data }}route GET /hook-check = {    good: telegram_webhook_ok({ "X-Telegram-Bot-Api-Secret-Token": SECRET }, SECRET),    wrong: telegram_webhook_ok({ "X-Telegram-Bot-Api-Secret-Token": "guess" }, SECRET),    absent: telegram_webhook_ok({}, SECRET),}// A sign-in with a hash worked out outside this plugin, for the bot token in `login`; a changed// field fails it. A real one is a fresh auth_date, so give it a limit such as 86400.let login = { token: "110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw" }let signed = { id: "42", first_name: "Ada", username: "ada", auth_date: "1790000000", hash: "23ba64017d6199737e8d7559955c5422d50c44c97b606fd9a3783420d0419e86" }route GET /login = {    user: telegram_login_check(signed, 0, login)?.first_name,    tampered: telegram_login_check({ ...signed, id: "43" }, 0, login).error,    too_old: telegram_login_check(signed, 60, login).error,}
$ quillo run telegram.qwe$ curl 'localhost:8080/send'$ curl 'localhost:8080/long'$ curl 'localhost:8080/photo'$ curl 'localhost:8080/buttons'$ curl 'localhost:8080/failures'$ curl 'localhost:8080/hook-setup'$ curl 'localhost:8080/poll'$ curl 'localhost:8080/hook-check'$ curl 'localhost:8080/login'

It talks to the Redis that REDIS_URL names, else Redis on this machine. The site's test runs it against a stand-in.

Source

telegram.qwer
//! A Telegram bot through the Bot API: messages split at Telegram's limit, photos by URL or upload, inline keyboards, updates by webhook or polling read into one shape, the webhook's secret token checked, and the Login Widget's hash verified.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! hmac = "0.12"//! sha2 = "0.10"//! hex = "0.4"//! serde_json = { version = "1", features = ["preserve_order"] }use hmac::{Hmac, Mac};use serde_json::{json, Value};use sha2::{Digest, Sha256};use std::time::{Duration, SystemTime, UNIX_EPOCH};const LIMIT: usize = 4096;const CAPTION_LIMIT: usize = 1024;fn token_of(o: &Value) -> Result<String, String> {    o.get("token").and_then(|t| t.as_str()).map(str::to_string).or_else(|| std::env::var("TELEGRAM_BOT_TOKEN").ok()).filter(|t| !t.is_empty()).ok_or("no bot token: set TELEGRAM_BOT_TOKEN (from @BotFather), or give the option token".to_string())}fn base() -> String {    std::env::var("TELEGRAM_API_URL").ok().filter(|u| !u.is_empty()).unwrap_or_else(|| "https://api.telegram.org".into()).trim_end_matches('/').to_string()}fn agent(read_s: u64) -> ureq::Agent {    ureq::AgentBuilder::new().timeout_connect(Duration::from_secs(10)).timeout_read(Duration::from_secs(read_s)).build()}// A failed call answers { ok: false, error_code, description, parameters }. A 429 says how long to wait.fn finish(method: &str, text: &str) -> Result<Result<Value, (Option<u64>, String)>, String> {    let v: Value = serde_json::from_str(text).map_err(|_| format!("Telegram's answer to {} is not JSON: {:?}", method, text.chars().take(200).collect::<String>()))?;    if v["ok"] == true {        return Ok(Ok(v["result"].clone()));    }    let code = v["error_code"].as_i64().unwrap_or(0);    let desc = v["description"].as_str().unwrap_or("no description");    let mut msg = format!("Telegram {}: {}", code, desc);    if let Some(to) = v.pointer("/parameters/migrate_to_chat_id") {        msg.push_str(&format!(" (the group is now the supergroup {})", to));    }    match code {        401 => msg.push_str(" (check TELEGRAM_BOT_TOKEN)"),        403 => msg.push_str(" (the user blocked the bot, or never started it)"),        _ => {}    }    let wait = if code == 429 { Some(v.pointer("/parameters/retry_after").and_then(|r| r.as_u64()).unwrap_or(1)) } else { None };    Ok(Err((wait, msg)))}fn post(token: &str, method: &str, params: &Value, read_s: u64) -> Result<Value, String> {    let url = format!("{}/bot{}/{}", base(), token, method);    let body = params.to_string();    let mut tries = 0;    loop {        let res = agent(read_s).post(&url).set("Content-Type", "application/json").send_string(&body);        let text = match res {            Ok(r) => r.into_string().map_err(|e| e.to_string())?,            Err(ureq::Error::Status(_, r)) => r.into_string().unwrap_or_default(),            // The token is in the URL, so ureq's own message must not be shown.            Err(ureq::Error::Transport(t)) => return Err(format!("cannot reach Telegram at {}: {}", base(), t.kind())),        };        match finish(method, &text)? {            Ok(v) => return Ok(v),            Err((Some(wait), _)) if tries < 2 && wait <= 30 => {                std::thread::sleep(Duration::from_secs(wait));                tries += 1;            }            Err((_, msg)) => return Err(msg),        }    }}/// Calls any Bot API method with its parameters (a map), giving its `result`. A 429 is waited out and tried again, twice at most. Options: token (else TELEGRAM_BOT_TOKEN).pub fn telegram_call(method: &str, params: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> {    let p = if params.is_null() { json!({}) } else { params };    post(&token_of(&options)?, method, &p, 30)}// Telegram counts UTF-16 code units; a long text breaks at a line, else a space, else anywhere.fn split(text: &str, limit: usize) -> Vec<String> {    let mut out = vec![];    let mut rest = text;    loop {        let mut units = 0;        let mut cut = rest.len();        for (i, c) in rest.char_indices() {            if units + c.len_utf16() > limit {                cut = i;                break;            }            units += c.len_utf16();        }        if cut == rest.len() {            out.push(rest.to_string());            return out;        }        let head = &rest[..cut];        let at = head.rfind('\n').or_else(|| head.rfind(' ')).filter(|i| *i > 0).unwrap_or(cut);        out.push(rest[..at].trim_end().to_string());        rest = rest[at..].trim_start_matches(['\n', ' ']);    }}fn with_options(mut p: Value, o: &Value) -> Value {    for k in ["parse_mode", "reply_markup", "disable_notification", "protect_content", "message_thread_id", "reply_parameters", "link_preview_options"] {        if let Some(v) = o.get(k) {            p[k] = v.clone();        }    }    p}/// Sends `text` to `chat_id` (a number or @channel), split into several messages over 4096 characters, a keyboard going on the last. Options: parse_mode ("HTML", "MarkdownV2"), reply_markup, disable_notification, protect_content, message_thread_id, reply_parameters, link_preview_options, token. Gives the messages sent.pub fn telegram_send(chat_id: serde_json::Value, text: &str, options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> {    if text.trim().is_empty() {        return Err("Telegram refuses an empty message".into());    }    let token = token_of(&options)?;    let parts = split(text, LIMIT);    let n = parts.len();    if n > 1 && options.get("parse_mode").is_some() {        return Err(format!("the text is over {} characters: a split could cut a tag in two, so send it in parts yourself or without parse_mode", LIMIT));    }    let mut out = vec![];    for (i, part) in parts.into_iter().enumerate() {        let mut p = with_options(json!({ "chat_id": chat_id, "text": part }), &options);        if i + 1 < n {            p.as_object_mut().map(|m| m.remove("reply_markup"));        }        out.push(post(&token, "sendMessage", &p, 30)?);    }    Ok(out)}fn caption_ok(caption: &str) -> Result<(), String> {    let n: usize = caption.chars().map(char::len_utf16).sum();    if n > CAPTION_LIMIT { Err(format!("a caption is at most {} characters; this one is {}", CAPTION_LIMIT, n)) } else { Ok(()) }}/// Sends a photo by URL or by a file_id Telegram already has, with a `caption` (or ""). Options as for telegram_send.pub fn telegram_photo(chat_id: serde_json::Value, photo: &str, caption: &str, options: serde_json::Value) -> Result<serde_json::Value, String> {    caption_ok(caption)?;    let mut p = with_options(json!({ "chat_id": chat_id, "photo": photo }), &options);    if !caption.is_empty() {        p["caption"] = json!(caption);    }    post(&token_of(&options)?, "sendPhoto", &p, 60)}/// Uploads a photo from a file on disk, as multipart/form-data (10 MB at most).pub fn telegram_photo_upload(chat_id: serde_json::Value, path: &str, caption: &str, options: serde_json::Value) -> Result<serde_json::Value, String> {    caption_ok(caption)?;    let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {}", path, e))?;    if bytes.len() > 10_000_000 {        return Err(format!("a photo is at most 10 MB; {} is {} bytes", path, bytes.len()));    }    let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("photo.jpg").replace('"', "");    let boundary = format!("quillo{:x}", SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(1));    let mut body = Vec::with_capacity(bytes.len() + 1024);    let mut field = |k: &str, v: &str| body.extend_from_slice(format!("--{}\r\nContent-Disposition: form-data; name=\"{}\"\r\n\r\n{}\r\n", boundary, k, v).as_bytes());    field("chat_id", &chat_id.as_str().map(str::to_string).unwrap_or(chat_id.to_string()));    if !caption.is_empty() {        field("caption", caption);    }    for k in ["parse_mode", "reply_markup"] {        match options.get(k) {            Some(Value::String(s)) => field(k, s),            Some(v) => field(k, &v.to_string()),            None => {}        }    }    body.extend_from_slice(format!("--{}\r\nContent-Disposition: form-data; name=\"photo\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n", boundary, name).as_bytes());    body.extend_from_slice(&bytes);    body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());    let token = token_of(&options)?;    let url = format!("{}/bot{}/sendPhoto", base(), token);    let res = agent(120).post(&url).set("Content-Type", &format!("multipart/form-data; boundary={}", boundary)).send_bytes(&body);    let text = match res {        Ok(r) => r.into_string().map_err(|e| e.to_string())?,        Err(ureq::Error::Status(_, r)) => r.into_string().unwrap_or_default(),        Err(ureq::Error::Transport(t)) => return Err(format!("cannot reach Telegram at {}: {}", base(), t.kind())),    };    finish("sendPhoto", &text)?.map_err(|(_, m)| m)}/// An inline keyboard for `reply_markup`: `rows` is a list of rows, each a list of { text, url } or { text, callback_data } (at most 64 bytes). Checked here, as Telegram's own error does not say which button is wrong.pub fn telegram_keyboard(rows: serde_json::Value) -> Result<serde_json::Value, String> {    let list = rows.as_array().ok_or("rows must be a list of rows of buttons")?;    if list.is_empty() {        return Err("the keyboard has no rows".into());    }    for row in list {        for b in row.as_array().ok_or("each row must be a list of buttons")? {            let t = b["text"].as_str().unwrap_or("");            if t.is_empty() {                return Err(format!("a button has no text: {}", b));            }            let kinds = ["url", "callback_data", "web_app", "login_url", "switch_inline_query", "switch_inline_query_current_chat", "copy_text", "pay"];            if !kinds.iter().any(|k| b.get(*k).is_some()) {                return Err(format!("the button {:?} needs a url or a callback_data", t));            }            if let Some(d) = b["callback_data"].as_str() {                if d.is_empty() || d.len() > 64 {                    return Err(format!("the button {:?} has {} bytes of callback_data; it takes 1 to 64", t, d.len()));                }            }        }    }    Ok(json!({ "inline_keyboard": rows }))}/// Answers a button press: without this the button keeps spinning. `text` ("" for none) shows as a note, or an alert with `show_alert`.pub fn telegram_answer_callback(callback_id: &str, text: &str, show_alert: bool, options: serde_json::Value) -> Result<bool, String> {    let mut p = json!({ "callback_query_id": callback_id, "show_alert": show_alert });    if !text.is_empty() {        p["text"] = json!(text);    }    post(&token_of(&options)?, "answerCallbackQuery", &p, 30).map(|r| r == true)}/// Changes a message the bot sent. Telegram's "message is not modified" is not an error here, as the message already says this.pub fn telegram_edit(chat_id: serde_json::Value, message_id: i64, text: &str, options: serde_json::Value) -> Result<bool, String> {    let p = with_options(json!({ "chat_id": chat_id, "message_id": message_id, "text": text }), &options);    match post(&token_of(&options)?, "editMessageText", &p, 30) {        Ok(_) => Ok(true),        Err(e) if e.contains("message is not modified") => Ok(false),        Err(e) => Err(e),    }}/// Tells Telegram where to post updates: an https `url`, and a `secret` of 1 to 256 of A-Z, a-z, 0-9, _ and - that comes back in a header. Options: allowed_updates, max_connections, drop_pending_updates.pub fn telegram_set_webhook(url: &str, secret: &str, options: serde_json::Value) -> Result<bool, String> {    if secret.is_empty() || secret.len() > 256 || !secret.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {        return Err("the secret is 1 to 256 of A-Z, a-z, 0-9, _ and -".into());    }    let mut p = json!({ "url": url, "secret_token": secret });    for k in ["allowed_updates", "max_connections", "drop_pending_updates"] {        if let Some(v) = options.get(k) {            p[k] = v.clone();        }    }    post(&token_of(&options)?, "setWebhook", &p, 30).map(|r| r == true)}fn same(a: &str, b: &str) -> bool {    a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0}/// Whether a webhook request is really from Telegram: its X-Telegram-Bot-Api-Secret-Token header (`headers` are the request's) against the `secret` given to setWebhook, compared in constant time.pub fn telegram_webhook_ok(headers: std::collections::HashMap<String, serde_json::Value>, secret: &str) -> bool {    let got = headers.iter().find(|(k, _)| k.eq_ignore_ascii_case("x-telegram-bot-api-secret-token")).and_then(|(_, v)| v.as_str());    !secret.is_empty() && got.map(|g| same(g, secret)).unwrap_or(false)}/// Long polls getUpdates from `offset` (the last update_id + 1, 0 at first) for up to `wait_s` seconds; each update read as telegram_read_update does. Fails with a 409 while a webhook is set.pub fn telegram_updates(offset: i64, wait_s: i64, options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> {    let wait = wait_s.clamp(0, 50);    let r = post(&token_of(&options)?, "getUpdates", &json!({ "offset": offset, "timeout": wait }), wait as u64 + 10)?;    Ok(r.as_array().map(|l| l.iter().map(read).collect()).unwrap_or_default())}fn read(u: &Value) -> Value {    let id = u["update_id"].clone();    if let Some(q) = u.get("callback_query") {        return json!({ "update_id": id, "kind": "callback", "callback_id": q["id"], "data": q["data"], "user": q["from"], "chat_id": q.pointer("/message/chat/id"), "message_id": q.pointer("/message/message_id") });    }    for (key, kind) in [("message", "message"), ("edited_message", "edited"), ("channel_post", "channel_post")] {        if let Some(m) = u.get(key) {            let text = m.get("text").or(m.get("caption")).cloned().unwrap_or(Value::Null);            // The biggest size comes last.            let photo = m.get("photo").and_then(|p| p.as_array()).and_then(|l| l.last()).map(|p| p["file_id"].clone()).unwrap_or(Value::Null);            let command = text.as_str().filter(|t| t.starts_with('/')).and_then(|t| t.split_whitespace().next()).map(|c| c.split('@').next().unwrap_or(c).to_string());            return json!({ "update_id": id, "kind": kind, "chat_id": m.pointer("/chat/id"), "message_id": m["message_id"], "user": m.get("from").cloned().unwrap_or(Value::Null), "text": text, "photo": photo, "command": command });        }    }    let kind = u.as_object().and_then(|m| m.keys().find(|k| *k != "update_id").cloned()).unwrap_or_default();    json!({ "update_id": id, "kind": kind })}/// An update from a webhook's body read into { update_id, kind, chat_id, message_id, user, text, photo, command } for a message, or { kind: "callback", callback_id, data, user, chat_id, message_id } for a button.pub fn telegram_read_update(body: &str) -> Result<serde_json::Value, String> {    let v: Value = serde_json::from_str(body).map_err(|e| format!("the update is not JSON: {}", e))?;    Ok(read(&v))}/// MarkdownV2 text with every special character escaped, as the mode refuses them bare.pub fn telegram_escape(text: &str) -> String {    let mut o = String::with_capacity(text.len());    for c in text.chars() {        if "_*[]()~`>#+-=|{}.!\\".contains(c) {            o.push('\\');        }        o.push(c);    }    o}fn data_check(fields: &std::collections::HashMap<String, serde_json::Value>) -> String {    let mut lines: Vec<String> = fields.iter().filter(|(k, _)| *k != "hash").map(|(k, v)| format!("{}={}", k, v.as_str().map(str::to_string).unwrap_or(v.to_string()))).collect();    lines.sort();    lines.join("\n")}fn login_hash(token: &str, check: &str) -> String {    let secret = Sha256::digest(token.as_bytes());    let mut m = Hmac::<Sha256>::new_from_slice(&secret).expect("HMAC takes any key");    m.update(check.as_bytes());    hex::encode(m.finalize().into_bytes())}/// Checks a Login Widget sign-in: `fields` are the query it sent (id, first_name, username, photo_url, auth_date, hash). The hash is an HMAC-SHA256 with the SHA-256 of the bot token as its key, and sign-ins older than `max_age_s` are refused (0: no limit). Gives the user's fields without the hash.pub fn telegram_login_check(fields: std::collections::HashMap<String, serde_json::Value>, max_age_s: i64, options: serde_json::Value) -> Result<serde_json::Value, String> {    let got = fields.get("hash").and_then(|h| h.as_str()).ok_or("the sign-in has no hash")?.to_ascii_lowercase();    let want = login_hash(&token_of(&options)?, &data_check(&fields));    if !same(&got, &want) {        return Err("the sign-in's hash does not match: it did not come from Telegram for this bot".into());    }    let date = fields.get("auth_date").and_then(|d| d.as_str().map(str::to_string).or(d.as_i64().map(|n| n.to_string()))).and_then(|d| d.parse::<i64>().ok()).ok_or("the sign-in has no auth_date")?;    let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);    if max_age_s > 0 && now - date > max_age_s {        return Err(format!("the sign-in is {} s old, over the {} s allowed", now - date, max_age_s));    }    let mut user = serde_json::Map::new();    let mut keys: Vec<&String> = fields.keys().filter(|k| *k != "hash").collect();    keys.sort();    for k in keys {        user.insert(k.clone(), fields[k].clone());    }    Ok(Value::Object(user))}
WhatsApp Cloud API whatsapp.qwer · 1.0.0Send WhatsApp text and template messages through Meta's Cloud API, with the 24-hour window and missing templates named when they refuse a message, answer the webhook's verify handshake, check X-Hub-Signature-256, and flatten webhook bodies into the messages and statuses they carry.chatmessagingwebhookssigning2 views · 6 downloadsCloudinary cloudinary.qwer · 1.0.0Cloudinary without an SDK: signed uploads from a URL or a file, the signature a browser needs for a direct upload, delivery URLs with transformations and URL signatures, webhook and upload signatures checked, and assets listed by cursor.imagesuploadscdnwebhookssigning3 views · 8 downloadsDiscord discord.qwer · 1.0.0Messages to Discord channels through webhooks: embeds with color, fields, images and timestamps built and checked against Discord's limits, mentions off unless asked for, threads, and the rate limit's wait reported.chatnotificationswebhooksdevops7 views · 8 downloadsHubSpot hubspot.qwer · 1.0.0Keep HubSpot's CRM in step with a program: contacts and deals made, read and linked, searches and lists followed through every page, batch upserts in hundreds with their partial failures read back, and webhook v3 signatures checked with HubSpot's odd URI decoding.crmwebhookssigningbatch1 view · 6 downloadsLinear linear.qwer · 1.0.0Work with Linear through its GraphQL API: issues opened, changed and moved to a state by name, comments, lists walked by cursor, the rate limit it answers as an HTTP 400 waited out, and webhooks checked by signature and timestamp.ticketsgraphqlwebhookssigning1 view · 7 downloadsMatrix chat matrix_chat.qwer · 1.0.0A bot on Matrix through the client-server API: log in with a password, join a room by alias, send text, HTML and notices with transaction ids that make a retry safe, read new events with /sync, page back through history, and wait out M_LIMIT_EXCEEDED.chatbotsfederation3 views · 7 downloads
Thread
48Useful? Like it, or add to its thread below.

Thread 0 posts

On Telegram bots: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.