vike
Guides

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:

gatewheresemantics
VIKE_TRADEHUB_CONTROL=1process envthe exact string "1"
VIKE_TELEGRAM_CONTROL=1process envthe exact string "1"
VIKE_TELEGRAM_BOT_TOKENcredential storeabsent or blank means nothing is constructed
VIKE_TELEGRAM_ALLOWED_CHAT_IDScredential storecomma-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

  1. A write instruction is answered with a preview: the resolved WireCommand, the server-authoritative guardrail verdict, and a single-use token. Nothing is sent.
  2. Only /confirm <token>, within CONFIRM_WINDOW_MS (60 s), executes.
  3. The token is single-use — PendingConfirms::take removes 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.

On this page