C SDK
The whole SDK is one file, Telegram/plugins_sdk/c/freshgram.h. It has no dependencies and no libc: it contains a CBOR reader and writer, a bump allocator, the exports the host needs (fg_alloc, fg_free, fg_init, fg_on_event), the import of the host call, and ready-made wrappers for the most common operations. The wire format and limits are in Wasm ABI; the operations are in Call protocol.
Build and include
Section titled “Build and include”clang --target=wasm32 -O2 -nostdlib -fno-builtin -Wl,--no-entry -o plugin.wasm plugin.c#include "freshgram.h" // in exactly ONE .c file
int fg_plugin_init(fg_value info) { (void)info; fg_log("ready"); return 0; }int fg_plugin_event(fg_value event) { (void)event; return 0; }Include the header in one translation unit only. It defines static state (the heap) and the exported functions, so a second inclusion in another .c file would give a second heap and duplicate exports. Add -DFG_HEAP_BYTES=... to the command line, or #define it before the #include, to change the heap size.
Then declare the plugin in manifest.json with "runtime": { "kind": "wasm", "module": "plugin.wasm", "language": "c" }; see Wasm quickstart.
Memory: the arena
Section titled “Memory: the arena”All memory comes from one static array fg_heap_ of FG_HEAP_BYTES bytes (1 MiB by default, 8-byte alignment). It is part of the module’s linear memory, so it counts against memoryMb of the manifest.
The allocator is a bump pointer. Nothing is released one block at a time:
| Rule | Detail |
|---|---|
| Allocation | fg_arena(size) moves the pointer forward; it returns 0 when the rest of the heap is too small |
| Free | The exported fg_free ignores its arguments and resets the whole arena. The host calls it after fg_init / fg_on_event returns normally, on the input block. So memory lives until the end of the current event |
| The event itself | The host puts the event (or the info map) into the arena with fg_alloc before calling you. It is the first block, so it is part of the budget |
| Each request | fg_begin / fg_begin_async call fg_writer_init(w, 4096): 4096 bytes are reserved for every request, however short, and are never given back during the event |
| Growth | fg_grow doubles the capacity (4096, 8192, …), allocates a new block and copies; the old block is abandoned |
| Answers | The host allocates every answer in the same arena (through fg_alloc) and never frees it |
Consequences:
- About 256 calls per event exhaust a 1 MiB heap (256 x 4096 bytes), even before answers are counted. The host also allows only 256 calls per event, so in practice the heap and the call budget run out together. If you need more room, raise
FG_HEAP_BYTES. - A single request cannot be larger than about 512 KiB:
fg_growrefuses to double once the capacity is aboveFG_HEAP_BYTES / 2, and the abandoned smaller blocks use the other half. This matters forfg_send_media,fg_edit_media,fg_socket_send. RaiseFG_HEAP_BYTESfor bigger payloads. - An answer plus a request plus the event must all fit. When
fg_allocreturns0for an input block the event is dropped as a failure; when it fails for an answer the host call returns-4and the wrapper returnsFG_TRANSPORT(see Wasm ABI). - If the 4096-byte reservation fails, the writer is not marked failed:
fg_writer_initleavesdata = 0,capacity = 0,failed = 0. The first write then goes tofg_grow, which starts at 64 bytes and may still succeed if a small piece of heap is left. Only whenfg_growfails does the writer becomefailed, and thenfg_sendreturnsFG_TRANSPORTwithout calling the host. - Do not call
fg_freeyourself inside a handler. It resets the arena, so theeventyou are handling, everyfg_valuereturned byfg_findorfg_send, and every text pointer fromfg_as_textbecome garbage as soon as the next allocation overwrites them. Copy what you need into your own static buffers first. - Static variables survive between events, but the instance can be stopped and restarted (unload, crash, update). Keep durable state in
storage.*.
memcpy and memset are provided as weak symbols (simple byte loops), because -nostdlib -fno-builtin leaves no libc. Define your own, or link a better implementation, and yours wins.
Exports and the host call
Section titled “Exports and the host call”| Name | Meaning |
|---|---|
FG_EXPORT(name) |
__attribute__((export_name(name))), to export a function from the module |
FG_HOST_CALL |
__attribute__((import_module("freshgram"), import_name("call"))) |
fg_host_call(request, length, &response, &response_length) |
The only import. request is CBOR, the answer is written into a block the host allocated with fg_alloc |
FG_HOST_CALL int fg_host_call( const void *request, fg_u32 length, void **response, fg_u32 *response_length);It returns 0 when an answer was written (the answer may still be {"ok": false, ...}), or a negative number when the call did not happen: -1 bad arguments, -2 the request is not valid CBOR, -3 the answer could not be encoded, -4 no memory for the answer, -5 the per-event call budget is used up. See Wasm ABI.
fg_send does not keep these numbers: any nonzero result (and a zero result with a null response) becomes FG_TRANSPORT. If you must tell -5 from -2, call fg_host_call yourself.
The header exports fg_alloc, fg_free, fg_init, fg_on_event for the host. You never call them.
The two callbacks
Section titled “The two callbacks”You define:
int fg_plugin_init(fg_value info);int fg_plugin_event(fg_value event);fg_init calls fg_plugin_init once when the plugin starts; fg_on_event calls fg_plugin_event for every event, one at a time (never re-entered). The return value is the event status. 0 means success; a nonzero value is not a crash, see Return values. For fg_plugin_init a nonzero value writes failure: the start handler returned N to the plugin log; for ordinary events the host ignores the status; for message hooks (message.outgoing, message.incoming) a nonzero status discards your edit (and a blocking hook refuses the message). So return 0 from a hook handler after a successful edit.
Init info
Section titled “Init info”info is a map (not the result of meta.info):
| Key | Type | Meaning |
|---|---|---|
account |
text | Id of the account this instance runs for, a decimal string such as "3" |
plugin |
text | Plugin id from the manifest |
version |
text | Installed plugin version |
api |
int | Host API level |
Events
Section titled “Events”Every event is a map with a type text key. Which events you get depends on the manifest and the permissions: message.new, command, menu.click, timer, toast.click, plugin.message, translate.request, ai.request, message.outgoing, call.result and others. The keys of each are in Event payloads; the list and permissions are in Events.
C has no string compare in libc, so write a small helper:
static int fg_is(fg_value v, const char *wanted) { const char *t = 0; fg_u32 n = 0; if (fg_as_text(v, &t, &n) != 0) return 0; for (fg_u32 i = 0; i < n; ++i) if (!wanted[i] || t[i] != wanted[i]) return 0; return wanted[n] == 0;}
int fg_plugin_event(fg_value event) { if (fg_is(fg_find(event, "type"), "command")) { /* ... */ } return 0;}Reading CBOR
Section titled “Reading CBOR”typedef struct { const fg_u8 *p; const fg_u8 *end; } fg_value;An fg_value is a pointer to the start of one CBOR item plus the end of the buffer it lives in (so it is not trimmed to the item’s own length). It points into the event or the answer; it owns nothing.
| Function | Meaning |
|---|---|
fg_none() |
An empty value (p = end = 0) |
fg_valid(v) |
Nonzero if v points at something (p set and p < end). A found key whose value is null is still valid; test it with the fg_as_* functions |
fg_find(map, key) |
Look up a text key in a map, return its value, or fg_none() if map is not a map, the key is absent, or the data is malformed. Keys are compared byte by byte; the first match wins |
fg_as_int(v, &out) |
Integer (CBOR major 0 or 1) into fg_i64. Floats are not accepted. Values above INT64_MAX wrap |
fg_as_bool(v, &out) |
true / false into int (1 / 0) |
fg_as_text(v, &ptr, &len) |
UTF-8 text: pointer into the buffer and length in bytes |
All three fg_as_* return 0 on success and -1 on failure (wrong type, fg_none(), malformed or truncated data), and then leave their output untouched, so initialize it:
fg_i64 chat = 0;if (fg_as_int(fg_find(event, "chat"), &chat) != 0) { /* key absent or not an int */ }Notes:
- A missing optional key is simply absent in the map, so
fg_findgivesfg_none()and thefg_as_*call fails with-1. Optional keys are notnull. - The text pointer is not NUL-terminated and points into the arena. It stays valid until
fg_freeruns (the end of the event). Do not compare it withstrcmp; use the length. - There is no
fg_as_bytes, no float reader and no array iterator.fg_as_textrejects byte strings (CBOR major 2), sonet.fetchbodies and socket data cannot be read with it. The internal helpersfg_headandfg_skipare available in the header and are enough to write these:
// bytes: CBOR major 2static int fg_as_bytes(fg_value v, const fg_u8 **data, fg_u32 *length) { const fg_u8 *p = v.p; int major = 0; fg_u64 n = 0; if (!fg_valid(v) || fg_head(&p, v.end, &major, &n) != 0 || major != 2 || n > (fg_u64)(v.end - p)) return -1; *data = p; *length = (fg_u32)n; return 0;}
// array: call for each element; returns the element count or -1static int fg_each(fg_value array, fg_value *items, int max) { const fg_u8 *p = array.p; int major = 0; fg_u64 n = 0; int got = 0; if (!fg_valid(array) || fg_head(&p, array.end, &major, &n) != 0 || major != 4) return -1; for (fg_u64 i = 0; i < n; ++i) { if (got < max) { fg_value it = { p, array.end }; items[got++] = it; } if (fg_skip(&p, array.end, 0) != 0) return -1; } return got;}Writing requests
Section titled “Writing requests”typedef struct { fg_u8 *data; fg_u32 size; fg_u32 capacity; int failed; } fg_writer;| Function | Writes |
|---|---|
fg_w_map(w, count) |
Map header; count is the number of key-value pairs |
fg_w_array(w, count) |
Array header; count is the number of elements |
fg_w_text(w, text) |
NUL-terminated UTF-8 text. Must be valid UTF-8, the host rejects anything else |
fg_w_text_n(w, text, length) |
Text of length bytes (no NUL needed) |
fg_w_bytes(w, data, length) |
Byte string |
fg_w_int(w, value) |
Integer (fg_i64, negative allowed) |
fg_w_bool(w, value) |
true if value is nonzero |
fg_w_null(w) |
null |
The header writes only the container head: you must write exactly count entries after fg_w_map / fg_w_array, and exactly arguments pairs after fg_begin. A wrong count gives invalid CBOR: the host answers -2 and you get FG_TRANSPORT, or the request is read with a shifted structure and fails with invalid. Keys and values are written one after the other: fg_w_text(&w, "chat"); fg_w_int(&w, chat); is one pair. Never pass a null const char * to fg_w_text (it would crash the module).
The writer has no error return; once it runs out of memory it sets failed and ignores further writes.
A synchronous call
Section titled “A synchronous call”fg_writer w;fg_begin(&w, "messages.send", 2); // operation, number of argument pairsfg_w_text(&w, "chat"); fg_w_int(&w, chat);fg_w_text(&w, "text"); fg_w_text(&w, "hi");fg_value result = fg_none();int code = fg_send(&w, &result); // FG_OK or an error codefg_begin(w, op, n) reserves the 4096 bytes and writes {"op": op}; if n is nonzero it also writes "a" and a map header of n pairs. fg_send returns FG_OK and stores the v field of the answer in *result (pass 0 to ignore it). result is fg_none() if the answer had no v, and a valid null value if the answer had v: null. The result points into the arena; read it before the event ends.
An asynchronous call
Section titled “An asynchronous call”fg_writer w;fg_begin_async(&w, "net.fetch", 1); // adds "async": truefg_w_text(&w, "url"); fg_w_text(&w, "https://example.org/");fg_i64 ticket = 0;int code = fg_send_async(&w, &ticket); // the call is queuedfg_send_async returns FG_OK and writes the ticket (FG_FAILED if the answer has no integer ticket). The real result arrives later as an event:
{ "type": "call.result", "ticket": N, "ok": true, "v": ... }{ "type": "call.result", "ticket": N, "ok": false, "e": "denied", "m": "..." }Match ticket against the one you stored (keep it in a static variable). Errors found at submit time (limit with 32 calls pending, invalid for an operation that cannot be awaited) come back from fg_send_async itself; errors at run time come in call.result. Some operations must be async (time.sleep, files.*, fs.*), others cannot be (plugin.unload, the *_outgoing and *_incoming hook calls). See Async calls and Call protocol.
Error codes
Section titled “Error codes”fg_send, fg_send_async and every wrapper return one of:
| Constant | Value | Meaning |
|---|---|---|
FG_OK |
0 | Success |
FG_DENIED |
1 | Missing permission, or the chat is out of the granted scope, or the action is not allowed |
FG_INVALID |
2 | A bad, missing or out-of-range argument; also an operation called in the wrong place (a hook call outside its hook) |
FG_NOT_FOUND |
3 | Chat, message, socket or ticket not known |
FG_QUOTA |
4 | Storage quota or key count exceeded |
FG_LIMIT |
5 | Rate limit, size limit, or too many pending calls or sockets |
FG_UNAVAILABLE |
6 | The app did not answer, or the operation is not possible now (for example inside a message hook) |
FG_FAILED |
7 | The server or the network refused or failed; also an unparsable answer |
FG_TRANSPORT |
8 | The call did not happen: the writer failed (out of arena), or fg_host_call returned anything nonzero, or the response was null |
fg_error_code(response) maps the answer’s e text: denied, invalid, not_found, quota, limit, unavailable become 1 to 6. Everything else becomes FG_FAILED, including failed itself, cancelled, and the network sub-codes (blocked, dns, connect, tls, timeout, closed, write, response, redirect). The message m is dropped too. If you need the detail, send the request by hand with fg_host_call and read e / m yourself. See Errors and limits.
Ready-made wrappers
Section titled “Ready-made wrappers”Each wrapper builds one request and calls fg_send; the return value is an error code above. Strings are NUL-terminated UTF-8 and must not be NULL unless the table says so. Every call spends one of the 256 calls of the event and 4096 bytes of the arena. Limits come from messaging, system, network and social operations; permissions are described in Permissions.
A wrapper that takes fg_i64 *id or fg_value *result only writes it on FG_OK.
Service and storage
Section titled “Service and storage”| Function | Operation | Permission | Notes |
|---|---|---|---|
fg_log(text) |
log |
none | text up to 4096 bytes, only the first 500 are kept in the log. Works inside hooks |
fg_unload() |
plugin.unload |
none | The instance stops after the current event; the app starts it again on the next event. Sync only |
fg_storage_get_int(key, &out) |
storage.get |
storage.private |
Key 1 to 128 bytes, no control characters. A missing key returns FG_OK and leaves *out untouched; so does a stored value that is not an integer. Set *out to a default first |
fg_storage_set_int(key, value) |
storage.set |
storage.private |
Stores an integer. FG_QUOTA when the quota is used up or 4096 keys exist |
fg_sleep_async(ms, &ticket) |
time.sleep |
none | ms from 0 to 60000, otherwise FG_INVALID. Async only: the call.result with v: null arrives after the delay |
fg_timer_set(id, delay_ms, repeat) |
timers.set |
none | id 1 to 64 bytes; delay_ms from 1000 to 604800000 (7 days); repeat nonzero for periodic. At most 32 timers. Fires the timer event {id}, even after an unload |
fg_timer_clear(id) |
timers.clear |
none | Clears a timer |
fg_i64 n = 0; // default for a missing keyif (fg_storage_get_int("count", &n) == FG_OK) fg_storage_set_int("count", n + 1);Sending and editing messages
Section titled “Sending and editing messages”Text is up to 4096 bytes of UTF-8 (bytes, not characters). The send bucket (fg_send_text*, fg_send_media, fg_edit_media) allows a burst of 5, refilled at one per second; the action bucket (fg_edit_text, fg_react) a burst of 20, refilled at five per second. A chat outside the granted scope gives FG_DENIED; a secret chat or Saved Messages gives FG_NOT_FOUND.
| Function | Operation | Permission | Notes |
|---|---|---|---|
fg_send_text(chat, text) |
messages.send |
messages.send |
Plain text only. No reply, topic, silent or format; for those use fg_begin |
fg_send_text_ids(chat, text, &id, &local_id) |
messages.send |
messages.send; messages.ids for the server id |
Like fg_send_text, plus the ids (see below). With messages.ids the call waits for the server (up to 90 s) |
fg_send_media(chat, data, length, name, caption, kind, &id, &local_id) |
messages.send_media |
messages.send |
name 1 to 128 bytes, no / or \. caption may be NULL (sent as empty, up to 4096 bytes). kind is "auto", "file" or "voice", NULL means "auto". "voice" always returns FG_INVALID: the wrapper has no duration parameter and the host requires one for voice. The file is limited to 100 MiB by the host, but to roughly 512 KiB by the arena. Media sent by a plugin skips the message.outgoing hooks |
fg_edit_text(chat, message, text) |
messages.edit |
messages.edit |
Only your own messages, inside Telegram’s edit window. Plain text, 1 to 4096 bytes (cannot be cleared). message is a server id or a local_id |
fg_edit_media(chat, message, data, length, name, caption, kind) |
messages.edit_media |
messages.edit |
Replaces the file. kind is "auto" or "file" (NULL is "auto"; "voice" is FG_INVALID). The new caption replaces the old one, and NULL clears it. Counts in the send bucket. Waits up to 5 minutes |
fg_react(chat, message, emoji) |
messages.react |
messages.react |
Sets your reaction; emoji is up to 64 bytes and must not be NULL. The wrapper cannot remove a reaction (it has no “remove” mode and no custom emoji). To remove or to use a custom emoji, send messages.react by hand without emoji (see below). Paid reactions cannot be set |
fg_sent_ids(result, &id, &local_id) is the helper behind the _ids functions and can be used with any fg_send result of messages.send / messages.send_media. Either pointer may be NULL. It always writes 0 when a key is missing or null and returns FG_OK. In fg_send_text_ids and fg_send_media the out-parameters are written only when the call succeeded. local_id is a large negative number valid until the app restarts; id is the server id, present only with messages.ids; it is 0 while unknown (and for scheduled sends, and when the server did not answer in time). A local_id is accepted by fg_edit_text, fg_edit_media and fg_react.
fg_i64 id = 0, local = 0;if (fg_send_text_ids(chat, "hello", &id, &local) == FG_OK) fg_edit_text(chat, id ? id : local, "hello, edited");Changing the message being sent (hooks)
Section titled “Changing the message being sent (hooks)”These work only inside a message.outgoing handler (a hook declared in contributes.outgoing), need the permission messages.transform_outgoing, and are never async. Called anywhere else they return FG_INVALID; they also return FG_INVALID when no change is requested. Return 0 from fg_plugin_event after a successful edit or the edit is discarded. See Message hooks. Several calls in one handler merge; the last value of a field wins.
| Function | Operation | Notes |
|---|---|---|
fg_set_outgoing(text) |
messages.set_outgoing |
New plain text, 1 to 4096 bytes (an empty text is allowed only to clear the caption of a media send). Old entities are dropped |
fg_set_outgoing_markdown(text) |
messages.set_outgoing |
Same, with format: "markdown" (**bold** and so on). Bad markup is FG_INVALID |
fg_set_outgoing_html(text) |
messages.set_outgoing |
Same, with format: "html" (<b>, <i>, <a href>, <blockquote expandable>, …). See Text formatting |
fg_set_outgoing_silent(silent) |
messages.set_outgoing |
Sends only silent; nonzero means send without sound. The text is left as it is |
fg_drop_outgoing(reason) |
messages.drop_outgoing |
Cancels the send. reason may be NULL or empty (no toast); otherwise up to 200 bytes, shown to the user as a toast |
int fg_plugin_event(fg_value event) { if (fg_is(fg_find(event, "type"), "message.outgoing")) return fg_set_outgoing_markdown("**hello**") == FG_OK ? 0 : -1; return 0;}There are no wrappers for message.incoming (messages.set_incoming, messages.hide_incoming); call them with fg_begin.
Reading chats and history
Section titled “Reading chats and history”| Function | Operation | Permission | Notes |
|---|---|---|---|
fg_chat_info(chat, &result) |
chat.info |
chat.info |
result is the chat map: id, title, type, unread, pinned, archived; see Data shapes |
fg_history(chat, limit, before, &result) |
messages.history |
messages.history |
Newest first. limit is clamped to 1 to 100. before is a message id (only older messages are returned); 0 starts from the newest. No topic or sender filter. result is an array of message objects (walk it with fg_each above); entries loaded from the server have fewer keys |
The result of a big history can be large and shares the arena with everything else. Ask for a small limit.
Network
Section titled “Network”Network traffic is governed by the user’s policy; see Network operations.
| Function | Operation | Permission | Notes |
|---|---|---|---|
fg_fetch(url, timeout, &result) |
net.fetch |
net.https (net.local for loopback) |
Only url and timeout: always a GET, no headers, no body. timeout is in ms, clamped to 100 to 300000. The result is {status, headers, header_list, body}; body is a byte string, read it with a bytes reader (not fg_as_text). HTTP 4xx/5xx is a normal result. A response larger than the free arena fails with FG_TRANSPORT. For other methods use fg_begin(&w, "net.fetch", ...) |
fg_tcp_connect(host, port, tls, &id) |
net.tcp.connect |
net.tcp |
tls nonzero for TLS (certificate checked against host). Connect timeout is the default 10 s (the wrapper cannot set it). *id is left untouched if the answer has no id |
fg_udp_connect(host, port, &id) |
net.udp.connect |
net.udp |
A UDP socket is bound to one peer. No TLS |
fg_socket_send(id, data, length) |
net.socket.send |
as for the socket | data must not be empty; at most 1 MiB for TCP, 65507 bytes for UDP, and in practice about 512 KiB because of the arena. UDP: 200 datagrams per second |
fg_socket_recv(id, max, timeout, &result) |
net.socket.recv |
as for the socket | max clamped to 1 to 1048576, timeout to 0 to 30000 ms. result is {data: bytes, closed: bool}. A timeout is not an error: data is empty. UDP returns one datagram |
fg_socket_close(id) |
net.socket.close |
as for the socket | Closes it |
At most 16 open sockets per kind. There are no WebSocket wrappers; use net.ws.* with fg_begin.
fg_value r = fg_none();if (fg_fetch("https://example.org/", 5000, &r) == FG_OK) { fg_i64 status = 0; fg_as_int(fg_find(r, "status"), &status);}User interface and other plugins
Section titled “User interface and other plugins”| Function | Operation | Permission | Notes |
|---|---|---|---|
fg_toast(text) |
ui.toast |
ui.toast |
text up to 200 bytes. One toast per 2 s (FG_LIMIT) |
fg_toast_action(text, id, label, undo, seconds) |
ui.toast |
ui.toast |
A toast with a button. id 1 to 32 bytes; arrives as toast.click {id}; toast.close {id, reason} follows. seconds is required and must be 1 to 8. label (up to 40 bytes) may be NULL or empty only with undo nonzero, which shows “Undo”; label NULL without undo gives FG_INVALID |
fg_notify(title, text) |
notify.show |
notifications |
text 1 to 256 bytes, title up to 64 bytes; both always sent, so pass real strings. One per 5 s |
fg_plugin_send_text(to, text) |
plugins.send |
plugins.message (also on the receiver) |
to is another plugin id (1 to 64 bytes, not yours); the receiver gets plugin.message {from, data} with data equal to the text. Up to 64 KiB. FG_NOT_FOUND for an unknown plugin |
Translator and AI provider
Section titled “Translator and AI provider”For a plugin that declares contributes.translator or contributes.aiProvider. The request comes as an event (translate.request, ai.request); answer it with the event’s ticket. See Event payloads for the request.
| Function | Operation | Permission | Notes |
|---|---|---|---|
fg_translate_reply(ticket, items, count, markdown) |
translate.reply |
translate.provide |
items is an array of count strings, and count must equal the number of requested items (at most 20; each item up to 32 KiB; 96 KiB together). markdown nonzero parses every item as Markdown; otherwise plain. FG_INVALID if the count differs (you may answer again); FG_NOT_FOUND if the ticket is unknown or expired (20 s) |
fg_translate_error(ticket, error) |
translate.reply |
translate.provide |
Fails the translation; error up to 200 bytes, non-empty |
fg_ai_reply(ticket, text, markdown) |
ai.reply |
ai.provide |
text up to 64 KiB, non-empty. markdown nonzero for Markdown, otherwise plain. HTML is not offered by the wrapper. The ticket expires after 60 s (FG_NOT_FOUND) |
fg_ai_error(ticket, error) |
ai.reply |
ai.provide |
Fails the request; error up to 200 bytes, non-empty |
int fg_plugin_event(fg_value event) { if (fg_is(fg_find(event, "type"), "ai.request")) { fg_i64 ticket = 0; const char *t = 0; fg_u32 n = 0; fg_as_int(fg_find(event, "ticket"), &ticket); if (fg_as_text(fg_find(event, "text"), &t, &n) == 0) fg_ai_reply(ticket, "rewritten text", 0); } return 0;}Operations without a wrapper
Section titled “Operations without a wrapper”Anything in the operation list can be called with fg_begin and fg_send. Take the op name and the argument keys from the page of that operation, write exactly the right number of pairs, and read the result from v:
// messages.react with a custom emoji, as an examplefg_writer w;fg_begin(&w, "messages.react", 3);fg_w_text(&w, "chat"); fg_w_int(&w, chat);fg_w_text(&w, "id"); fg_w_int(&w, message);fg_w_text(&w, "custom_emoji"); fg_w_int(&w, 5368324170671202286LL);int code = fg_send(&w, 0);
// an optional list: message ids for messages.deletefg_begin(&w, "messages.delete", 3);fg_w_text(&w, "chat"); fg_w_int(&w, chat);fg_w_text(&w, "ids"); fg_w_array(&w, 2); fg_w_int(&w, 11); fg_w_int(&w, 12);fg_w_text(&w, "everyone"); fg_w_bool(&w, 1);code = fg_send(&w, 0);Use fg_begin_async plus fg_send_async for the operations that must or may be awaited. Operations that need no arguments use fg_begin(&w, "time.now", 0). Each call is limited like a wrapper: 4096 arena bytes, one of 256 calls per event.
The example counter.c
Section titled “The example counter.c”Telegram/plugins_sdk/c/examples/counter.c counts events in plugin storage and shows the pattern of reading, checking the code, and writing:
int fg_plugin_event(fg_value event) { fg_i64 chat = 0; fg_as_int(fg_find(event, "chat"), &chat); fg_i64 count = 0; int read = fg_storage_get_int("count", &count); if (read != FG_OK) { fg_log("cannot read the counter"); return 100 + read; } count += 1; /* ... fg_storage_set_int("count", count) ... */ return (int)count;}Be aware of what it returns: the counter itself as the event status. That works only because the host accepts any int as a status and ignores it for ordinary events. It is not a convention to copy. A nonzero status from fg_plugin_init is logged as a start failure, and a nonzero status from a message hook discards the plugin’s edit (or refuses the message in a blocking hook). Return 0 for success and a negative number for your own errors. The example also relies on fg_storage_get_int returning FG_OK with count still at its initial 0 when the key does not exist yet.
