#!/usr/bin/env python3
"""
Telegram bridge — @Wazier_bot routed through the Chairman agent.

Replaces sovereign-org-engine/bot.py. Long-polls the Telegram API (the same
mode the org engine used), authorizes 19Keys only, and routes every message
through agent_loop() so Telegram gets the same brain, tools, and living
memory (sovereign-wiki/memory/CHAIRMAN-MEMORY.md) as iMessage and heartbeat.
mempal.md is NOT read or written — Chairman memory is the single memory.

The 32-agent roster from config/agents.py is preserved:
  plain text        Chairman decides; delegates to roster personas as needed
  /agent ID task    run one roster persona directly (charter from the roster)
  /agents           list the roster by tier
  /pulse            live empire pulse (revenue, tasks, leaks) — org-engine port
  /musa ...         Mansu Musa on demand (audit/agents/leaks/brief/status);
                    the AUTONOMOUS cycle stays retired
  /costs, /unlock   cost_guard.py budget report + cutoff reset — org-engine port
  /update           full empire update (org + Neo + vault + costs + Ollama)
  /new              fresh conversation
  /status           quick local status

Guards carried over from the org engine:
  cost_guard.py     same monthly caps + 80% auto-cutoff; Anthropic calls are
                    metered by wrapping agent.api_call, and a tripped cutoff
                    HARD-STOPS new API calls until /unlock
  agent_bus.py      fail-soft Supabase writes (agent_tasks, agent_decisions,
                    agent_reports) so the org dashboards keep reading fresh rows

Modes:
  python3 telegram_bridge.py                 run the polling bridge
  python3 telegram_bridge.py --test "msg"    route one message and print the
                                             reply — never touches Telegram
  python3 telegram_bridge.py --roster        print the loaded roster

Survives network loss: every Telegram call retries with backoff instead of
exiting. (The org engine bot died with exit 1 whenever DNS was down at boot
— python-telegram-bot's bootstrap aborts on NetworkError — and crash-looped
under KeepAlive. This bridge waits the outage out instead.)
"""
import argparse
import importlib.util
import json
import logging
import os
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

ROOT = Path(__file__).resolve().parent
REPO = ROOT.parent
LOG_DIR = ROOT / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
OFFSET_FILE = LOG_DIR / ".telegram_offset"
CHAT_TRANSCRIPT = LOG_DIR / "telegram-chat.log"
ROSTER_FILE = ROOT / "config" / "agents.py"
# Telegram creds historically live only in the org engine's .env; read it as
# a fallback so cutover needs no env surgery. content-os/config/.env wins.
ORG_ENGINE_ENV = REPO / "sovereign-org-engine" / "config" / ".env"

sys.path.insert(0, str(ROOT))
import agent  # noqa
import agent_bus as bus  # noqa
import cost_guard  # noqa
from agent import agent_loop, load_env  # noqa
from prompt import SYSTEM_PROMPT  # noqa

load_env()

# ------------------------------------------------------------- cost metering
# Wrap agent.api_call so every Anthropic round-trip the bridge triggers is
# priced into cost_guard (agent_loop resolves api_call from module globals at
# call time, so this covers the whole tool loop). If the cutoff trips
# mid-run, the wrapper raises CutoffError — a hard stop, slightly stricter
# than the org engine, which only checked between conversations.
_unmetered_api_call = agent.api_call


def _metered_api_call(messages, system):
    if cost_guard.is_cutoff_active():
        raise cost_guard.CutoffError("budget cutoff active — /unlock to resume")
    resp = _unmetered_api_call(messages, system)
    try:
        u = resp.get("usage") or {}
        cost_guard.log_api_call(
            "anthropic", agent.MODEL,
            int(u.get("input_tokens") or 0), int(u.get("output_tokens") or 0),
        )
    except Exception:
        logging.getLogger("chairman-telegram").warning(
            "cost metering failed", exc_info=True)
    return resp


agent.api_call = _metered_api_call

CUTOFF_TEXT = (
    "BUDGET CUTOFF ACTIVE — paid API calls are paused "
    "(80% of the monthly cap reached).\n"
    "/costs for the report · /unlock to resume paid APIs."
)


def load_org_engine_env():
    if not ORG_ENGINE_ENV.exists():
        return
    for line in ORG_ENGINE_ENV.read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, v = line.split("=", 1)
        k = k.strip()
        if k.startswith("TELEGRAM") and not os.environ.get(k):
            os.environ[k] = v.strip().strip('"').strip("'")


load_org_engine_env()
TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
USER_ID = int(os.environ.get("TELEGRAM_USER_ID", "0") or 0)
API = f"https://api.telegram.org/bot{TOKEN}"

POLL_TIMEOUT = 50       # long-poll hold, seconds
CHUNK = 4000            # Telegram message limit is 4096
HISTORY_LIMIT = 24      # rolling turns kept per chat (12 exchanges)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler(LOG_DIR / "telegram.log"),
        logging.StreamHandler(),
    ],
)
log = logging.getLogger("chairman-telegram")

# Per-chat rolling conversation history: chat_id -> [{"role", "content"}]
conversations = {}


# ------------------------------------------------------------------- roster
def load_roster():
    spec = importlib.util.spec_from_file_location("chairman_roster", ROSTER_FILE)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod.AGENT_PROMPTS


ROSTER = load_roster()
TIER_ORDER = ["executive", "oversight", "operations", "intelligence",
              "revenue", "community", "product"]


def roster_lines():
    by_tier = {}
    for agent_id, a in ROSTER.items():
        by_tier.setdefault(a.get("tier", "other"), []).append(
            f"  {agent_id} — {a.get('name', agent_id)}"
        )
    lines = []
    for tier in TIER_ORDER + sorted(set(by_tier) - set(TIER_ORDER)):
        if tier in by_tier:
            lines.append(f"{tier.upper()}:")
            lines.extend(by_tier[tier])
    return lines


ROUTING_ADDENDUM = (
    "\n\nTELEGRAM CHANNEL + AGENT ROSTER:\n"
    "This message arrived from 19Keys over Telegram (@Wazier_bot). Reply in "
    "plain text — no markdown headers, no tables; short lines that read well "
    "on a phone. You are also the router for the sovereign organization's "
    f"{len(ROSTER)}-agent roster:\n\n"
    + "\n".join(roster_lines())
    + "\n\nROUTING SEMANTICS (carried over from the org engine):\n"
    "- When a directive belongs to a specialist, adopt that agent's charter "
    "and answer AS that agent, prefixing the reply with its tag, e.g. "
    "[RESEARCH_AGENT]. Full charters live in "
    f"{ROSTER_FILE} — read the one you need with read_file.\n"
    "- Creative work (visual, video, design, branding, motion) routes "
    "PHILOSOPHY_CEO first, then TASTE_MANAGER (veto power), then "
    "BRAND_DESIGNER, then production (CREATIVE_DESIGNER / FILMMAKER / "
    "MOTION_DESIGNER). Never straight to production.\n"
    "- Oversight agents (QUALITY_AGENT, VOICE_GUARD, COMPLIANCE_AGENT) gate "
    "anything public-facing before it ships.\n"
    "- You keep your own memory: CHAIRMAN-MEMORY.md, not mempal.md."
)

PERSONA_NOTE = (
    "\n\nEXECUTION CONTEXT: You are running inside the Chairman agent's body "
    "on 19Keys' Mac with real tools (bash, read_file, write_file, "
    "str_replace, web_search). Do the work, not a plan for the work. Your "
    "reply goes straight to 19Keys on Telegram — plain text, phone-readable."
)


# ----------------------------------------------------------------- telegram
class TelegramError(Exception):
    pass


def tg(method, payload=None, timeout=35):
    req = urllib.request.Request(
        f"{API}/{method}",
        data=json.dumps(payload or {}).encode(),
        headers={"content-type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=timeout) as r:
        resp = json.loads(r.read())
    if not resp.get("ok"):
        raise TelegramError(f"{method}: {resp.get('description')}")
    return resp.get("result")


def send_text(chat_id, text):
    text = (text or "(no reply)").strip() or "(no reply)"
    for i in range(0, len(text), CHUNK):
        tg("sendMessage", {"chat_id": chat_id, "text": text[i:i + CHUNK]})


def load_offset():
    try:
        return int(OFFSET_FILE.read_text().strip())
    except Exception:
        return 0


def save_offset(offset):
    try:
        OFFSET_FILE.write_text(str(offset))
    except Exception:
        log.warning("could not persist offset %s", offset)


# ------------------------------------------------------------------ routing
def route_message(chat_id, text):
    """Plain text -> Chairman decision path, with rolling conversation."""
    history = conversations.setdefault(chat_id, [])
    if history:
        recent = "\n".join(
            f"{h['role'].upper()}: {h['content'][:600]}" for h in history
        )
        user_msg = (
            f"RECENT CONVERSATION (rolling, oldest first):\n{recent}\n\n"
            f"NEW MESSAGE FROM 19KEYS:\n{text}"
        )
    else:
        user_msg = text
    reply = agent_loop(
        user_msg,
        system=SYSTEM_PROMPT + ROUTING_ADDENDUM,
        log_path=CHAT_TRANSCRIPT,
    )
    history.append({"role": "user", "content": text})
    history.append({"role": "assistant", "content": reply or ""})
    if len(history) > HISTORY_LIMIT:
        conversations[chat_id] = history[-HISTORY_LIMIT:]
    # Decision log — same write org-engine's process_command did.
    bus.log_decision("CHAIRMAN_AI", "command_response", text, reply,
                     "Processed through Chairman agent (Telegram bridge)")
    return reply


def run_persona(agent_id, task, report_type=None):
    """/agent ID task -> run one roster persona directly.

    Mirrors org-engine's delegate_to_agent ledger: create agent_tasks row,
    run, complete or fail it. All Supabase writes are fail-soft.
    """
    persona = ROSTER[agent_id]
    task_id = bus.create_task(agent_id, "CHAIRMAN_AI", task[:100], task)
    try:
        reply = agent_loop(
            task,
            system=persona["system"] + PERSONA_NOTE,
            log_path=CHAT_TRANSCRIPT,
        )
    except Exception as e:
        bus.fail_task(task_id, e)
        raise
    bus.complete_task(task_id, reply)
    if report_type:
        bus.submit_report(agent_id, report_type, status="GREEN",
                          notes=reply)
    return f"[{agent_id}]\n{reply}"


# ------------------------------------------- one-tap commands (org-engine port)
def _fmt_money(n):
    if n >= 1_000_000:
        return f"${n / 1_000_000:.1f}M"
    if n >= 1_000:
        return f"${n / 1_000:.0f}K"
    return f"${n:.0f}"


def cmd_pulse():
    """Port of org-engine /pulse — revenue, tasks, leaks at a glance."""
    streams = bus.get_revenue_streams()
    tasks = bus.get_tasks_snapshot()
    last_report = bus.get_latest_report()

    total_target = sum(float(r.get("target_annual", 0) or 0) for r in streams)
    total_current = sum(float(r.get("current_annual", 0) or 0) for r in streams)
    capture = round(total_current / total_target * 100, 1) if total_target else 0
    gap = total_target - total_current
    dormant = sum(1 for s in streams if s.get("status") == "dormant")
    build = sum(1 for s in streams if s.get("status") == "build")

    total_tasks = len(tasks)
    completed = sum(1 for t in tasks if t.get("status") == "completed")
    comp_rate = round(completed / total_tasks * 100) if total_tasks else 0

    audit_status = last_report["status"] if last_report else "NONE"
    icon = "OK" if capture >= 50 else ("WATCH" if capture >= 20 else "RED")

    lines = [f"CHAIRMAN PULSE [{icon}]", "",
             "REVENUE",
             f"  Target: {_fmt_money(total_target)}/yr",
             f"  Captured: {_fmt_money(total_current)}/yr",
             f"  Gap: {_fmt_money(gap)}",
             f"  Capture rate: {capture}%", "",
             "TASKS",
             f"  Completion: {comp_rate}% ({completed}/{total_tasks})", "",
             "LEAKS",
             f"  Dormant streams: {dormant}",
             f"  Unbuilt streams: {build}", "",
             f"LAST AUDIT: {audit_status}", "", "STREAMS"]
    for s in streams:
        cur = float(s.get("current_annual", 0) or 0)
        name = (s.get("stream_name") or "?").replace("_", " ").title()
        lines.append(f"  [{s.get('status', '?')}] {name}: {_fmt_money(cur)}")
    if not streams:
        lines.append("  (revenue_tracking unreachable or empty)")
    return "\n".join(lines)


def cmd_costs():
    """Port of org-engine /costs — cost_guard report with usage bars."""
    r = cost_guard.get_cost_report()

    def bar(pct):
        filled = min(10, int(pct / 10))
        return "#" * filled + "-" * (10 - filled)

    lines = [f"API COST REPORT — {r['month']}", ""]
    labels = {"anthropic": "Anthropic (Claude)", "openai": "OpenAI",
              "openrouter": "OpenRouter (GLM)", "perplexity": "Perplexity"}
    for p, label in labels.items():
        row = r[p]
        lines += [label,
                  f"  ${row['cost']:.2f} / ${row['budget']} ({row['usage_pct']}%)",
                  f"  {bar(row['usage_pct'])} {row['calls']} calls", ""]
    lines += [f"Ollama (local/FREE): {r['ollama']['calls']} calls", "",
              f"Total paid: ${r['total_paid']:.2f} / ${r['total_budget']} "
              f"({r['total_usage_pct']}%)"]
    if r["cutoff_active"]:
        lines += ["", "CUTOFF ACTIVE — paid APIs paused. /unlock to resume."]
    return "\n".join(lines)


def cmd_update():
    """Port of org-engine /update — org + Neo + vault + costs + Ollama."""
    parts = []

    s = bus.get_system_status()
    parts.append("ORG ENGINE\n"
                 f"Status: {s['status']}\n"
                 f"Agents: {s['agents_active']}/{s['agents']} active\n"
                 f"Open tasks: {s['open_tasks']}\n"
                 f"Escalations: {s['open_escalations']}\n"
                 f"Reports: {s['latest_reports']}")

    try:
        with urllib.request.urlopen("http://localhost:8819/api/status",
                                    timeout=5) as r:
            neo = json.loads(r.read())
        layers = neo.get("layers", {})
        active = sum(1 for l in layers.values() if l.get("status") == "active")
        idle = sum(1 for l in layers.values() if l.get("status") == "idle")
        layer_lines = "".join(
            f"  [{i.get('status', '?')}] {n}: {i.get('detail', i.get('status'))}\n"
            for n, i in layers.items())
        parts.append(f"NEO (OpenClaw)\n"
                     f"Status: {'ACTIVE' if active else 'IDLE'} "
                     f"({active} active, {idle} idle)\n"
                     f"System: {neo.get('system', 'unknown')}\n"
                     f"Outputs: {neo.get('outputs', 0)}\n{layer_lines}")
    except Exception as e:
        parts.append(f"NEO (OpenClaw)\nOffline or unreachable: {e}")

    try:
        vault_root = REPO / "content-os" / "obsidian_vault"
        vault_count = sum(1 for _ in vault_root.rglob("*.md"))
        ip_count = sum(1 for _ in (vault_root / "IP-Library").rglob("*.md"))
        parts.append("VAULT\n"
                     f"Total notes: {vault_count}\n"
                     f"IP Library: {ip_count} entries\n"
                     "HLC Transcripts: 59 episodes\n"
                     "Voice Data: 4.1M+ words")
    except Exception as e:
        parts.append(f"VAULT\nError: {e}")

    c = cost_guard.get_cost_report()
    cost_line = (f"COSTS ({c['month']})\n"
                 f"Anthropic: ${c['anthropic']['cost']:.2f}/"
                 f"{c['anthropic']['budget']} ({c['anthropic']['calls']} calls)\n"
                 f"OpenAI: ${c['openai']['cost']:.2f}/"
                 f"{c['openai']['budget']} ({c['openai']['calls']} calls)\n"
                 f"Perplexity: ${c['perplexity']['cost']:.2f}/"
                 f"{c['perplexity']['budget']} ({c['perplexity']['calls']} calls)\n"
                 f"Ollama: FREE ({c['ollama']['calls']} calls)\n"
                 f"Total: ${c['total_paid']:.2f}/${c['total_budget']} "
                 f"({c['total_usage_pct']}%)")
    if c["cutoff_active"]:
        cost_line += "\nCUTOFF ACTIVE"
    parts.append(cost_line)

    try:
        with urllib.request.urlopen("http://localhost:11434/api/tags",
                                    timeout=5) as r:
            models = json.loads(r.read()).get("models", [])
        parts.append(f"OLLAMA\n{len(models)} models: "
                     + ", ".join(m["name"] for m in models))
    except Exception:
        parts.append("OLLAMA\nOffline")

    return "SOVEREIGN EMPIRE — FULL UPDATE\n\n" + "\n\n".join(parts)


MUSA_HELP = (
    "MANSU MUSA — on-demand commands\n"
    "/musa audit — full revenue audit\n"
    "/musa agents — agent ROI scorecard\n"
    "/musa leaks — money leak scan\n"
    "/musa brief — cash flow intelligence brief\n"
    "/musa status — Mansu Musa status\n"
    "(autonomous cycle retired at cutover — every run is on demand)"
)

MUSA_TASKS = {
    "audit": "Run a full revenue audit across every 19Keys revenue stream. "
             "Pull real numbers (Supabase revenue_tracking, Shopify via bash "
             "if reachable, CRM stats). Flag dormant and underperforming "
             "streams with dollar amounts.",
    "agents": "Produce the agent ROI scorecard: for each roster agent, what "
              "revenue or leverage has it produced vs its cost? Use the "
              "agent_tasks ledger in Supabase for evidence. Rank them.",
    "leaks": "Run a money leak scan: dormant revenue streams, unbilled work, "
             "subscription overlap, expired funnels, stale offers. Quantify "
             "each leak in dollars and order by size.",
    "brief": "Write the cash flow intelligence brief: current monthly "
             "revenue vs target, trajectory, the single highest-leverage "
             "revenue move this week. Ground every number in real data.",
}


def cmd_musa(chat_id, args):
    """Port of org-engine /musa — on-demand only; autonomous cycle retired."""
    if not args:
        return MUSA_HELP
    sub = args[0].lower()
    if sub == "status":
        return ("MANSU MUSA STATUS\n"
                "Mode: ON-DEMAND (autonomous cycle retired at cutover)\n"
                "Persona: MANSU_MUSA from config/agents.py\n"
                "Ledger: agent_tasks + agent_reports (Supabase)\n"
                "Run: /musa audit | agents | leaks | brief")
    if sub == "cycle":
        return "Autonomous cycle is retired. Use /musa audit for a one-shot run."
    if sub not in MUSA_TASKS:
        return f"Unknown command: {sub}. Use /musa for help."
    send_text(chat_id, "Mansu Musa working...")
    return run_persona("MANSU_MUSA", MUSA_TASKS[sub],
                       report_type="audit" if sub == "audit" else None)


def local_status():
    inbox = len(list((ROOT / "inbox").glob("*.md")))
    done = len(list((ROOT / "done").glob("*.md")))
    hb = LOG_DIR / "heartbeat.log"
    hb_age = "never"
    if hb.exists():
        mins = int((time.time() - hb.stat().st_mtime) / 60)
        hb_age = f"{mins} min ago"
    return (
        "CHAIRMAN STATUS\n"
        f"Model: {os.environ.get('CHAIRMAN_MODEL', 'claude-sonnet-5')}\n"
        f"Roster: {len(ROSTER)} agents loaded\n"
        f"Inbox tasks: {inbox} · Done: {done}\n"
        f"Last heartbeat log write: {hb_age}\n"
        "Memory: sovereign-wiki/memory/CHAIRMAN-MEMORY.md"
    )


START_TEXT = (
    "CHAIRMAN — Telegram bridge\n"
    f"One brain, every channel. {len(ROSTER)} agent personas on the roster.\n\n"
    "/update — full empire update (org + Neo + vault + costs)\n"
    "/pulse — live empire pulse (revenue, tasks, leaks)\n"
    "/musa — Mansu Musa wealth intelligence (on demand)\n"
    "/costs — API spend and budget report\n"
    "/unlock — resume paid APIs after cutoff\n"
    "/agents — list the roster\n"
    "/agent AGENT_ID task — run one persona directly\n"
    "/status — bridge + agent status\n"
    "/new — fresh conversation\n\n"
    "Or just say what you need. I route it."
)

# Commands that trigger paid API calls and must respect the cutoff.
API_COMMANDS = {"/agent", "/musa"}


def handle_command(chat_id, text):
    """Returns a reply string, or None if the message is not a command."""
    if not text.startswith("/"):
        return None
    parts = text.split(maxsplit=2)
    cmd = parts[0].split("@")[0].lower()
    if cmd == "/start":
        return START_TEXT
    if cmd == "/new":
        conversations[chat_id] = []
        return "Fresh conversation. What's the directive, Chairman?"
    if cmd == "/agents":
        return "AGENT ROSTER\n" + "\n".join(roster_lines())
    if cmd == "/status":
        return local_status()
    if cmd == "/pulse":
        return cmd_pulse()
    if cmd == "/costs":
        return cmd_costs()
    if cmd == "/update":
        send_text(chat_id, "Pulling full empire status...")
        return cmd_update()
    if cmd == "/unlock":
        cost_guard.reset_cutoff()
        return "Paid APIs unlocked. Budget cutoff reset."
    if cmd == "/musa":
        return cmd_musa(chat_id, parts[1:])
    if cmd == "/agent":
        if len(parts) < 3:
            return "Usage: /agent AGENT_ID task\nSee /agents for IDs."
        agent_id = parts[1].upper()
        if agent_id not in ROSTER:
            return f"Unknown agent: {agent_id}. See /agents."
        send_text(chat_id, f"[{agent_id}] working...")
        return run_persona(agent_id, parts[2])
    return f"Unknown command: {cmd}. See /start."


def handle_update(update):
    msg = update.get("message") or {}
    text = (msg.get("text") or "").strip()
    chat_id = (msg.get("chat") or {}).get("id")
    uid = (msg.get("from") or {}).get("id")
    if not text or chat_id is None:
        return
    if uid != USER_ID:
        log.warning("unauthorized sender: %s", uid)
        send_text(chat_id, "Unauthorized. This organization reports to 19Keys only.")
        return
    log.info("inbound: %s", text[:120])
    # Budget hard stop: API-calling routes are refused while the cutoff is
    # active. (org-engine degraded to Ollama here; the Chairman has no local
    # fallback, so the port refuses with instructions instead.)
    needs_api = (not text.startswith("/")
                 or text.split(maxsplit=1)[0].split("@")[0].lower() in API_COMMANDS)
    if needs_api and cost_guard.is_cutoff_active():
        send_text(chat_id, CUTOFF_TEXT)
        return
    try:
        reply = handle_command(chat_id, text)
        if reply is None:
            send_text(chat_id, "Processing...")
            reply = route_message(chat_id, text)
    except cost_guard.CutoffError:
        reply = CUTOFF_TEXT
    except Exception as e:
        log.exception("routing error")
        reply = f"(agent error: {e})"
    send_text(chat_id, reply)
    log.info("replied %d chars", len(reply or ""))


# --------------------------------------------------------------------- loop
def poll_forever():
    if not TOKEN or not USER_ID:
        print("ERROR: TELEGRAM_BOT_TOKEN / TELEGRAM_USER_ID not set "
              f"(checked env, content-os/config/.env, {ORG_ENGINE_ENV})",
              file=sys.stderr)
        sys.exit(1)
    offset = load_offset()
    backoff = 5
    log.info("Chairman Telegram bridge live — %d personas, offset %d",
             len(ROSTER), offset)
    while True:
        try:
            updates = tg(
                "getUpdates",
                {"offset": offset, "timeout": POLL_TIMEOUT,
                 "allowed_updates": ["message"]},
                timeout=POLL_TIMEOUT + 15,
            )
            backoff = 5
        except urllib.error.HTTPError as e:
            if e.code == 409:
                log.warning("409 Conflict — another poller owns this bot "
                            "token (org engine still running?). Waiting 30s.")
                time.sleep(30)
                continue
            log.warning("poll HTTP %s — retry in %ds", e.code, backoff)
            time.sleep(backoff)
            backoff = min(backoff * 2, 300)
            continue
        except Exception as e:
            # DNS down, wifi off, Telegram unreachable: wait it out. Never exit.
            log.warning("poll failed: %s — retry in %ds", e, backoff)
            time.sleep(backoff)
            backoff = min(backoff * 2, 300)
            continue
        for update in updates:
            offset = update["update_id"] + 1
            save_offset(offset)
            try:
                handle_update(update)
            except Exception:
                log.exception("update handling failed")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--test", help="route one message locally and print the "
                                   "reply (no Telegram calls)")
    ap.add_argument("--roster", action="store_true", help="print the roster")
    args = ap.parse_args()

    if args.roster:
        print(f"{len(ROSTER)} agents from {ROSTER_FILE}\n")
        print("\n".join(roster_lines()))
        return
    if args.test:
        print(route_message(0, args.test))
        return
    poll_forever()


if __name__ == "__main__":
    main()
