Untether telegram-bot-api
install
source · Clone the upstream repo
git clone https://github.com/littlebearapps/untether
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/littlebearapps/untether "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude/skills/telegram-bot-api" ~/.claude/skills/littlebearapps-untether-telegram-bot-api && rm -rf "$T"
manifest:
.claude/skills/telegram-bot-api/SKILL.mdsource content
Telegram Bot API (Raw HTTP)
Untether uses a custom Telegram Bot API client built on
httpx (async) and msgspec (JSON). There is no Telegram SDK dependency.
Key files
| File | Purpose |
|---|---|
| — all Bot API calls |
| — queued send/edit/delete with rate limiting |
| — renders progress, inline keyboards, answers |
| Long polling loop (), callback dispatch |
| Command and callback handlers |
| Full transport reference |
Bot API call pattern
All calls go through
TelegramClient, which wraps httpx.AsyncClient:
# Typical Bot API call (inside TelegramClient) resp = await self._http.post( f"{self._base_url}/bot{self._token}/{method}", json=params, ) data = msgspec.json.decode(resp.content, type=TelegramResponse)
- Base URL:
https://api.telegram.org - Auth: bot token in the URL path (
)/bot<token>/ - All responses decoded with
into typed structsmsgspec.json.decode - Error handling: check
field, raise on HTTP or Telegram errorsok
Inline keyboards and callback queries
Permission requests and plan mode buttons use Telegram inline keyboards:
# reply_markup structure in RenderedMessage.extra { "reply_markup": { "inline_keyboard": [ [{"text": "Approve", "callback_data": "ctrl:approve:<request_id>"}], [{"text": "Deny", "callback_data": "ctrl:deny:<request_id>"}], [{"text": "Pause & Outline Plan", "callback_data": "ctrl:discuss:<request_id>"}], ] } }
- Callback data format:
(max 64 bytes)<prefix>:<action>:<id> - Must call
promptly to clear the spinneranswerCallbackQuery - Early answering: set
on the backend to clear the spinner immediately with a toastanswer_early = True
Long polling (getUpdates
)
getUpdates# In telegram/loop.py updates = await client.get_updates(offset=last_offset + 1, timeout=30) for update in updates: last_offset = update.update_id await handle_update(update)
- Bypasses the outbox (direct API call)
- Retries on
by sleeping for the provided delayRetryAfter - No webhooks — Untether is designed for single-instance long polling
Outbox model
All writes (send, edit, delete) go through
TelegramOutbox:
- Single worker processes one op at a time
- Keyed deduplication: one pending op per key; new ops overwrite payload but preserve
queued_at - Priority scheduling:
ordering(priority, queued_at)- send=0 (highest), delete=1, edit=2 (lowest)
- Coalescing: rapid edits to the same message naturally coalesce (only latest payload runs)
Key formats (include
chat_id to avoid cross-chat collisions):
for edits("edit", chat_id, message_id)
for deletes("delete", chat_id, message_id)
when replacing a progress message("send", chat_id, replace_message_id)- Unique key for normal sends
Rate limiting
- Per-chat pacing:
(default 1.0 msg/s),private_chat_rps
(default 20/60 msg/s)group_chat_rps - Per-chat
timestamps — worker picks from unblocked chats; global_next_at[chat_id]
blocks all on 429retry_at - On 429:
raised usingRetryAfter
; op requeued if no newer op superseded itparameters.retry_after - Non-429 errors: logged and dropped (no retry)
Replace progress messages
send_message(replace_message_id=...):
- Drops any pending edit for the progress message
- Enqueues the send at highest priority
- On success, enqueues a delete for the old progress message
Voice transcription
[transports.telegram] voice_transcription = true voice_transcription_model = "gpt-4o-mini-transcribe"
- Download voice payload from Telegram (
+ HTTP fetch)getFile - Transcribe with OpenAI-compatible API (or local Whisper server)
- Route transcript through same command/directive pipeline as typed text
Forum topics
Topics bind Telegram forum threads to a project/branch:
- Scope modes:
,auto
,main
,projectsall
creates and binds a topic/topic <project> @branch- Resume tokens persist per topic
- Bot needs Manage Topics permission
Media group coalescing
Multiple documents sent as an album share a
media_group_id:
collects messages with the sameMediaGroupBuffermedia_group_id- After
seconds of quiet, buffer flushesmedia_group_debounce_s - Processed as a single batch via
handle_media_group
Forwarded message coalescing
Comment + forwarded messages arrive as separate updates:
- Wait
seconds for additional forwardsforward_coalesce_s - Forwards appended to the prompt; don't start their own runs
- Forwarded messages alone don't start runs
Message overflow
- Default: split across multiple messages with "continued (N/M)" headers (~3500 chars per chunk)
- Trim mode: truncate to single message (~3500 chars)
- Configure via
message_overflow = "split" | "trim"
Approval push notifications
edit_message_text doesn't trigger phone push notifications. Untether sends a separate notify=True message ("Action required -- approval needed") when approval buttons appear. The _approval_notified flag resets when buttons disappear.
Ephemeral message cleanup
Approval-related messages auto-delete:
- "Action required" notification — deleted when user clicks a button
- "Approved/Denied" feedback — deleted when the run finishes
- Tracked via
(in_approval_notify_ref
) andProgressEdits
(in_EPHEMERAL_MSGS
)runner_bridge.py