Repository navigation
Telegram Bot
telegram_bot.py is the operator surface for Execution-Guard decisions: it
pushes trade proposals to a linked chat, and lets the operator approve or reject
them without opening the dashboard. It is deliberately thin — the raw Bot API over
requests, English-only labels, and no business logic of its own.
Related pages: API-Reference · Execution-Guard · Data-Model · Operations · Configuration
Both delivery models funnel into one function, handle_update(update), so a button
behaves identically in production and on a laptop:
| Production — webhook | Development — long polling | |
|---|---|---|
| Entry point |
POST /api/telegram/webhook/ (TelegramWebhookView) |
python manage.py telegram_poll |
| Needs a public HTTPS URL | Yes | No |
| Auth |
X-Telegram-Bot-Api-Secret-Token header, compared with secrets.compare_digest
|
Bot token only |
| Update source | Telegram pushes; request.data is passed straight to handle_update
|
getUpdates with an offset, looped |
| Dispatcher |
handle_update → _handle_callback / command handlers |
identical |
The webhook view is unauthenticated by design — Telegram cannot present a DRF
token — and verified instead by the shared secret from TELEGRAM_WEBHOOK_SECRET.
Behaviour, in the order the view evaluates it:
- No
TELEGRAM_BOT_TOKENconfigured → return{"ok": true}, 200, do nothing. - Secret configured and header absent/mismatched → log
Rejected Telegram webhook with bad secret token, return 403. - Otherwise call
handle_update, swallowing any exception, and always return 200 so Telegram does not retry the same update forever. - A blank
TELEGRAM_WEBHOOK_SECRETwith a bot token configured makes the endpoint return503(fail-closed) instead of accepting unsigned posts. Set it in production;.env.examplecalls it required there.
set_webhook sends allowed_updates: ["message", "callback_query"] and
drop_pending_updates: true, so the bot only ever receives what it handles and
never replays a backlog it missed. delete_webhook drops pending updates too.
telegram_poll long-polls for local development:
python manage.py telegram_poll # loop until Ctrl-C
python manage.py telegram_poll --once # drain the backlog and exit
python manage.py telegram_poll --timeout 10It calls getMe first (a rejected token exits with
Telegram rejected the bot token), clears any registered webhook — getUpdates and
a webhook are mutually exclusive — then loops with offset = update_id + 1, sleeping
3 s after a transport error. Per-update failures are logged and skipped, never fatal.
-
Create the bot with @BotFather and keep the token out of the repository.
-
Add the configuration to
.env(placeholders — never commit real values):TELEGRAM_BOT_TOKEN=<token-from-BotFather> TELEGRAM_WEBHOOK_SECRET=<random-32-byte-string> TELEGRAM_WEBHOOK_URL=https://<your-host>/api/telegram/webhook/ TELEGRAM_REQUEST_TIMEOUT=15
Leaving
TELEGRAM_BOT_TOKENempty disables the integration completely:TELEGRAM_CONFIG["ENABLED"]is derived from it, the webhook acknowledges without work, andPOST /api/telegram/link/returns 503. The rest of the platform is unaffected. -
Recreate the services that read the token —
web(webhook, link API) andworker(outbound notifications):docker compose up -d --force-recreate web worker
-
Register the webhook. There is no management command for this; use the Bot API directly (the payload mirrors
TelegramClient.set_webhook):curl -sS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/setWebhook" \ -H 'Content-Type: application/json' \ -d "{\"url\":\"${TELEGRAM_WEBHOOK_URL}\",\"secret_token\":\"${TELEGRAM_WEBHOOK_SECRET}\", \"allowed_updates\":[\"message\",\"callback_query\"],\"drop_pending_updates\":true}"
The equivalent from inside the app:
docker compose exec web python manage.py shell -c \ "from telegram_bot import get_client; print(get_client().call('getWebhookInfo'))"
-
Link a chat to an account. Mint a code from the authenticated API, then send it to the bot:
curl -sS -X POST http://127.0.0.1:8000/api/telegram/link/ \ -H "Authorization: Token ${ALPHAAGENT_API_TOKEN}" # -> {"link_code": "<16-hex-code>", "expires_at": "...", "instructions": "..."}
In Telegram:
/start <16-hex-code>. The chat is now bound;/balanceand the main menu work. -
For a laptop or a sandbox, skip step 4 and run the poller instead (
python manage.py telegram_poll); no public URL is involved.
Verify the pairing from the server side:
docker compose exec web python manage.py shell -c \
"from core.models import TelegramLink; print(list(TelegramLink.objects.values_list('user__username','chat_id','is_active')))"Commands are matched case-insensitively after stripping a @botname suffix.
Unknown /commands get "Unknown command" plus the help text; any non-command text
gets the help text and the main menu.
| Command | Handler | Effect |
|---|---|---|
/start |
_cmd_start |
Linked chat: welcome back + main menu. Unlinked: instructions to obtain a code |
/start <CODE> |
_cmd_start |
Binds this chat to the account owning an active link_code; clears the code, records telegram_username and last_seen_at
|
/balance |
_cmd_balance |
Equity, cash, positions value, unrealised and realised P&L, daily-loss budget, per-trade budget, autopilot state, plus the pause/resume buttons |
/positions |
_cmd_positions |
One block per asset: quantity, value, average price, P&L |
/thoughts |
_cmd_thoughts |
Last 3 AgentDecisionLog rows: action, timestamp, tokens, cost, 600 chars of reasoning |
/pending |
_cmd_pending |
Lists up to 5 pending proposals; each gets its own message with Approve/Reject/Reasoning buttons |
/analyse |
_cmd_analyse |
Queues advisory_sweep_task for the linked portfolio — analysis only, nothing executes |
/pause |
_set_autonomy(False) |
Sets is_autonomous=False, emits an autonomy event, warns that proposals now need approval |
/resume |
_set_autonomy(True) |
Sets is_autonomous=True and warns that the AI may now execute within limits |
/unlink |
_cmd_unlink |
Sets the link's is_active=False; the chat can be re-paired later |
/help |
inline | The English command list with the main menu |
Labels are English-only; every button carries a short callback_data string. The
test suite asserts every callback_data stays within Telegram's 64-byte limit.
Main menu (main_menu_keyboard):
| Button | callback_data |
|---|---|
| 📊 Balance Report | report:balance |
| 📈 Open Positions | report:positions |
| 🧠 Latest AI Reasoning | report:thoughts |
| 🗂 Pending Approvals | report:pending |
| 🔍 Analyse Now | action:analyse |
| 🔄 Refresh | report:balance |
Per recommendation (recommendation_keyboard(id)):
| Button | callback_data |
Handler |
|---|---|---|
| ✅ Approve Trade | ap:<id> |
_handle_recommendation_action → approve_recommendation(..., via=DecidedVia.TELEGRAM)
|
| ❌ Reject Trade | rj:<id> |
_handle_recommendation_action → reject_recommendation(..., via=DecidedVia.TELEGRAM)
|
| 🧠 Full Reasoning | why:<id> |
_show_reasoning |
| 🗂 All Pending | report:pending |
_cmd_pending |
Autonomy toggle (autonomy_keyboard): ⏸ Pause Autopilot (action:pause) or
▶️ Resume Autopilot (action:resume), plus 📊 Balance (report:balance).
Unknown callback data replies 🤔 Unknown action. with the main menu.
A callback payload is attacker-controllable input, never an authority. Anyone who
can reach the update path can forge ap:1; the code therefore treats callback_data
as a request and re-derives every fact it needs from the server-side session (the
chat link) and the database.
| Supplied by the payload | Derived server-side and trusted |
|---|---|
The recommendation id in ap: / rj: / why:
|
The acting user — from chat_id → active TelegramLink.user
|
| (nothing else) | Ownership — TradeRecommendation.filter(pk=reco_id, portfolio__user=link.user)
|
Actionability — reco.is_actionable re-read at click time |
|
Trade validity — evaluate_proposal() re-run against live prices |
Every path enforces the same order:
-
Link required.
_require_link(chat_id)resolves an activeTelegramLink; otherwise the bot replies "This chat is not linked to an AlphaAgent account" and the callback is answered with aNot linkedalert. No report is rendered. -
Ownership re-verified. The recommendation is fetched with
portfolio__user=link.user. Another user's id yieldsNot found, never data. -
State re-checked.
is_actionableguards against double approval: a decided or expired proposal answersAlready <status>and, where possible, edits the original message to show the final state. -
Approval is not a bypass.
approve_recommendation()takes a row lock, replays positions, fetches a fresh quote and runs the same execution guard used by the autonomous path. A proposal that no longer fits is stored asBLOCKEDwith the guard reason instead of executing — see Execution-Guard. -
Attribution. The decision is recorded with
decided_via=TELEGRAMand written into the audit trail by_log_human_decision, so the operator's action sits in the same timeline as the AI reasoning.
Supporting controls:
-
Pairing.
POST /api/telegram/link/mintssecrets.token_hex(8).upper(); the code is cleared on first successful use, and a chat is bound to at most one account (any otherTelegramLinkrow for thatchat_idis deleted).chat_idis unique and non-null, so an unpaired row carries a synthetic negative id (-user.id). Expiry is enforced, not merely advertised:_cmd_startresolves the code throughTelegramLink.redeemable(), which requireslink_code,is_activeand alink_code_issued_atinsideLINK_CODE_TTL(15 minutes). This closed a real gap — the lookup previously filtered onis_activealone, so an unused code never expired and a leaked one was a permanent credential. Treat the code as a secret and let it be consumed or replaced. -
Webhook authenticity. The
X-Telegram-Bot-Api-Secret-Tokenheader is compared withsecrets.compare_digest; a mismatch is logged and rejected with 403. If the secret is unset while a bot token is configured, the endpoint fails closed with503rather than accepting unsigned posts — an unconfigured deployment must not be less safe than a configured one. -
Output hardening. All model- and user-derived text goes through
html.escape(esc) before HTMLparse_moderendering; messages are truncated at 3900 chars and callback answers at 200, so a long reasoning string cannot break a reply. -
Fail-soft, never fail-closed on Telegram.
handle_updatecatches everything,_safe_editlogs at debug, and notification helpers return a status dict instead of raising — a Telegram outage must never fail a trade.
| Function | Trigger | Gating | Returns |
|---|---|---|---|
notify_recommendation(id) |
tasks.notify_recommendation_task, queued from run_alpha_agent_task after a PENDING proposal is created |
bot_enabled(), active link, link.notify_recommendations
|
sent / disabled / missing / not_linked / error
|
notify_trade(id) |
tasks.notify_trade_task, enqueued after execution on both the autonomous path and the human-approval path |
same, plus link.notify_trades
|
same shape |
notify_recommendation sends the proposal card with Approve/Reject buttons;
notify_trade (implemented, not yet dispatched) would send the execution summary with
the main menu. A transport failure is logged as a warning and returned as error, and
the Celery wrapper in tasks.py catches as well — a Telegram problem never marks a
successful trade as failed. /pending is the recovery path when a push is missed.
| Symptom | Cause | Action |
|---|---|---|
Telegram rejected the bot token on telegram_poll
|
Token wrong, revoked, or from another bot | Re-copy from @BotFather; /start from the bot's own chat to confirm it is alive |
Webhook log: Rejected Telegram webhook with bad secret token
|
TELEGRAM_WEBHOOK_SECRET does not match the secret_token registered with setWebhook
|
Re-register the webhook (setup step 4), then recreate web
|
| Bot silent in production | Webhook never registered, or registered with a URL Telegram cannot reach |
getWebhookInfo; a webhook URL must be public HTTPS — terminate TLS in front and enable DJANGO_SECURE_SSL_REDIRECT/DJANGO_USE_X_FORWARDED_PROTO
|
| Bot silent during development | Webhook still registered, so getUpdates returns nothing |
telegram_poll clears it automatically; otherwise call deleteWebhook
|
Conflict: terminated by other getUpdates request |
Two pollers on the same token | Run one; the exception is caught and retried after 3 s, which hides the cause |
/start <CODE> → "That link code is not valid or has expired" |
Code mistyped, already consumed, or the link was deactivated | Mint a new code; codes are 16 hex characters, uppercase |
Buttons answer Not linked
|
The chat was unlinked or re-paired to another account | Mint a new code in the dashboard and send /start <CODE> again |
Buttons answer Already executed / Already expired
|
Correct behaviour on a stale message | Use /pending for the current list |
Approval results in Blocked by guardrail: ...
|
The trade stopped fitting between proposal and click (price, cash, stop-loss) | Expected; the reason is shown and logged — see Execution-Guard |
| Long reasoning appears cut off | Intentional truncation (build_thoughts_message 600 chars, messages 3900) |
Read the full text in the dashboard audit trail |
Start here
The system
Interfaces
Running it
Background