The pmv2 fleet’s single Telegram I/O plane. Captures the approved (2026-06-22) v1 design: a stateless, durable JetStream consumer that faithfully relays telegram.outbound messages to the Telegram Bot API. Full spec lives at /Users/levander/coding/pmv2/telegram_connector/docs/superpowers/specs/2026-06-22-telegram-connector-outbound-design.md.
For Agents
telegram_connectoris the sole bot-token holder and sole Telegram sender for the entire pmv2 fleet — the single rate-limit authority. It holds no trading logic, no DB, and is stateless in v1. Other fleet services (cohort_algo, position_manager, weather, crypto) never send to Telegram directly; they publish to thetelegram.outboundNATS subject and this service relays. Repo:/Users/levander/coding/pmv2/telegram_connector(own git repo + cargo workspace).
Boundary & Role
- Single-purpose: durably consume the
telegram.outboundJetStream subject and relay each message to Bot APIsendMessage. - Sole holder of the bot token; sole Telegram sender → single rate-limit authority for the fleet.
- No trading logic, no other service’s DB, stateless v1 (no
SUPABASE_DB_URL). - Dumb, faithful relay: passes
textthrough verbatim with the publisher-suppliedparse_mode. Escaping/formatting is the publisher’s job — the connector never re-escapes.
v1 Scope: OUTBOUND ONLY
In scope: durable JetStream pull consumer on subject telegram.outbound (stream TELEGRAM_OUTBOUND) → Telegram Bot API sendMessage.
Out of scope (deferred to a separate later spec): inbound (getUpdates → auth → ops.commands / ops.results relay). The inbound spec is gated on @positionmanager locking the ops.* contract (babylon #pmv2 #311). Also out of scope: proactive rate limiting, persistence/ops state, multiple bot tokens, non-sendMessage methods (photos, edits).
The Wire — telegram.outbound Envelope
Deserialize-only in this service. Matched field-for-field to the reference publisher at cohort_algo/crates/cohort_algo/src/telegram_outbound.rs until the shared pmv2-contracts standalone repo publishes (then vendor it).
TelegramOutbound {
msg_id: String, // idempotency key = Nats-Msg-Id; stream dedup within duplicate_window
chat: Option<String>, // None => default TELEGRAM_CHAT_ID
text: String, // already-formatted body — relayed verbatim
parse_mode: Option<String>, // "HTML" | "MarkdownV2" | null
kind: Option<String>, // "info" | "alert" | "trade" — advisory only (log attribute)
}- Subject
telegram.outbound→ streamTELEGRAM_OUTBOUND(1d retention, 5-minduplicate_window), provisioned by @deploy. - Dedup is the stream’s job (keyed on
Nats-Msg-Id=msg_id). The connector keeps no dedup state.
Delivery Semantics
At-least-once. Ack only AFTER a confirmed send (or a deliberate terminal drop).
| Outcome | Triggers | Policy |
|---|---|---|
Sent | HTTP 200, ok: true | ack |
Transient | 429 (+retry_after), 5xx, network/timeout | in-process retry (honor retry_after else backoff); if still failing → no ack → JetStream redelivers, bounded by max_deliver; on exhaustion → dead-letter + ERROR |
Terminal | 400 (bad format / chat not found), 403 (bot blocked) | drop + ack (avoid head-of-line block & burning redelivery budget) + ERROR log + best-effort meta-alert |
Meta-alert loop guard
On a terminal drop, after structured-logging it, the connector sends one best-effort plain-text meta-alert to
TELEGRAM_CHAT_ID(dropped msg_id=… error_code=… desc=…) so a swallowed alert stays visible. The meta-alert itself has noparse_mode, and a failed meta-alert is only logged, never meta-alerted about. Config-toggleMETA_ALERT_ON_DROP(default on).
Concurrency & Rate Limiting
- Concurrency (v1): sequential — one message in-flight at a time. Low volume, preserves order, trivially respects per-chat limits. Concurrent workers deferred (YAGNI).
- Rate limiting (v1): reactive only — on 429, honor
retry_after. No proactive token bucket until volume proves it necessary (YAGNI).
Testability Decision
The relay loop sits behind a MessageSource trait so it is unit-testable with a fake (no broker required):
- Unit:
outbound(de)serialization parity (golden),telegramerror classification from sample Bot API JSON,configdormant guard,relaypolicy against a fakeMessageSource+ fake telegram client. - Integration: wiremock the Bot API for the full relay; a separate optional test exercises real
async-natsJetStream against a testcontainer (provisions its ownTELEGRAM_OUTBOUND+tg_outbound). - Green gate: build + tests +
clippy -D warnings+fmt+ dormant-safe smoke (unset token →exit(0), no panic).
Module Decomposition
Single bin crate. config (typed env + dormant guard — exit 0 if TELEGRAM_BOT_TOKEN/NATS_URL unset), telemetry (OTLP to local collector), outbound (the envelope type), telegram (reqwest/rustls client, injectable base URL, error classification), consumer (JetStream nkey auth; bind-or-create own durable PULL consumer tg_outbound; does not create the stream), relay (core loop behind MessageSource), main (wire-up + graceful SIGINT/SIGTERM drain).
Conventions
edition 2024, resolver “3”, rust 1.85, unsafe_code = "forbid". Deps: tokio, async-nats (+nkeys), reqwest (rustls), serde/serde_json, anyhow, thiserror, tracing + tracing-opentelemetry + opentelemetry-otlp; dev: wiremock (+ optional testcontainers). No code comments. Mirrors sibling-repo workspace layout. See pmv2-fleet-infra-observability-nats-conventions for the fleet-wide OTel/logging/NATS rules this service follows.
Open Coordination (babylon #pmv2)
- #310 —
pmv2-contractsstandalone repo confirmed (Andras). Vendor its envelope type when it publishes (swap the hand-rolledoutboundtype). - #311 — inbound
ops.*contract with @positionmanager still open; gates the inbound spec, not this one. - Provisioning:
TELEGRAM_OUTBOUND(1d, 5-min dedup) confirmed; outbound consumer istg_outbound(connector-owned).