Telegram (telegram)
Telegram is a bot platform, not an OAuth provider. Rustiz runs one bot for
the whole deployment, identified by TELEGRAM_TOKEN; each connected channel is
simply a chat id that the bot has already joined. The connect flow asks a human
to send /connect <word> to the bot in the target group or channel, then polls
Telegram’s getUpdates endpoint until the message appears.
- Rust source:
crates/rustiz-providers/src/telegram.rs - Connect flow:
crates/rustiz-server/src/connect.rs - Upstream port:
telegram.provider.ts - Editor: HTML
- Connection mode:
telegramBot
Overview
| Property | Value |
|---|---|
| Identifier | telegram |
| Display name | Telegram |
| Connection mode | telegramBot (human sends a bot command; no browser OAuth) |
| Editor | html |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; extra posts are replies with reply_to_message_id |
| Pending / async publishing | No. supports_pending = false; one Bot API call (or media group sequence) per post |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 3 — conservative against Bot API limits |
Character limit (max_length) | 4096 characters for text; media captions are limited to 1024 |
| API host | https://api.telegram.org (override TELEGRAM_BASE_URL) |
| OAuth scopes | None |
| Redirect URI | None |
The provider sets info.is_web3 = true because the upstream Postiz class does.
That flag is not used for Telegram’s connect path: connect::start() has an
explicit "telegram" branch before the is_web3 rejection, and
connect::describe() returns type: "telegramBot". It does not mean Telegram
is a crypto/web3 platform.
Server Environment Variables
required_env() returns exactly:
#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
&["TELEGRAM_TOKEN"]
}
}
required_env_alternatives() is not overridden.
| Variable | Required | Default | Purpose |
|---|---|---|---|
TELEGRAM_TOKEN | Yes | — | Bot API token from BotFather, format <bot_id>:<secret> |
TELEGRAM_BOT_NAME | No | unset | Bot username without @, used only in the connect instructions (@name). Not used for API calls. |
TELEGRAM_BASE_URL | No | https://api.telegram.org | Origin for all Bot API calls. Trailing slash stripped. Useful for a local Bot API server. |
STORAGE_PROVIDER | No | local | When local, media URLs beginning with FRONTEND_URL are converted to local upload paths before sending. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Used to recognise local media URLs and in the generic OAuth state machinery (not for Telegram’s redirect). |
JWT_SECRET | Yes in practice | empty | Encrypts the pending connect state row. |
TELEGRAM_BOT_TOKEN is not read; the exact name is TELEGRAM_TOKEN.
rustiz providers --status checks only that TELEGRAM_TOKEN is non-empty. It
cannot tell whether the token is valid, whether a webhook is configured, or
whether another process is polling the same bot.
TELEGRAM_BOT_NAME only changes the text of the connect message. If it is
unset, the message says “the Telegram bot”; the actual /connect <word> flow
still works. The username in the message has no @ in the environment value;
the code adds it.
Step-by-Step Developer App Setup
1. Create the bot with BotFather
- Open Telegram and start a chat with @BotFather.
- Send
/newbot. - Choose a display name and a username ending in
bot(e.g.MyRustizBot/MyRustizBot_bot). - BotFather returns an API token such as
123456789:AAH…. This isTELEGRAM_TOKEN. Treat it like a password. - Optionally send
/setdescription,/setabouttext,/setuserpicand/setcommandsto make the bot presentable.
There is no developer portal, no app review and no OAuth client.
2. Decide the bot’s privacy mode
For groups, the bot must see the /connect <word> message. By default
BotFather enables privacy mode, which limits which group messages a bot
receives. Rustiz matches the update text exactly:
/connect <word>
It does not match /connect@MyRustizBot <word>. If privacy mode is on in a
group, the plain command may not be delivered, and the connection times out.
Disable privacy mode:
- Chat with @BotFather.
- Send
/mybots, select the bot. - Bot Settings → Group Privacy → Turn off (“Privacy mode is disabled”).
- Remove and re-add the bot to the group if it was already a member, so the new setting takes effect.
For channels, the bot receives channel posts when it is a member; making it an administrator is strongly recommended so it can post and clean up the connect messages.
3. Add the bot to the target chat
- Group: add the bot as a member. To post media and delete its own messages, grant the relevant permissions or make it an administrator.
- Channel: add the bot as an administrator with at least:
- Post messages (
can_post_messages) - Edit messages (optional)
- Delete messages (
can_delete_messages) — used to remove the/connectcommand and the bot’s “Connection Successful” notice - media sending permissions
- Post messages (
Without administrator rights in a channel, the bot can often read updates but cannot publish. The connect flow itself can still succeed in a group without admin rights; Rustiz then tells the human to delete the connect messages manually.
4. Remove any webhook
getUpdates and setWebhook are mutually exclusive. If the bot was previously
used with a webhook, delete it:
curl -sS "https://api.telegram.org/bot${TELEGRAM_TOKEN}/deleteWebhook"
If a webhook remains, getUpdates fails with 409 Conflict: can't use getUpdates while webhook is active.
5. Set the environment and restart
TELEGRAM_TOKEN="123456789:AAH…"
TELEGRAM_BOT_NAME="MyRustizBot_bot"
Restart rustiz serve, rustiz worker and rustiz mcp, then:
rustiz providers --status
6. Only one poller per bot token
Telegram allows one getUpdates consumer per bot token. If the same token
is used by two Rustiz deployments, a staging and a production instance, or a
second bot process, each instance continuously evicts the other and the logs
fill with:
409 Conflict: terminated by other getUpdates request
Use a separate bot per deployment. This is the most common Telegram misconfiguration.
7. Sandbox / testing vs production
Telegram has no sandbox, no test-mode bot and no app review. A bot is free and immediately live.
- Create a private test group or channel and a dedicated bot for testing.
- Test
/connect, text posts, one-image posts, multi-image albums, video with a thumbnail, and comments. - There is no “production app” switch; the same bot token works everywhere. Restrict who can add the bot to chats via BotFather’s group settings if needed.
- Telegram’s Bot Developer Terms apply. Bots must not spam, must respect Telegram’s automation rules and must not scrape private data.
Connecting a Channel (CLI & MCP)
CLI
Start the two-step flow:
rustiz channels connect telegram
The JSON document contains:
{
"connected": false,
"pending": true,
"connectionId": "tg_…",
"word": "Ab3xY9zQ1p",
"command": "/connect Ab3xY9zQ1p",
"message": "Add @MyRustizBot to the Telegram group or channel (as an administrator), then send `/connect Ab3xY9zQ1p` in that chat. Call connectChannelTool or GET /public/v1/integrations/connect/{connectionId} again with this connectionId to check."
}
Give the command to a human, who sends it in the target chat. Then poll:
rustiz channels connect-status tg_…
The pending connection lives for one hour; each poll refreshes the TTL. A
successful poll returns connected: true, the channel name and id, and the
integration is stored immediately.
If you already know the numeric chat id, skip the word flow with a fields file:
cat > telegram.json <<'JSON'
{ "chatId": "-1001234567890" }
JSON
rustiz channels connect telegram --fields telegram.json
chatId may be a string or a JSON number. The snake_case alias chat_id is
also accepted by the server code. The bot must already be a member of that chat.
MCP connectChannelTool
Start without credentials:
{ "provider": "telegram" }
Response:
{
"connected": false,
"pending": true,
"connectionId": "tg_…",
"word": "Ab3xY9zQ1p",
"command": "/connect Ab3xY9zQ1p",
"message": "Add @MyRustizBot … then send `/connect Ab3xY9zQ1p` …"
}
Poll with:
{ "connectionId": "tg_…" }
Polling is safe to repeat. The server calls getUpdates with the previous
update offset, so already-seen updates are not returned again. When the command
is found, Rustiz calls getChat, checks whether the bot is an administrator,
optionally deletes the command/success messages, and saves the integration.
Direct connect:
{
"provider": "telegram",
"customFields": { "chatId": "-1001234567890" }
}
The server calls getChat with that id and saves the channel immediately. There
is no name or description field for Telegram; those are Moltbook-only.
What the server does internally
connect.rs stores the pending state in the oauth_states table:
{ "word": "Ab3xY9zQ1p", "lastChatId": null }
lastChatId is misnamed: it is the update offset (last update_id + 1),
not a chat id. On each poll, get_bot_id() calls:
getUpdates?offset=<lastChatId>&allowed_updates=["message","channel_post"]
and looks for an update whose message.text or channel_post.text is exactly
/connect <word>. If found, it returns { "chatId": -100… }; otherwise it
returns { "lastChatId": <last update_id + 1> } or {}.
If the bot is an administrator and has can_delete_messages, it deletes
the /connect message, sends Connection Successful. Message will be deleted in 10 seconds., and schedules deletion of that notice after 10 seconds. If it
is not an administrator, it sends a message asking the human to delete the
connect messages manually. Either way the connection succeeds once the chat id
is known.
Choosing the channel after connecting
is_between_steps = false; there is no channelSetupTool step and no channel
picker. The chat id is the channel. To connect another group or channel,
run the /connect flow again or pass a different chatId.
Telegram exposes no provider functions (functions() is empty), so
triggerTool has nothing to call for this provider.
What not to send
externalUrl— Telegram is not self-hosted. The Telegram connect branch ignores it; usecustomFields.chatIdor the/connectword instead.customFieldswith achatIdwhen the bot is not already in that chat —getChatfails. Add the bot first, or use the word flow.providerwhen polling — onlyconnectionIdis needed.
Post Settings & Media Validation
settings_schema()
{ "type": "object", "properties": {}, "additionalProperties": true }
There are no provider-specific settings. The destination is the chat id
stored as the integration’s access token, not a settings.channel field. The
engine adds "__type": "telegram" to the stored settings.
validate_settings()
Not overridden: every settings object is accepted. A post with
{"channel":"..."} still publishes to the integration’s chat; the extra field
is ignored.
check_validity()
Not overridden: no pre-flight media or length rules. The provider’s own
process_media() can fail before the network call if a local path is outside
the upload directory.
Media requirements
process_media() classifies each media item by extension:
| Extension | Kind | Bot API method |
|---|---|---|
png, jpg, jpeg, gif, webp, bmp, svg, tiff, tif, heic | photo | sendPhoto (single) / sendMediaGroup |
mp4, m4v, mov, webm, mkv, avi, mpeg, mpg | video | sendVideo / sendMediaGroup |
| anything else | document | sendDocument / sendMediaGroup |
The send path depends on the number of media items:
- 0 media:
sendMessagewithparse_mode=HTML. - 1 media:
sendPhoto,sendVideoorsendDocument, with the text as the caption andparse_mode=HTML. - 2+ media: split into chunks of 10 and sent with
sendMediaGroup. The caption is attached only to the first item of the first group; later groups have no caption, so the text is lost for items after the first 10.
Local vs remote files:
- With
STORAGE_PROVIDER=local, a media URL beginning withFRONTEND_URLis converted to a local path (the frontend prefix is stripped), validated against the upload directory, and uploaded as multipart. - Any other
http(s)URL is passed to Telegram as a URL string; Telegram downloads it. - A non-URL path must exist under the upload directory, or
process_mediafails with a local media error.
Telegram’s own limits:
| Limit | Value |
|---|---|
| Text message | 1–4096 characters after HTML entity parsing |
| Media caption | 0–1024 characters |
| Upload via multipart | 10 MB for photos, 50 MB for other files |
| Send by URL | 5 MB for photos, 20 MB for other content |
sendMediaGroup items | 2–10 per group |
getFile download | 20 MB |
| Messages per second in one chat | about 1; short bursts tolerated |
| Messages per minute in a group | 20 |
| Global bot broadcast | about 30 messages/second |
The provider does not pre-check any of these. Telegram returns a 400 with a
description; handle_errors() maps it to
Telegram rejected the post: <description>, or to
Telegram could not download your media file, please check the file and try again when the description is failed to get HTTP URL content.
HTML formatting
telegram_text() runs striptags(value, ["u", "strong", "p"]), then:
<strong>→<b>and</strong>→</b><p>…</p>→…\n
So the only tags preserved for Telegram are <u> and <b> (plus the temporary
<p> conversion). Italics (<i>, <em>), links (<a href>), code blocks,
lists and other HTML are stripped to their text. Telegram’s HTML parse mode
supports more, but this port does not forward it.
Plain text is not HTML-escaped. A literal < or & in a message can make
Telegram reject the post with a “can’t parse entities” error. Escape such text
or use the Normal editor for content with raw angle brackets.
Threads and comments
supports_comments = true.
comment()computesreply_to = last_comment_id.unwrap_or(post_id), parses it asi64, drops0, and sends the message withreply_to_message_id.- The first reply targets the original message; later replies target the previous comment’s message id, forming a reply chain.
- If the id is not numeric (for example a legacy row),
reply_toisNoneand the “comment” becomes a new top-level message.
Plugs
No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the
trait defaults and return ProviderError::Unsupported.
Implementation Notes & Gotchas
- The bot token is the whole deployment. All channels share one bot. There
is no per-channel bot token and no OAuth refresh. Rotating the token in
BotFather breaks every Telegram channel until
TELEGRAM_TOKENis updated and the processes restart. - The bot token leaks through the integration picture.
authenticate()resolves the chat photo withgetFileand stores the returned link as the integration’spicture:https://api.telegram.org/file/bot<TELEGRAM_TOKEN>/<file_path>. That URL is returned byintegrationList/channels listand is visible to any client that can read integrations. It contains the bot token. Treat the picture URL as a secret, or strip it before exposing integration metadata. The token also appears in the path of every Bot API request and may end up in error bodies or logs. /connectmatching is exact. The update text must be exactly/connect <word>./connect@MyRustizBot <word>, extra spaces, or a different case do not match. Disable privacy mode for groups or the bot may never see the plain command.- Only one
getUpdatesconsumer. A webhook or a second poller causes409 Conflict. CalldeleteWebhookbefore using Rustiz, and never share a bot token between staging and production. Updates are kept for 24 hours and are consumed by offset; a second consumer can steal the/connectmessage. - Pending state is one hour. The
tg_…connection id is stored inoauth_stateswithTELEGRAM_TTL_MS = 60 * 60 * 1000. Each poll refreshes the TTL. If the human waits too long, start again; the word is not reusable after expiry. lastChatIdis an update offset. Do not read it as a chat id. It is fed back togetUpdatesasoffset, which confirms all earlier updates.- Connect can succeed without admin rights.
get_bot_id()checksgetChatMemberand only uses admin status to decide whether to delete the connect messages. A group connection can succeed with a non-admin bot, but posting may still fail if the bot lacks send permissions. - Admin deletion needs
can_delete_messages. Beingadministratororcreatoris not enough; the code also requiresresult.can_delete_messages == true. Otherwise it leaves the messages and asks the human to remove them. getChatfailure is fatal.authenticate()callsgetChatwith the chat id. If the bot is not a member, the id is wrong, or the chat was deleted, the connect fails with the platform error. Add the bot first or use the word flow.- Chat id vs username.
authenticate()storesid = chat.usernamewhen the chat has a public username, otherwiseid = chat_id. The access token is always the numeric chat id. Posting uses the token, so both work. The storedinternal_idis used for the release URL and for diagnostics. - Private-chat release URLs are wrong.
release_link()useshttps://t.me/{internal_id}/{message_id}wheneverinternal_idis non-empty and not"undefined". For a private channel or group without a username,internal_idis the negative chat id (e.g.-1001234567890), producinghttps://t.me/-1001234567890/42, which is not a valid Telegram link. Thehttps://t.me/c/<id>/<message>fallback only runs for legacy rows whoseinternal_idis empty or"undefined". Public channels/groups with a username get correctt.me/<username>/<message>links. - Single media vs album. With one media item the text is a caption (max
1024). With multiple items the caption is on the first item of the first
group only. A 1500-character post with one image fails even though
max_length()allows 4096. - A trailing group of one item breaks albums.
send_message()chunks media by 10 and callssendMediaGroupfor every chunk. Telegram requires 2–10 items per group. 11 media items produce groups of 10 and 1; the second call fails. 21 items produce 10, 10, 1; the last call fails. There is no rebalancing (e.g. 6+5). - Mixed media types can break albums.
sendMediaGroupallows photos and videos together, but documents/audio can only be grouped with the same type. The provider does not group bykind; a mix of a PDF and a photo in one chunk is rejected. - Local media must be under the upload directory. For
STORAGE_PROVIDER=local, a URL starting withFRONTEND_URLis converted to a local path; anything that does not start withhttpis validated withlocal_media_path(..., "telegram-error"). A path outsideUPLOAD_DIRECTORYfails before Telegram is called. - Remote media is fetched by Telegram. A URL must be publicly reachable and
must have the right MIME type. Telegram’s URL download limit is 5 MB for
photos and 20 MB for other content, lower than the multipart limits. The
provider maps
failed to get HTTP URL contentto a friendly error but cannot fix an unreachable URL. - No media retry. Text and single-part calls use
Http::send, which retries429/5xx.multipart()usesexecutedirectly and does not retry; a transient429or5xxon an upload becomes aBadBody(often with “Unknown Error”). Re-run the post if the media is safe to duplicate. handle_errors()only handles HTTP 400. It parseserror_codeanddescription; any non-400 status is left to the shared HTTP layer. A401(invalid bot token) therefore becomes aRefreshTokenerror by the generic rule, butrefresh_token()returns an empty token, so the channel is flagged for reconnection even though the only fix is to correctTELEGRAM_TOKEN.- No real token refresh.
refresh_token()returnsexpires_in: Some(0)and an empty access token. Telegram bot tokens do not expire; the only lifecycle event is revocation/regeneration in BotFather. generate_auth_url()is a stub. It returns the random state as theurlfield. The normal/integrations/social/telegramOAuth start route is not the supported path; useconnectChannelTool,rustiz channels connect telegram, orPOST /public/v1/integrations/telegram/connect, all of which go throughconnect::start()’s special case.is_web3 = trueis cosmetic. It is an upstream class flag; the connect layer special-cases Telegram anddescribe()reportstelegramBot.PostResponse::done()reportscompleted, notposted. Both Discord and Slack setPostStatus::Posted; Telegram uses the helper that setsPostStatus::Completed. Both are terminal for the engine, but a consumer that distinguishes the two states will see Telegram ascompleted.- Rate limits. Telegram’s documented limits are about 30 messages/second
per bot globally, 20 messages/minute in a group, and 1 message/second
in a single chat (short bursts tolerated).
max_concurrent_job = 3is conservative but a queue that targets one channel can still hit the per-chat limit. Telegram returns429withparameters.retry_after; the shared HTTP layer retries text calls, but uploads do not. - No analytics, mentions or plugs. Telegram has no analytics methods, no
post analytics,
supports_mentions = false, and no plug hooks in this port. - Release URL for a public channel depends on the username at connect time.
If the channel later changes its username, the stored
internal_idis stale and the release link breaks; posting still works because it uses the numeric chat id. - One bot per deployment is a hard operational rule. The Bot API is not multi-tenant. A single Rustiz deployment can connect many chats with one bot, but two deployments cannot share the token. Use separate bots for staging and production.
References
- BotFather / bot creation: https://core.telegram.org/bots/features#botfather
- Bot API: https://core.telegram.org/bots/api
getUpdatesand webhooks (mutually exclusive): https://core.telegram.org/bots/api#getting-updates- Privacy mode and which updates a bot sees: https://core.telegram.org/bots/features#privacy-mode
- Sending files and size limits: https://core.telegram.org/bots/api#sending-files
sendMediaGroup(2–10 items): https://core.telegram.org/bots/api#sendmediagroupsendPhoto(caption 1024): https://core.telegram.org/bots/api#sendphotosendMessage(text 4096): https://core.telegram.org/bots/api#sendmessage- Bot FAQ / rate limits: https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this
- Postiz Telegram setup: https://docs.postiz.com/providers/telegram