Telegram Plugin
The Telegram plugin connects Codumentor to a Telegram bot so users can talk to the mentor agent from their phone or desktop — with voice support:
- Text in → text out — a text message starts an agent turn; the reply is posted back to the chat.
- Voice in → voice out — a voice message is transcribed (speech-to-text), passed to the agent marked as a voice input, and the agent's reply is synthesized back (text-to-speech) and returned as a Telegram voice note. The full text is also sent so nothing is lost.
Speech-to-text and text-to-speech are provided by the general Speech service (config.speech), which uses OpenAI-compatible audio APIs and is reusable beyond this plugin.
Two modes
The plugin runs in one of two modes, chosen by mode:
| Mode | Bot | Turns run as | Token from | Best for |
|---|---|---|---|---|
technical_user | one shared bot | a single service user | telegram_bot_token_global user secret (stored under the service user), or the bot_token_env env var as fallback | a team/instance bot |
per_user | one bot per user | the bot's owner | each user's telegram_bot_token user secret | everyone brings their own private bot |
Both modes resolve their bot token through the central User Secrets store and declare it via ui_manifest() so it shows up in the User Secrets pane ("needed by Telegram, not set yet"). technical_user additionally accepts the legacy bot_token_env environment variable as a fallback when the secret is unset.
Overview
- Long-polls Telegram's
getUpdates— no public URL or webhook receiver required. - Maps each Telegram chat to a single Codumentor conversation in plugin-owned SQLite (
data/telegram_integration.sqlite3). Subsequent messages in the same chat continue the same conversation. - Subscribes to
onResponsePersistto auto-post the agent's final response back to the originating chat (only main-agent answers, never subagent intermediate output). - If the incoming message was a voice note, transcribes it before the turn and synthesizes the reply back as a voice note afterward.
- Runs the poller from the
onServerReadyhook so exactly one poller starts per process, and guards single-runner across processes with a file lock (Telegram returns HTTP 409 if two pollers share a token). - Runs a daily TTL sweep to age out stale update-dedup rows, per-turn reply modes, and old chat mappings.
Requirements
- No extra Python packages. The plugin talks to the Bot API over
httpx, and the Speech service uses theopenaiSDK — both already inrequirements.txt/setup.py. - A Telegram bot. Create one with @BotFather (see Telegram bot setup). This gives you a bot token like
123456789:AA.... - For voice: an OpenAI API key (or any OpenAI-compatible audio endpoint) configured under
config.speech(see Speech service). Voice is optional — without it the plugin still handles text, and voice messages get a polite "voice isn't enabled" notice. dependencies: [user_secrets]— required for both modes: the bot token (and, when configured as${secret:...}, the speech key) lives in the User Secrets store. Thebot_token_envenv var still works as atechnical_userfallback ifuser_secretsis absent.
Quick start (technical_user)
# codumentor.yaml
speech:
enabled: true
api_key: "${OPENAI_API_KEY}" # voice; omit to run text-only
plugins:
- module: codumentor.plugins.telegram
class: TelegramPlugin
args:
enabled: true
mode: technical_user
service_user_id: telegram-bot # the shared token is this user's secret
allowed_user_ids: [123456789] # who may talk to the bot (your Telegram id)
Provide the bot token one of two ways:
- Preferred — User Secrets: sign in as the service account (
service_user_id, defaulttelegram-bot) and add a secret namedtelegram_bot_token_globalwith the bot token as its value (Settings → User Secrets; the pane lists it as "needed by Telegram"). The poller reads it straight from the store; each turn's auto-reply reads it from the service user's secret registry. - Fallback — environment variable: export the
bot_token_envvar (defaultTELEGRAM_BOT_TOKEN). Used only when the secret is unset, for backward compatibility.
export TELEGRAM_BOT_TOKEN="123456789:AA..." # only if not using the secret
export OPENAI_API_KEY="sk-..." # only needed for voice
If neither the telegram_bot_token_global secret nor bot_token_env resolves, the plugin loads in degraded mode — no poller, no crash — and logs a warning. The outbound auto-reply hook is always attached but no-ops for non-Telegram conversations.
Authorization: with
allow_all: false(the default) and an emptyallowed_user_ids, the bot rejects everyone. Add your numeric Telegram id toallowed_user_ids(ask @userinfobot for it) before the bot will respond.
Configuration — technical_user mode
plugins:
- module: codumentor.plugins.telegram
class: TelegramPlugin
priority: 50
args:
enabled: true
mode: technical_user
global_token_secret_name: telegram_bot_token_global # user-secret holding the shared token
bot_token_env: TELEGRAM_BOT_TOKEN # env-var fallback when the secret is unset
service_user_id: telegram-bot # Codumentor user that owns these conversations (and the token secret)
allow_all: false
allowed_user_ids: [123456789, 987654321]
permission_policy: auto_allow
target_agents: main
voice_reply: true # honor voice-in → voice-out
long_poll_timeout: 50
request_timeout: 70.0
Configuration — per_user mode
In per_user mode each Codumentor user registers their own bot token; the system polls every configured bot and runs turns as the owning user (their secrets, their repo context). No global token is needed.
plugins:
- module: codumentor.plugins.telegram
class: TelegramPlugin
priority: 50
args:
enabled: true
mode: per_user
token_secret_name: telegram_bot_token # user-secret each user stores their token under
per_user_authz: tofu # tofu | allowlist
per_user_allowed_ids_secret: telegram_allowed_ids # only for allowlist authz
bot_discovery: periodic # periodic | startup
reconcile_interval: 60 # seconds between rescans (periodic)
voice_reply: true
See Per-user private bot setup for the end-user steps.
Configuration parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Master on/off switch. |
mode | string | technical_user | technical_user (one shared bot) or per_user (one bot per user). |
global_token_secret_name | string | telegram_bot_token_global | technical_user: name of the user-secret (stored under service_user_id) holding the shared bot token. Preferred over bot_token_env; declared to the User Secrets pane. |
bot_token_env | string | TELEGRAM_BOT_TOKEN | technical_user: env var holding the bot token, used only as a fallback when the global_token_secret_name secret is unset. Read from the environment, never the YAML — only the env-var name is recorded, never the token. |
service_user_id | string | telegram-bot | technical_user: Codumentor user that owns Telegram-originated conversations and stores the shared bot-token secret. With auth.provider: none, any string is accepted. |
allow_all | bool | false | technical_user: if true, anyone who finds the bot may use it. Leave false and use allowed_user_ids. |
allowed_user_ids | list[int] | [] | technical_user: Telegram numeric user-ids allowed to use the bot. Empty + allow_all: false ⇒ nobody is allowed. |
token_secret_name | string | telegram_bot_token | per_user: user-secret name each user stores their bot token under. |
per_user_authz | string | tofu | per_user: who may use a user's bot — tofu or allowlist (see Authorization). |
per_user_allowed_ids_secret | string | telegram_allowed_ids | per_user/allowlist: user-secret holding the owner's allowed Telegram ids (comma/space separated). |
bot_discovery | string | periodic | per_user: periodic rescans for added/removed bots every reconcile_interval; startup discovers once. |
reconcile_interval | int | 60 | per_user/periodic: seconds between rescans. |
permission_policy | string | auto_allow | Tool-permission policy for turns from this channel. Only auto_allow is wired today: tool prompts are auto-allowed (global deny rules still apply), and a turn stuck on a durable approval is auto-approved and resumed. |
target_agents | string | main | Which agent types this applies to: main, subagent, all. |
voice_reply | bool | true | Honor voice-in → voice-out. Requires config.speech enabled; otherwise replies stay text. |
long_poll_timeout | int | 50 | getUpdates long-poll timeout (seconds). |
request_timeout | float | 70.0 | HTTP timeout (seconds); must exceed long_poll_timeout. |
Speech service (STT / TTS)
Voice support is provided by a general, system-level speech service configured under the top-level speech: key. It is not Telegram-specific — it can power future web/TUI voice features too.
speech:
enabled: true
base_url: "https://api.openai.com/v1" # OpenAI-compatible audio endpoint
api_key: "${OPENAI_API_KEY}" # ${ENV_VAR} or ${secret:<name>}
stt_model: "whisper-1" # speech-to-text model
stt_language: null # null = auto-detect
tts_model: "tts-1" # text-to-speech model
tts_voice: "alloy" # alloy | echo | fable | onyx | nova | shimmer
tts_format: "opus" # opus → native Telegram voice note
tts_speed: 1.0
| Parameter | Default | Description |
|---|---|---|
enabled | false | Master switch. Even when true, the service reports not usable until base_url and api_key resolve to real values. |
base_url | https://api.openai.com/v1 | OpenAI-compatible audio endpoint. Point it elsewhere to self-host later. |
api_key | — | Supports ${ENV_VAR} substitution and ${secret:<name>} resolution. For a service-account caller ${OPENAI_API_KEY} is simplest. When set to ${secret:<name>}, the Telegram plugin declares it to the User Secrets pane and installs the relevant user's secrets around background transcription so the key resolves there too (see Speech key as a user secret). |
stt_model | whisper-1 | Transcription model (/v1/audio/transcriptions). |
stt_language | null | ISO code to force a language, or null to auto-detect. |
stt_max_audio_bytes | 26214400 | Reject larger uploads before calling the provider (OpenAI caps at 25 MB). |
tts_model | tts-1 | Synthesis model (/v1/audio/speech). |
tts_voice | alloy | Voice name. |
tts_format | opus | Output format. opus yields Ogg/Opus, which Telegram renders as a native voice note. |
tts_speed | 1.0 | Playback speed. |
tts_max_input_chars | 4096 | Truncate longer text before synthesis (the full text is still sent as a message). |
read_timeout | 120.0 | HTTP read timeout (seconds). |
max_retries | 2 | Transient-error retries. |
If speech.enabled is false or the key/URL don't resolve, the plugin runs text-only: voice messages get a "voice isn't enabled for this bot" notice, and replies are always text.
Speech key as a user secret
The speech api_key is a shared service credential, so the simplest setup is an environment variable (${OPENAI_API_KEY}, the default), which resolves everywhere — including the background transcription loop, which runs outside any agent turn.
If you prefer to keep it out of the environment, set api_key: "${secret:speech_api_key}" (any name). Then:
- The Telegram plugin declares that secret to the User Secrets pane (alongside the bot token), so it shows as "needed by Telegram".
- TTS (the reply) runs inside the turn, so the key resolves from the acting user's secrets —
service_user_idintechnical_usermode, the bot owner inper_usermode. - Transcription (the incoming voice note) runs before the turn, outside any secret context. The listener handles this by installing that user's secrets just for the transcription call, so the key resolves there too.
Store the secret under the same account that owns the bot token: the service_user_id for technical_user, or each owner for per_user. Mixing a ${secret:...} speech key with a technical_user bot whose token comes from bot_token_env is fine — the two credentials resolve independently.
Per-user private bot setup
In per_user mode each user runs their own bot under their own Codumentor account:
- Create a bot: message @BotFather →
/newbot→ follow the prompts → copy the token (123456789:AA...). - Store the token in Codumentor: Settings → User Secrets → Add, name it
telegram_bot_token(or whatevertoken_secret_nameis set to), paste the token. - With
bot_discovery: periodic(default), the bot starts polling withinreconcile_intervalseconds — no restart needed. Removing the secret stops it on the next rescan. - First message binds you as the owner (default
tofuauthorization): open your bot in Telegram, send any message, and you're bound as the only account allowed to use it. Turns then run as your Codumentor user, with your secrets and repo context.
To make step 2 obvious, the plugin declares this secret to the User Secrets pane via ui_manifest() secret_requirements, so it shows "Telegram Bot Token — needed by Telegram" with set/unset state. See Secret requirements.
Voice messages
When voice is enabled (config.speech.enabled and voice_reply: true):
- A user sends a Telegram voice note (Ogg/Opus).
- The plugin downloads the audio (
getFile+ file download) and transcribes it with the Speech service (Whisper accepts Ogg/Opus directly — no conversion). - The transcript is passed to the agent prefixed with
[Voice message — transcribed]so the agent knows it was speech, and the turn is flagged to reply with voice. - After the turn, the agent's final answer is synthesized (
tts_format: opus) and sent as a Telegram voice note viasendVoice— followed by the full text so nothing is lost.
Text messages are unaffected: text in → text out.
Long replies are summarized poorly as a single long voice note, so the text follow-up is always sent. The TTS input is capped at
tts_max_input_chars.
Authorization
A Telegram bot is reachable by anyone who discovers its @username, and a turn runs with real agent capabilities (and, in per_user mode, the owner's secrets). Authorization gates who may actually use a bot.
technical_user mode — static allowlist:
allow_all: true— anyone may use the bot.allow_all: false(default) — onlyallowed_user_ids(Telegram numeric ids) may use it. Empty list ⇒ nobody.
per_user mode (per_user_authz):
tofu(trust-on-first-use, default) — the first Telegram account to message a user's bot is bound as its owner; everyone else is rejected. Zero configuration for the user, secure after the first message.allowlist— the owner stores allowed Telegram numeric ids in thetelegram_allowed_idsuser-secret (comma/space separated). If unset, the bot rejects everyone (and logs a warning).
Find your numeric Telegram id by messaging @userinfobot.
Telegram bot setup
- Open @BotFather in Telegram and send
/newbot. - Choose a display name and a unique
@usernameending inbot. - Copy the token BotFather gives you (
123456789:AA...). - technical_user: store the token as the
telegram_bot_token_globaluser-secret under the service account (or, as a fallback, in thebot_token_envenvironment variable). per_user: each user stores it as thetelegram_bot_tokenuser-secret. - (Optional)
/setprivacy→ Disable if you want the bot to see all messages in groups; for 1:1 chats the default is fine. - Open a chat with your bot and send a message. (In
per_user/tofuthis binds you as owner; intechnical_usermake sure your id is inallowed_user_ids.)
State & storage
Plugin-private SQLite at data/telegram_integration.sqlite3:
chat_mappings(bot_key, chat_id, conversation_id, created_at)— binds a Telegram chat to a Codumentor conversation.INSERT OR IGNORE: first-message race winner pins the binding.processed_updates(bot_key, update_id, seen_at)— dedup keyed off Telegram'supdate_id.bot_offsets(bot_key, next_offset)— persistedgetUpdatescursor so a restart resumes instead of replaying.turn_reply_mode(conversation_id, turn_id, mode)— per-turn voice/text flag (set on a voice message, read by the auto-reply hook).bot_owner_binding(bot_key, telegram_user_id, bound_at)—per_user/tofuowner binding.
A background TTL task runs every 24 hours and purges stale processed_updates, turn_reply_mode, and 30-day-old chat_mappings rows.
Behavior notes
- Single in-flight turn per chat. If a second message arrives while the previous turn is still running, the plugin replies "still working on your previous message" and drops the new one. v1 keeps this simple; queueing is deferred.
- Auto-reply scope. Only the main agent's final response is posted back; subagent intermediate output stays internal; empty text is skipped.
- Long replies are chunked at paragraph breaks into ≤4,000-char messages (Telegram's limit is 4,096).
- Single poller. The poller starts from
onServerReady(once per process) and acquires a file lock (data/telegram_poller.lock); if another process already holds it, this process skips polling but still posts auto-replies. This prevents the HTTP 409 you'd get from two pollers sharing a token. - Token handling. Tokens are never written to logs. All bot tokens live encrypted in the user_secrets store: the
technical_usershared token underservice_user_id(withbot_token_envas a plaintext-env fallback), per-user tokens under each owner. The poller reads them straight from the store; the auto-reply hook resolves them at reply time from the turn's secret registry.
Limitations
- Real-time SSE updates for web-UI users. Telegram-originated conversations are persisted to the same event store as UI conversations, but the listener constructs its own broadcaster. A web-UI client on the live SSE stream won't see real-time updates for Telegram-initiated turns until it reloads. The transcript is fully visible after refresh. (Same trade-off as the Slack plugin.)
- Permissions are
auto_allow. Telegram conversations are created withauto_runmetadata, so tool prompts are auto-allowed (global deny rules still apply) — nobody is watching the web approval dialog from Telegram. If a turn does suspend on a durable approval anyway (e.g. a conversation created before this wiring), the listener auto-approves the pending request and resumes the turn instead of blocking the chat. Rule-based and interactive (inline-button) approval are designed but not yet wired. - No group-thread isolation. A whole chat maps to one conversation; per-topic threads are not split out in v1.
See Also
- Example config — copy-pasteable
speech:+ plugin entry for both modes - Slack Integration — the same two-way pattern for Slack
- User Secrets — where per-user bot tokens are stored
- Configuration Reference