Built + merged to main (commit e048062) on 2026-06-23. Inbound = operator Telegram commands → NATS publish, role-based authorization, restart-replay safety, Admin in-memory kill-switch independence.
High-Level Design
Inbound flow: operator issues /command in Telegram → poller (getUpdates) receives update → authorize (role check) → validate command shape → publish to ops.commands via NATS JetStream (with Nats-Msg-Id dedup) → ops consumer handles.
Key principle: Commands are CODE-DEFINED (not self-serve DB registry). New commands requested via COMMAND_REQUEST.md → @pmv2_telegram implements in the connector.
Commands are dispatch-only; there is NO command oracle. Unknown user → silently ignored (no error/permission message). Insufficient role → “not authorized” reply, no publish.
Activation & Config
Inbound turns on ONLY when TELEGRAM_OWNER_IDS is set. This is additive — outbound relay is unaffected by inbound activation.
Required env:
TELEGRAM_BOT_TOKEN— sole holderTELEGRAM_CHAT_ID— default chat for outboundTELEGRAM_OWNER_IDS— comma-separated Telegram user IDs (at least one = Admin)NATS_URL+NATS_*_NKEY_SEED— fleet NATS spineSUPABASE_DB_URL— optional; for role-based perms lookup (DB unreachable at boot = start owners-only, never crash)
Role-Based Permissions
Role hierarchy: Viewer < Operator < Admin
Bootstrap on startup (idempotent CREATE TABLE IF NOT EXISTS):
CREATE TABLE IF NOT EXISTS tg_operators (
telegram_user_id BIGINT PRIMARY KEY,
role TEXT NOT NULL CHECK (role IN ('Viewer', 'Operator', 'Admin')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);The connector NEVER writes to this table. It is READ-ONLY from the connector. Bootstrap is idempotent; the connector is NOT a sqlx migrator — this avoids colliding with the shared DB’s _sqlx_migrations table (fleet decision #333).
Admin Kill-Switch Independence
CRITICAL: Admin user IDs from TELEGRAM_OWNER_IDS are resolved IN-MEMORY before any DB lookup. If DB is unreachable at boot:
- Owners from env are still Admin
- Connector starts normally, owners-only mode
- Connector never crashes; outbound untouched
- DB reconnect on next poll; lazy role refresh
This is why owners are in env: unconditional access even if Supabase is down.
Restart-Replay Safety
CRITICAL Design
Telegram re-delivers unconfirmed updates ≤24h after a restart. The poller MUST handle this correctly.
The fix:
- Poll loop tracks
process_start = now()at boot - For each incoming update, check
message.date < process_start - Pre-restart messages are DROPPED from dispatch (no command execute)
- BUT offset is still advanced past the dropped update — so a
/haltor/autotradeissued before restart never re-executes - No offset persistence to SQLite — would reintroduce the replay risk
Why it works: Telegram’s unconfirmed delivery guarantees that re-delivered messages have date <= original_date. By advancing the offset, we acknowledge receipt without executing; Telegram stops re-sending. By dropping from dispatch, we prevent double-execute.
Authorization & Dispatch
Command execution is authorize-before-execute:
- Lookup user — find
telegram_user_idintg_operatorstable (or env override for owners) - Check role — verify role >= command minimum (e.g.,
/status= Viewer,/halt= Admin) - Unknown user — silently ignore (no “not authorized” message)
- Insufficient role — reply “not authorized”, do NOT publish
- Authorized — parse args, validate shape, publish
ops.commands, await result for reply
Reload Commands (Admin)
/reload-commands (Admin-only) atomically swaps the role permissions snapshot:
- Reads fresh
tg_operatorsfrom DB - Replaces in-memory cache (prevents TOCTOU race)
- If DB read fails → keep old snapshot, log warning, do NOT reply (silent fail-safe)
Group Chat Support
Command tokens strip the @BotName suffix automatically (e.g., /halt@my_bot_1234 → /halt).
Architecture: Trait Seams & Dispatch
Similar to outbound relay’s trait separation:
UpdateSource trait
trait UpdateSource {
async fn poll(&mut self) -> Result<Vec<Update>>;
async fn ack(&mut self, id: u64) -> Result<()>;
async fn nak(&mut self, id: u64) -> Result<()>;
}Impl: GetUpdatesClient (adapted from polymarket-data/notifier.rs).
Publisher trait
trait Publisher {
async fn publish(&self, subject: &str, msg: &CommandMsg) -> Result<()>;
}Impl: NatsPublisher (JetStream publish_with_headers; Nats-Msg-Id dedup key = command_id).
dispatch() — pure logic
async fn dispatch(
update: Update,
db: &Database,
owner_ids: &[u64],
publisher: &Publisher,
) -> Result<()>Takes Telegram Update, checks auth, validates, publishes. No side effects except DB reads + publish.
Main poll loop
Async-native (no block_on):
loop {
match source.poll().await {
Ok(updates) => {
for update in updates {
if update.message.date < process_start { source.ack(update.id).await?; continue; }
match dispatch(update, &db, &owner_ids, &publisher).await {
Ok(()) => source.ack(update.id).await?,
Err(e) => { log::warn!(...); source.nak(update.id).await?; }
}
}
}
Err(e) => { /* exponential backoff */ }
}
}Ops Command Contract (Co-Design with PM)
Status: co-design in progress (babylon 378, 2026-06-23).
PM proposed shape for pmv2_contracts::ops:
pub struct OpsCommand {
pub command_id: String, // reply correlation key; Nats-Msg-Id = command_id
pub issued_at: i64, // unix millis
pub actor: String, // telegram user_id as string
pub command: Command,
}
pub enum Command {
#[serde(rename = "set_mode")]
SetMode { mode: Mode },
#[serde(rename = "enable")]
Enable { target: String },
#[serde(rename = "disable")]
Disable { target: String },
#[serde(rename = "halt")]
Halt,
#[serde(rename = "status")]
Status,
}
pub enum Mode {
#[serde(rename = "off")]
Off,
#[serde(rename = "dry")]
Dry,
#[serde(rename = "live")]
Live,
}
pub struct OpsResult {
pub command_id: String, // reply to OpsCommand.command_id
pub status: OpsStatus, // applied | rejected | needs_confirm | error
pub state: OpsState, // current state snapshot after command
pub message: String, // human-readable detail
}
pub enum OpsStatus {
#[serde(rename = "applied")]
Applied,
#[serde(rename = "rejected")]
Rejected,
#[serde(rename = "needs_confirm")]
NeedsConfirm,
#[serde(rename = "error")]
Error,
}Six Adjustments Confirmed
- Add
schema_version: u32toOpsCommand+OpsResult(for forward compat) - Add consts
OPS_SCHEMA_VERSION,subjects::OPS_COMMANDS,subjects::OPS_RESULTStopmv2_contracts actor= telegramuser_idas string (not integer)- Two-step confirm for global live mode — driven STATELESSLY by operator token, PM owns the policy
- All types
serdewith#[serde(rename = "...")]inner enum tag, snake_case keys OpsState/OpsCapsusef64for status values (OK for status updates)
NATS Subjects
ops.commands ← inbound publishes OpsCommand (with Nats-Msg-Id = command_id)
ops.results ← inbound consumes OpsResult for reply correlation (DeliverPolicy::New + pending-map)
Deferred (Gated on PM Contract)
- Concrete
/autotrade,/halt,/statuscommand impls - ops.results reply-correlation consumer (DeliverPolicy::New + in-memory pending-map keyed by command_id)
- Currently, a synthetic
/echocommand exercises the framework (echoes back the command via ops.results for testing)
Known Limitations & Future Work
No Argument Parsing
Current framework dispatch is command name only (no args dict). This was a v1 decision to keep it simple. Future:
- Add
args: serde_json::ValuetoOpsCommand - Update
/set-modeetc. to include target/parameters - Keep dispatch pure; let consuming service validate args
No Pending Commands Map
Once PM confirms ops.results contract, we’ll add:
- In-memory
pending: HashMap<String, PendingCmd>(keyed bycommand_id) - Timeout mechanism (if no result within X seconds, reply with “pending” or “timeout”)
- Reply correlation for multi-service commands (one command may need results from multiple services)
Ops State Persistence
Current: stateless reply correlation (PM owns the state). Future may move to:
- Postgres
ops_commands(audit log) - Postgres
ops_state(current state snapshot)
Current Build Status
- Inbound framework: complete, merged to main (commit e048062)
- Startup tests: verify config parsing, env override for owners
- Integration tests: tbd (awaiting PM ops.* contract finalization)
- Concrete commands (
/autotrade,/halt,/status): deferred, gated on PM contract
Related
- telegram-connector-overview — service overview + wire contracts
- nats-jetstream-message-source — NATS pull consumer (outbound relay trait seam)
- task7-lib-bin-split-relay-loop-tests — outbound relay implementation + tests