Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

PropertyValue
Identifiertelegram
Display nameTelegram
Connection modetelegramBot (human sends a bot command; no browser OAuth)
Editorhtml
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra posts are replies with reply_to_message_id
Pending / async publishingNo. supports_pending = false; one Bot API call (or media group sequence) per post
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job3 — conservative against Bot API limits
Character limit (max_length)4096 characters for text; media captions are limited to 1024
API hosthttps://api.telegram.org (override TELEGRAM_BASE_URL)
OAuth scopesNone
Redirect URINone

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.

VariableRequiredDefaultPurpose
TELEGRAM_TOKENYes—Bot API token from BotFather, format <bot_id>:<secret>
TELEGRAM_BOT_NAMENounsetBot username without @, used only in the connect instructions (@name). Not used for API calls.
TELEGRAM_BASE_URLNohttps://api.telegram.orgOrigin for all Bot API calls. Trailing slash stripped. Useful for a local Bot API server.
STORAGE_PROVIDERNolocalWhen local, media URLs beginning with FRONTEND_URL are converted to local upload paths before sending.
FRONTEND_URLYes in practicehttp://localhost:5000Used to recognise local media URLs and in the generic OAuth state machinery (not for Telegram’s redirect).
JWT_SECRETYes in practiceemptyEncrypts 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

  1. Open Telegram and start a chat with @BotFather.
  2. Send /newbot.
  3. Choose a display name and a username ending in bot (e.g. MyRustizBot / MyRustizBot_bot).
  4. BotFather returns an API token such as 123456789:AAH…. This is TELEGRAM_TOKEN. Treat it like a password.
  5. Optionally send /setdescription, /setabouttext, /setuserpic and /setcommands to 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:

  1. Chat with @BotFather.
  2. Send /mybots, select the bot.
  3. Bot Settings → Group Privacy → Turn off (“Privacy mode is disabled”).
  4. 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 /connect command and the bot’s “Connection Successful” notice
    • media sending permissions

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; use customFields.chatId or the /connect word instead.
  • customFields with a chatId when the bot is not already in that chat — getChat fails. Add the bot first, or use the word flow.
  • provider when polling — only connectionId is 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:

ExtensionKindBot API method
png, jpg, jpeg, gif, webp, bmp, svg, tiff, tif, heicphotosendPhoto (single) / sendMediaGroup
mp4, m4v, mov, webm, mkv, avi, mpeg, mpgvideosendVideo / sendMediaGroup
anything elsedocumentsendDocument / sendMediaGroup

The send path depends on the number of media items:

  • 0 media: sendMessage with parse_mode=HTML.
  • 1 media: sendPhoto, sendVideo or sendDocument, with the text as the caption and parse_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 with FRONTEND_URL is 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_media fails with a local media error.

Telegram’s own limits:

LimitValue
Text message1–4096 characters after HTML entity parsing
Media caption0–1024 characters
Upload via multipart10 MB for photos, 50 MB for other files
Send by URL5 MB for photos, 20 MB for other content
sendMediaGroup items2–10 per group
getFile download20 MB
Messages per second in one chatabout 1; short bursts tolerated
Messages per minute in a group20
Global bot broadcastabout 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:

  1. <strong> → <b> and </strong> → </b>
  2. <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() computes reply_to = last_comment_id.unwrap_or(post_id), parses it as i64, drops 0, and sends the message with reply_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_to is None and 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_TOKEN is updated and the processes restart.
  • The bot token leaks through the integration picture. authenticate() resolves the chat photo with getFile and stores the returned link as the integration’s picture: https://api.telegram.org/file/bot<TELEGRAM_TOKEN>/<file_path>. That URL is returned by integrationList/channels list and 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.
  • /connect matching 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 getUpdates consumer. A webhook or a second poller causes 409 Conflict. Call deleteWebhook before 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 /connect message.
  • Pending state is one hour. The tg_… connection id is stored in oauth_states with TELEGRAM_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.
  • lastChatId is an update offset. Do not read it as a chat id. It is fed back to getUpdates as offset, which confirms all earlier updates.
  • Connect can succeed without admin rights. get_bot_id() checks getChatMember and 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. Being administrator or creator is not enough; the code also requires result.can_delete_messages == true. Otherwise it leaves the messages and asks the human to remove them.
  • getChat failure is fatal. authenticate() calls getChat with 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() stores id = chat.username when the chat has a public username, otherwise id = chat_id. The access token is always the numeric chat id. Posting uses the token, so both work. The stored internal_id is used for the release URL and for diagnostics.
  • Private-chat release URLs are wrong. release_link() uses https://t.me/{internal_id}/{message_id} whenever internal_id is non-empty and not "undefined". For a private channel or group without a username, internal_id is the negative chat id (e.g. -1001234567890), producing https://t.me/-1001234567890/42, which is not a valid Telegram link. The https://t.me/c/<id>/<message> fallback only runs for legacy rows whose internal_id is empty or "undefined". Public channels/groups with a username get correct t.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 calls sendMediaGroup for 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. sendMediaGroup allows photos and videos together, but documents/audio can only be grouped with the same type. The provider does not group by kind; 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 with FRONTEND_URL is converted to a local path; anything that does not start with http is validated with local_media_path(..., "telegram-error"). A path outside UPLOAD_DIRECTORY fails 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 content to a friendly error but cannot fix an unreachable URL.
  • No media retry. Text and single-part calls use Http::send, which retries 429/5xx. multipart() uses execute directly and does not retry; a transient 429 or 5xx on an upload becomes a BadBody (often with “Unknown Error”). Re-run the post if the media is safe to duplicate.
  • handle_errors() only handles HTTP 400. It parses error_code and description; any non-400 status is left to the shared HTTP layer. A 401 (invalid bot token) therefore becomes a RefreshToken error by the generic rule, but refresh_token() returns an empty token, so the channel is flagged for reconnection even though the only fix is to correct TELEGRAM_TOKEN.
  • No real token refresh. refresh_token() returns expires_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 the url field. The normal /integrations/social/telegram OAuth start route is not the supported path; use connectChannelTool, rustiz channels connect telegram, or POST /public/v1/integrations/telegram/connect, all of which go through connect::start()’s special case.
  • is_web3 = true is cosmetic. It is an upstream class flag; the connect layer special-cases Telegram and describe() reports telegramBot.
  • PostResponse::done() reports completed, not posted. Both Discord and Slack set PostStatus::Posted; Telegram uses the helper that sets PostStatus::Completed. Both are terminal for the engine, but a consumer that distinguishes the two states will see Telegram as completed.
  • 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 = 3 is conservative but a queue that targets one channel can still hit the per-chat limit. Telegram returns 429 with parameters.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_id is 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