Control the daemon from Telegram
A second remote write surface on the headless daemon, off at compile time, behind four gates and a mandatory preview.
vike-tradehub can be driven from a phone. The channel is a getUpdates long-poll against
Telegram's Bot API — an outbound HTTPS GET, no inbound listener — and every write instruction is
answered with a preview and a single-use token before anything reaches the core.
The accepted risk, stated once
crates/vike-tradehub/src/telegram/mod.rs states it plainly: with this channel enabled,
possession of the bot token plus membership in an allowlisted chat is sufficient to place real
orders. Telegram is a third party sitting in an order-origination path. ControlLimits and the
core RiskGate still bound the blast radius; the residual does not go away.
1. Build with the feature
The default is a compile-time one, not a runtime one. Without the telegram feature
src/telegram/ is not part of the crate at all, main.rs has no mount and no call site, and the
direct ureq edge is dropped. A runtime gate can be misconfigured or inherited from a stale systemd
unit; code that was never compiled in cannot be reached by any misconfiguration. The crate is built
with --features telegram, and that feature has its own CI lane.
2. Open all four gates
They are required simultaneously, and they live in two different places on purpose:
| gate | where | semantics |
|---|---|---|
VIKE_TRADEHUB_CONTROL=1 | process env | the exact string "1" |
VIKE_TELEGRAM_CONTROL=1 | process env | the exact string "1" |
VIKE_TELEGRAM_BOT_TOKEN | credential store | absent or blank means nothing is constructed |
VIKE_TELEGRAM_ALLOWED_CHAT_IDS | credential store | comma-separated; empty means disabled |
Absent any one of the four there is no poller thread, no token in memory and no network call.
maybe_spawn enforces that ordering literally: the two process-env flags are checked by the pure
control_gates_open first and the credential loader is passed as a function, so on the closed path
the store is never even opened and the bot token never enters the process's address space. An
optional fifth, VIKE_TELEGRAM_ALLOWED_USER_IDS, can only ever narrow.
Authorization is per-chat; attribution is per-user
The gate asks one question — is this chat allowlisted? — and a chat is not a person. An allowlisted
-100… supergroup authorizes every member, including everyone anyone later adds, with no config
change and nothing to review. parse_updates therefore reads message.from.id and the @handle,
and confirm_reason/confirmed_by put both the instructing and the confirming actor into the audit
rationale. The confirm token stays chat-bound rather than user-bound deliberately: binding it would
stop an operator confirming a colleague's preview.
An unlisted chat_id is ignored and logged, never answered — no reply, not even an error. A bot
that answers unknown chats is an oracle confirming a trading node exists behind it.
3. The grammar
crates/vike-tradehub/src/telegram/parse.rs's parse_instruction is the whole grammar and the only
place a chat string becomes a WireCommand. Anything without a leading / is silently ignored, so
ordinary group chatter produces no bot noise; /verb@botname is accepted, because Telegram appends
the bot's username in a group.
Read verbs answer immediately: /status, /positions, /orders, /equity, plus /help
(/start). Write verbs are previewed only:
/submit <venue> <symbol> <buy|sell> <qty> [price] [reduce]
/cancel <client_order_id>
/modify <client_order_id> [qty=<q>] [price=<p>]
/masscancel
/flatten <venue> <symbol>
/marketexit
/state <active|reducing|halted>
/confirm <token>There is no stop verb. Stopping the daemon is its own procedure.
4. Preview, then confirm
- A write instruction is answered with a preview: the resolved
WireCommand, the server-authoritative guardrail verdict, and a single-use token. Nothing is sent. - Only
/confirm <token>, withinCONFIRM_WINDOW_MS(60 s), executes. - The token is single-use —
PendingConfirms::takeremoves it before the command is handed to the core — and bound to the exact command it previewed and to the chat that asked. A token that has fired, expired, or belongs to another chat is refused, and a wrong-chat attempt does not burn it.
crates/vike-tradehub/src/telegram/confirm.rs lists those properties as a table with the test that
gates each one, so removing a guarantee fails CI rather than evaporating quietly. The store is
memory-only: a day-old /confirm replayed out of Telegram's queue after a restart finds no
pending entry and executes nothing, while a replayed /submit produces only another preview.
Every confirmed instruction then goes through crate::server::accept_command — the exact function
the TCP Request::Command arm calls — so the rate token, the notional cap, the rationale sanitizer,
the lowering, the single-writer lane and the audit record are identical by construction rather than
by review. See the control boundary.
5. At-most-once across a restart
Telegram's update_id is monotonic. crates/vike-tradehub/src/telegram/ledger.rs's UpdateLedger
persists the high-water mark (append-only lines, max on load) to
<project>/settings/state/telegram_updates.ledger, and every getUpdates passes
offset = last + 1, which is also how Telegram is ACKed. An update is marked before it is acted
on: a daemon that dies mid-command must lose that command, never place it twice.
A ledger write that cannot land is an error, never a shrug. At arming, maybe_spawn opens the ledger
to prove it appendable and returns None with one tracing::error! if it is not — the daemon keeps
trading headless and the remote write surface simply does not exist. At runtime an unmarkable update
is dropped undispatched.
When the channel goes quiet
crates/vike-tradehub/src/telegram/failure.rs separates "retrying could help" from "retrying can
never help". A 401, 403 or 404 is permanent: one error! and the poller stops. Everything else —
5xx, 429, 409, transport, unreadable body, bad JSON — is transient, with exponential backoff to a
ceiling and announcements throttled to consecutive failures 1, 2, 4, 8. 409 Conflict is
deliberately transient: it means two daemons on one token, or an active webhook, and neither is a
rejected credential.
That policy exists because of a measurement. A wrong bot token fails instantly — there is no long
poll to wait through — so the old answer-every-failure-the-same loop settled at roughly two requests
per second forever: 19 warnings in 10 s on a clean install with a mistyped token, about 164,000
outbound third-party requests and 164,000 WARN lines (~37 MB) per day, at the exact level operators
are told to set VIKE_LOG_FILE_LEVEL to. The token is read once at mount, so the error line names
the restart.
The bot token is embedded in the API base URL, which is why ProdTelegramDeps has no Debug and
never interpolates the URL or a transport error into a message, and TelegramConfig's Debug
redacts the token.