Skip to content

Rust SDK

The freshgram crate lives in Telegram/plugins_sdk/rust. It is a thin layer over the Wasm ABI: the only link to the host is the extern "C" import freshgram.call. On non-wasm targets that import is replaced by a stub that always fails, so every call returns an Error of kind Transport (“the host refused the call (-1)”); the crate can therefore be built and unit-tested on the development machine.

Everything below is exported from the crate root: freshgram::{plugin, map, Value, DecodeError, Error, ErrorKind, call, call_async, Sent, Response, FetchOptions} and all the ready-made functions (log, storage_get, …). All calls block the handler until the host answers, except call_async (see Async calls).

Limits and permissions are not repeated in full here; the per-function table links to the reference pages where each operation is described exactly. The Python and C SDKs use the same operations, see Python and C.

Cargo.toml of your plugin crate:

[package]
name = "counter"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
freshgram = { path = "../../plugins_sdk/rust" } # path to the SDK directory, relative to THIS crate
[profile.release]
opt-level = "s"
lto = true
panic = "abort"
strip = true
  • path is resolved relative to the directory of the plugin crate’s Cargo.toml. In the bundled example (plugins_sdk/rust/examples/counter) it is ../..; for a crate anywhere else write the real relative path to Telegram/plugins_sdk/rust.
  • crate-type = ["cdylib"] is required, otherwise no .wasm module with the exports is produced.
  • [profile.release] is read only from the root manifest of a workspace. If your crate is a workspace member (as the example is, listed in members of the SDK workspace), put the profile in the root Cargo.toml; in a standalone crate it stays in the crate’s own manifest.
  • The SDK has no dependencies besides std.

Build:

Terminal window
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown

Copy target/wasm32-unknown-unknown/release/<crate>.wasm next to manifest.json and name it in runtime.module (with "language": "rust"). A complete minimal plugin is in the Wasm quick start.

freshgram::plugin!(init = init, event = event); // with an init handler
freshgram::plugin!(event = event); // without: init does nothing and returns 0

Both handlers are paths to functions with the signature fn(&Value) -> i32 (plain functions, not closures). The macro generates the exports the host expects:

Export What it does
fg_alloc(size: u32) -> u32 allocates a zeroed block of max(size, 1) bytes with the Rust allocator
fg_free(pointer: u32, size: u32) frees such a block; a null pointer is ignored
fg_init(pointer, size) -> i32 installs the panic hook (once), decodes the info map, calls your init handler
fg_on_event(pointer, size) -> i32 decodes the event map, calls your event handler

Use the macro once per crate (the export names are fixed).

Decoding. The block is decoded with Value::decode. If decoding fails, your handler is not called and the export returns -1.

Panics. The hook writes panic: <message and location> to the plugin log (through log). With panic = "abort" (the profile above) the module then traps, which the host treats as a failure: the instance is dead and is restarted later with a backoff. Do not use panics for normal error handling.

What the handler returns. The i32 is passed to the host as the status of the finished event. 0 means success. A non-zero status is not a crash and is not counted as a failure, and the host does not interpret the number:

Where What a non-zero status does
init the line failure: the start handler returned N is written to the plugin log; startup continues
ordinary events ignored
message hooks (message.outgoing and the incoming transforms) the plugin’s edit is discarded; for a blocking hook the message is refused (“the plugin failed”)

So return 0 on success and a negative number for your own errors. See Return values.

The event. The handler gets the event map (event.get("type"), …); fields of each event are in Event payloads. Events are delivered one at a time and the handler is never re-entered. The decoded Value is an owned copy, and the input block is freed by the host after the handler returns. The event time limit (eventTimeoutMs, default 500 ms) does not count time spent inside host calls, which is capped separately (60 s and 256 calls per event); see Limits. Memory comes from the Rust allocator, so the heap grows up to memoryMb of the manifest.

A CBOR value. Clone, Debug, PartialEq.

pub enum Value {
Null,
Bool(bool),
Int(i64),
Float(f64),
Bytes(Vec<u8>),
Text(String),
Array(Vec<Value>),
Map(Vec<(String, Value)>), // keys are always text; order is kept; a Vec, not a hash map
}

Bytes and Text are different types: a Vec<u8> is never turned into an array of numbers or into text. There is no as_map; look up keys with get or match on Value::Map(entries).

Method Signature Result
get fn get(&self, key: &str) -> Option<&Value> the value of the first entry with this key; None if the key is missing or self is not a Map
as_i64 fn as_i64(&self) -> Option<i64> Some only for Int (a Float gives None)
as_f64 fn as_f64(&self) -> Option<f64> Some for Float and also for Int (converted with as f64)
as_bool fn as_bool(&self) -> Option<bool> Some only for Bool
as_str fn as_str(&self) -> Option<&str> Some only for Text
as_bytes fn as_bytes(&self) -> Option<&[u8]> Some only for Bytes
as_array fn as_array(&self) -> Option<&[Value]> Some only for Array
is_null fn is_null(&self) -> bool true only for Null
encode fn encode(&self) -> Vec<u8> the CBOR bytes
decode fn decode(data: &[u8]) -> Result<Value, DecodeError> associated function: Value::decode(bytes)

Chaining is the usual way to read an event:

let chat = event.get("chat").and_then(Value::as_i64).unwrap_or(0);
let text = event.get("text").and_then(Value::as_str).unwrap_or("");

encode uses the shortest form for integers, always writes floats as 64-bit, and writes map keys as text. It does not remove duplicate keys. It has no depth limit, but the host refuses requests nested deeper than 32 levels.

decode reads exactly one item and requires the whole slice to be used. It accepts 16, 32 and 64-bit floats (all become Float). It rejects what the host rejects. Container nesting deeper than 32 levels is refused with TooDeep.

Clone, Copy, Debug, PartialEq, Eq. Eight variants:

Variant When
Truncated the data ends too early, or an array or map claims more items than the remaining bytes can hold
Malformed a reserved additional-information value (28 to 30)
Unsupported tags (CBOR major type 6), indefinite-length items and the break code, simple values other than false, true, null and floats (for example undefined)
TooDeep arrays and maps nested deeper than 32 levels
IntRange an integer that does not fit i64 (above i64::MAX or below i64::MIN)
BadUtf8 a text string that is not valid UTF-8
KeyNotText a map key that is not a text string
TrailingData bytes left after the first item

Display prints invalid CBOR: <Variant>, for example invalid CBOR: Truncated. DecodeError implements std::error::Error.

From Becomes
bool Bool
i64, i32, u32 Int
f64 Float
&str, String Text
Vec<u8> Bytes
Vec<Value> Array

There is no From for u64, usize, &[u8], Option<T>, Vec<String> or maps. Build a map with map! or Value::Map(vec![...]), and convert other lists yourself (items.iter().map(|s| Value::from(*s)).collect::<Vec<_>>()). Value converts into itself, so a Value can be passed anywhere an Into<Value> is expected.

The functions that take impl Into<Value> accept exactly these types: storage_set(key, value) and plugin_send(to, data).

let arguments = map! { "chat" => chat, "text" => "hi", "silent" => true };

Builds a Value::Map in the order written. Every key goes through .to_string() (so a &str, a String or anything with Display works) and every value through Value::from(...) (so a Value or any type from the table above works). A trailing comma is allowed; duplicates are not removed.

pub struct Error { pub kind: ErrorKind, pub message: String } // Clone, Debug, PartialEq
pub enum ErrorKind { // Clone, Copy, Debug, PartialEq, Eq
Denied, // 0
Invalid, // 1
NotFound, // 2
Quota, // 3
Limit, // 4
Unavailable, // 5
Failed, // 6
Cancelled, // 7
Transport, // 8
}
  • There is no .code (unlike the error classes in Python). Branch on error.kind; error.message is the host’s short explanation (for Denied it is usually the permission id).
  • Display is "<Kind>: <message>" (the Debug name of the kind), for example Denied: storage.private. Error implements std::error::Error, so ? works into Box<dyn std::error::Error>.
  • The discriminants are the numbers in the comments above; error.kind as i32 yields them. They are not the FG_* values of the C header. See the note in the quick start.
  • The host’s error codes (denied, invalid, not_found, quota, limit, unavailable, cancelled) map to the same-named kinds; failed and any unknown code become Failed. In particular the network transport codes (dns, connect, tls, timeout, closed, response, redirect, blocked, …) all arrive as Failed; the code itself is not kept, so tell them apart by message. What each code means is in Errors and limits.
  • Transport is raised by the SDK, never by the host’s answer: the import returned a non-zero status (the request was not even processed, see Import), the answer could not be decoded (the message is the DecodeError text), or the answer has no ok field.
  • Failed is also used by the SDK when an answer lacks a field the wrapper needs (“the answer has no ticket”, “the answer has no id”).
pub struct Sent { pub id: i64, pub local_id: i64 } // Clone, Copy, Debug, Default, PartialEq, Eq

Returned by send_text, reply_text, send_media. A field the host did not provide is 0: id is 0 unless the plugin has the optional messages.ids permission (and also when the server did not answer in time); local_id is 0 for a scheduled message or when the app showed no message. A local id (a large negative number) is accepted wherever a message id is, until the app restarts.

pub struct Response { pub status: i64, pub headers: Vec<(String, String)>, pub body: Vec<u8> } // Clone, Debug, PartialEq

headers is built from the host’s header_list: every header in the order received, as (name, value) with lower-case names; repeated names appear repeatedly. Set-Cookie and Set-Cookie2 are removed by the host. body is the raw bytes (never decompressed); decode with String::from_utf8. HTTP error statuses are a normal Ok(Response). There are no text(), json() or ok helpers.

pub struct FetchOptions { // Clone, Debug, Default
pub method: Option<String>,
pub headers: Vec<(String, String)>,
pub body: Option<Vec<u8>>,
pub timeout_ms: Option<i64>,
}

FetchOptions::default() is a plain GET with no headers and no body. Fields that are None or empty are not sent, so the host defaults apply. Rules of the host (net.fetch):

Field Rule
method exactly GET, POST, PUT, PATCH, DELETE or HEAD, upper case; anything else is Invalid (“method or url”). Default GET
headers at most 32, name 1 to 64 characters, value up to 4096 bytes, no CR/LF; forbidden names (Host, Content-Length, Cookie, Connection, …) are Invalid
body up to 100 MiB; sent as bytes
timeout_ms clamped by the host to 100 to 300000 ms; default 10000 ms
use freshgram::{fetch, FetchOptions};
let answer = fetch("https://example.org/api", FetchOptions {
method: Some("POST".to_string()),
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
body: Some(br#"{"a":1}"#.to_vec()),
..Default::default()
})?;
if answer.status == 200 {
let text = String::from_utf8_lossy(&answer.body);
}
pub fn call(op: &str, arguments: Option<Value>) -> Result<Value, Error>
pub fn call_async(op: &str, arguments: Option<Value>) -> Result<i64, Error>

call sends {"op": op, "a": arguments} and returns the v of the answer (Value::Null when the operation has no result). arguments is None for operations without arguments, otherwise it must be a Map (anything else is Invalid: “arguments must be a map”). The host checks the rate limit, the permission and the arguments and answers with an error that becomes an Error.

Every operation in the operation list can be called through call, using the argument names of the reference pages (they are the same as in the raw protocol) and reading the result from the returned Value. The result shapes are in Data shapes.

use freshgram::{call, map, Value};
// messages.send with options the wrapper does not have
let sent = call("messages.send", Some(map! {
"chat" => chat, "text" => "**hi**", "format" => "markdown", "silent" => true,
}))?;
let local_id = sent.get("local_id").and_then(Value::as_i64);
// chats.mute, time.now ...
call("chats.mute", Some(map! { "chat" => chat, "seconds" => 3600 }))?;
let now = call("time.now", None)?.as_i64();

Check the exact argument names on the reference page of the operation first (messages, chats, users, bots, files and images, network, system). The permission must be in the manifest, as for any call.

call_async sends the same request with "async": true. It returns at once with the ticket (an i64, starting at 1, unique per plugin instance); the operation runs in the background. The result arrives later as an event delivered to your event handler (fg_on_event), with the same ticket:

{"type": "call.result", "ticket": 7, "ok": true, "v": <result>}
{"type": "call.result", "ticket": 7, "ok": false, "e": "<code>", "m": "<message>"}

There is no future to await: remember the ticket (for example in storage) and match it when the event arrives. The SDK has no helper that turns e and m into an Error; the code names are the ones listed under Errors.

use freshgram::{log, sleep_async, storage_set, Value};
fn event(event: &Value) -> i32 {
match event.get("type").and_then(Value::as_str) {
Some("command") => {
if let Ok(ticket) = sleep_async(1500) {
let _ = storage_set("sleep_ticket", ticket);
}
}
Some("call.result") => {
let ticket = event.get("ticket").and_then(Value::as_i64);
let ok = event.get("ok").and_then(Value::as_bool).unwrap_or(false);
if !ok {
let code = event.get("e").and_then(Value::as_str).unwrap_or("failed");
let _ = log(&format!("call {ticket:?} failed: {code}"));
}
}
_ => {}
}
0
}

Rules (The async mechanism):

  • At most 32 async calls may be pending; the 33rd fails at once with Limit. An async call also uses one of the 200 calls per second.
  • Errors found at submit time (Limit, Unavailable, Invalid) are returned by call_async itself; errors found while the operation runs (Denied, Invalid, rate limits, …) arrive in call.result.
  • time.sleep, files.pick, files.save and fs.* can only be called asynchronously (a synchronous call is Invalid: “must be awaited”). plugin.unload, messages.set_outgoing, messages.drop_outgoing, messages.set_incoming and messages.hide_incoming can not be async.
  • Async calls are refused (Unavailable) while a message hook is handled.
  • There is no cancellation. If the plugin stops before the answer, no call.result is delivered.
  • Any other operation can be made async with call_async, for example call_async("net.fetch", ...); the wrapper fetch is synchronous only.

All return Result<_, Error>; the second column lists the arguments and the Ok type. “Permission” is the manifest permission the host checks (permissions); “none” means the host checks nothing. Limits are those of the host and are enforced by it: the wrapper never validates, a violation comes back as Invalid or Limit. Text limits are in bytes of UTF-8.

Function Arguments and result Operation, permission, important limits
log (text: &str) -> Result<(), Error> log, none, allowed in hooks. text up to 4096 bytes (longer is Invalid; unlike Python, Rust does not shorten it). The log keeps the first 500 bytes of a line and the last 200 lines
unload () -> Result<(), Error> plugin.unload, none. Stops the plugin after the current event; it starts again on the next event that activates it. Timers survive. Sync only
storage_get (key: &str) -> Result<Option<Value>, Error> storage.get, storage.private. A stored null and a missing key both give None (the wrapper maps Null to None)
storage_set (key: &str, value: impl Into<Value>) -> Result<(), Error> storage.set, storage.private. Key 1 to 128 bytes, no control characters; value up to 1 MiB encoded and nested at most 16 levels (Limit); at most 4096 keys and the manifest quota (Quota)
storage_delete (key: &str) -> Result<(), Error> storage.delete, storage.private. The host answers whether the key existed, but the wrapper discards that bool; use call("storage.delete", ...) if you need it. A missing key is not an error
storage_keys (prefix: &str, limit: i64) -> Result<Vec<String>, Error> storage.list, storage.private. The wrapper always sends limit, so the host default of 100 never applies: pass the number you want. limit below 1 is Invalid, above 1000 is clamped to 1000. prefix up to 128 bytes ("" for all). Keys come sorted
timer_set (id: &str, delay_ms: i64, repeat: bool) -> Result<(), Error> timers.set, none. id 1 to 64 bytes; delay_ms from 1000 to 604800000 (7 days), else Invalid; at most 32 timers (Limit); the same id replaces the timer. Fires the event {"type": "timer", "id": ...}. Timers are not saved across app restarts
timer_clear (id: &str) -> Result<(), Error> timers.clear, none. The bool “a timer was removed” is discarded
sleep_async (ms: i64) -> Result<i64, Error> time.sleep via call_async, none. Returns the ticket; ms from 0 to 60000. The event call.result with v = null arrives when the time has passed. Counts toward the 32 pending calls
Function Arguments and result Operation, permission, important limits
send_text (chat: i64, text: &str) -> Result<Sent, Error> messages.send, messages.send. text 1 to 4096 bytes, plain text. No format, entities, silent, topic or schedule: use call for those. Shared send bucket: burst of 5, one more per second (Limit)
reply_text (chat: i64, text: &str, reply_to: i64) -> Result<Sent, Error> messages.send, messages.send. reply_to goes last; it must be a message in the app’s cache (NotFound otherwise); 0 means no reply. Same limits as send_text
send_media (chat: i64, data: Vec<u8>, name: &str, caption: &str, kind: &str) -> Result<Sent, Error> messages.send_media, messages.send. data non-empty, up to 100 MiB; name 1 to 128 bytes without / or \; caption up to 4096 bytes, plain. kind is always sent and must be "auto", "file" or "voice": pass "auto" explicitly (an empty string is not accepted). "voice" needs a duration, which the wrapper cannot send: use call. Send bucket. Media sent by a plugin skips the outgoing hooks
edit_text (chat: i64, message: i64, text: &str) -> Result<(), Error> messages.edit, messages.edit. Only your own messages; text 1 to 4096 bytes (cannot be cleared); message may be a local_id. Action bucket: burst of 20, 5 per second
edit_media (chat: i64, message: i64, data: Vec<u8>, name: &str, caption: &str, kind: &str) -> Result<(), Error> messages.edit_media, messages.edit. kind is "auto" or "file" ("voice" is Invalid); the new caption replaces the old one, "" clears it. Uses the send bucket; may wait up to 5 minutes
react (chat: i64, message: i64, emoji: &str) -> Result<(), Error> messages.react, messages.react. emoji up to 64 bytes; "" removes your reactions. No custom emoji. Action bucket
history (chat: i64, limit: i64, before: i64) -> Result<Value, Error> messages.history, messages.history. Returns an Array of message objects, newest first (Message object). limit is always sent and clamped to 1 to 100; before is a message id, 0 means from the newest. Unlike Python it has no topic and no from_user
set_outgoing (text: &str) -> Result<(), Error> messages.set_outgoing, messages.transform_outgoing. Only inside the message.outgoing hook handler, sync only. Takes the new text only (no format, silent, schedule, reply_to, topic). text up to 4096 bytes. The handler must return 0, otherwise the edit is discarded. See Hooks
drop_outgoing (reason: &str) -> Result<(), Error> messages.drop_outgoing, messages.transform_outgoing. Only inside message.outgoing. An empty reason is not sent; otherwise up to 200 bytes, shown to the user as a toast
chat_info (chat: i64) -> Result<Value, Error> chat.info, chat.info. Returns the chat object as a Map (Chats and people)

Common rules for the chat argument: it is the numeric peer id; the service chat 777000 and chats outside the granted scope are Denied, unknown chats, secret chats and Saved Messages are NotFound. See Operations: messages.

Function Arguments and result Operation, permission, important limits
toast (text: &str) -> Result<(), Error> ui.toast, ui.toast. Only text, up to 200 bytes: no title, style, duration or button. At most one toast per 2 seconds (Limit). The host prefixes the text with the plugin id
notify (title: &str, text: &str) -> Result<(), Error> notify.show, notifications. title comes first (the Python function takes the text first). text 1 to 256 bytes, title up to 64 bytes. The wrapper always sends title; to leave it out (the host then uses the app name) use call. At most one per 5 seconds

The host allows only public addresses (and loopback with net.local and the manifest ports). Details, redirects and TLS rules: Network operations. WebSocket (net.ws.*, permission net.ws) has no wrapper: use call.

Function Arguments and result Operation, permission, important limits
fetch (url: &str, options: FetchOptions) -> Result<Response, Error> net.fetch, net.https (public) or net.local. https:// only, URL up to 2048 bytes of printable ASCII (percent-encode the rest); up to 5 redirects; response body up to 100 MiB; the timeout covers the whole request. HTTP 4xx/5xx is Ok. HEAD on a reply that has Content-Length ends with an error (Failed, “the connection closed early”); use GET
tcp_connect (host: &str, port: i64, tls: bool) -> Result<i64, Error> net.tcp.connect, net.tcp (net.local for loopback). Returns the socket id. host up to 255 bytes, port 1 to 65535. There is no timeout argument: the host uses 10000 ms. At most 16 open sockets per kind. tls = true verifies the certificate against host
udp_connect (host: &str, port: i64) -> Result<i64, Error> net.udp.connect, net.udp. Returns the socket id; the socket is bound to this one peer. No timeout argument
socket_send (id: i64, data: Vec<u8>) -> Result<(), Error> net.socket.send, the permission of the socket kind. data non-empty, up to 1 MiB for TCP and 65507 bytes for UDP (Limit); UDP also at most 200 datagrams per second. The number of bytes sent is discarded. No timeout argument
socket_recv (id: i64, max: i64, timeout_ms: i64) -> Result<(Vec<u8>, bool), Error> net.socket.recv. Returns (data, closed): closed is true once the peer closed and nothing is left; data is empty if the timeout passed. max is clamped to 1 to 1048576, timeout_ms to 0 to 30000. TCP returns as soon as at least one byte is there; UDP returns one datagram, cut to max
socket_close (id: i64) -> Result<(), Error> net.socket.close. The id becomes invalid; sockets are also closed when the plugin stops

An unknown or closed socket id is NotFound; an id of another kind (a WebSocket) is Invalid.

Function Arguments and result Operation, permission, important limits
plugin_send (to: &str, data: impl Into<Value>) -> Result<(), Error> plugins.send, plugins.message (on both plugins). to is a plugin id, 1 to 64 bytes, not your own. data at most 12 levels deep and 64 KiB encoded (Limit). Delivery is asynchronous: the target gets {"type": "plugin.message", "from": ..., "data": ...}. NotFound if there is no such plugin, Denied if it does not accept messages from you
translate_reply (ticket: i64, items: &[&str]) -> Result<(), Error> translate.reply, translate.provide; the plugin declares contributes.translator. ticket comes from the translate.request event. The number of items must equal the number requested (the request has 1 to 20); each item up to 32 KiB, 96 KiB in total. The format is always plain (the wrapper cannot send format). The ticket expires after 20 s (NotFound). An empty item counts as a failed item
translate_error (ticket: i64, error: &str) -> Result<(), Error> translate.reply with error, translate.provide. error must be non-empty, up to 200 bytes; it fails the whole translation
ai_reply (ticket: i64, text: &str) -> Result<(), Error> ai.reply, ai.provide; the plugin declares contributes.aiProvider. ticket comes from the ai.request event. text up to 64 KiB, plain only. The ticket expires after 60 s
ai_error (ticket: i64, error: &str) -> Result<(), Error> ai.reply with error, ai.provide. error non-empty, up to 200 bytes

Event shapes (plugin.message, translate.request, ai.request, timer, call.result) are in Event payloads.

Everything else is reachable through call: messages.delete, messages.forward, messages.send_poll, messages.search, chats.*, users.*, channels.*, stickers.*, bots.*, ui.dialog.*, ui.window.*, badges.*, clipboard.*, files.*, fs.*, media.*, image.*, net.ws.*, time.now, timers.list, storage.usage, meta.info, account.info, settings.read, ghost.*, plugins.peers and the rest of the operation list.

The counter plugin from the Wasm quick start uses log, storage_get and storage_set and returns the counter as its status. That is allowed because the host ignores the status of ordinary events, but a real plugin should return 0 on success. Source: Telegram/plugins_sdk/rust/examples/counter.