#!/usr/bin/env python3
"""Passive Telegram bot log collector (P0 pilot, docs/log-source-discovery.md §5.2).

Polls the Bot API getUpdates for every valid token harvested by
tg_bot_harvest.py (findings/data/tg_bots_validated.tsv, status=valid) and
downloads exfiltrated per-victim archives to findings/breaches/TGBOT-<bot_id>/.

Notes:
- Updates live on Telegram servers only 24h — poll continuously, not cron-daily.
- Each update is delivered once (offset); if the operator also polls, the
  stream is shared. We never send/delete anything: pure read-only polling.
- If the operator set a webhook, getUpdates returns 409. We do NOT call
  deleteWebhook automatically — that is an active, operator-visible action
  (operator-gate). The token is flagged webhook_conflict instead.

Usage:
  python3 scripts/tg_bot_collect.py --once          # one poll round, then exit
  python3 scripts/tg_bot_collect.py                 # daemon: poll forever
  python3 scripts/tg_bot_collect.py --interval 20   # seconds between rounds

After collection, run standard intake:
  python3 scripts/intake_local.py --extracted findings/breaches/TGBOT-<id> --source TGBOT-<id>
"""
import argparse
import csv
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path

SCRIPT_DIR = Path(__file__).parent
PROJECT_DIR = SCRIPT_DIR.parent
DATA_DIR = PROJECT_DIR / "findings" / "data"
BREACH_DIR = PROJECT_DIR / "findings" / "breaches"
IN_TSV = DATA_DIR / "tg_bots_validated.tsv"
STATE_JSON = DATA_DIR / "tg_bots_state.json"

TG_API = "https://api.telegram.org/bot{token}/{method}"
TG_FILE = "https://api.telegram.org/file/bot{token}/{path}"
MAX_FILE_SIZE = 50 * 1024 * 1024  # skip monsters; stealer ZIPs avg <1MB
UA = {"User-Agent": "tg-bot-collector/0.1"}


def now_iso() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def load_tokens() -> list[dict]:
    """Valid tokens from harvest TSV: [{token, bot_id, bot_username, chat_id}]."""
    if not IN_TSV.exists():
        sys.exit(f"ERROR: {IN_TSV} not found — run tg_bot_harvest.py first")
    tokens = {}
    with IN_TSV.open() as f:
        for row in csv.DictReader(f, delimiter="\t"):
            if row["status"] == "valid":
                tokens[row["token"]] = row
    return [{"token": t, "bot_id": r["bot_id"], "bot_username": r["bot_username"],
             "chat_id": r["chat_id"]} for t, r in sorted(tokens.items())]


def load_state() -> dict:
    if STATE_JSON.exists():
        return json.loads(STATE_JSON.read_text())
    return {}


def save_state(state: dict):
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    STATE_JSON.write_text(json.dumps(state, indent=1))


def api_call(token: str, method: str, params: dict | None = None,
             timeout: int = 30) -> dict:
    url = TG_API.format(token=token, method=method)
    if params:
        url += "?" + urllib.parse.urlencode(params)
    req = urllib.request.Request(url, headers=UA)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return json.loads(r.read())
    except urllib.error.HTTPError as e:
        try:
            return json.loads(e.read())
        except Exception:
            return {"ok": False, "error_code": e.code, "description": str(e)}
    except Exception as e:
        return {"ok": False, "description": str(e)}


def safe_name(name: str) -> str:
    return re.sub(r"[^A-Za-z0-9._-]+", "_", name)[:120] or "file"


def download_file(token: str, file_path: str, dest: Path) -> bool:
    url = TG_FILE.format(token=token, path=file_path)
    req = urllib.request.Request(url, headers=UA)
    try:
        with urllib.request.urlopen(req, timeout=120) as r:
            dest.parent.mkdir(parents=True, exist_ok=True)
            with dest.open("wb") as f:
                while True:
                    chunk = r.read(1 << 20)
                    if not chunk:
                        break
                    f.write(chunk)
        return True
    except Exception as e:
        print(f"      download failed: {e}")
        return False


def process_update(token: str, entry: dict, upd: dict, stats: dict,
                   st: dict | None = None):
    bot_dir = BREACH_DIR / f"TGBOT-{entry['bot_id'] or 'unknown'}"
    uid = upd["update_id"]

    # raw audit trail (no-masking: full update JSON)
    bot_dir.mkdir(parents=True, exist_ok=True)
    with (bot_dir / f"{uid}_raw.json").open("w") as f:
        json.dump(upd, f, ensure_ascii=False, indent=1)

    # my_chat_member: bot added/promoted in a chat — discovers exfil group IDs
    mcm = upd.get("my_chat_member")
    if mcm:
        chat = mcm.get("chat") or {}
        new_status = (mcm.get("new_chat_member") or {}).get("status", "")
        disc = (st or {}).setdefault("discovered_chats", {})
        disc[str(chat.get("id"))] = {
            "title": chat.get("title"), "type": chat.get("type"),
            "status": new_status, "date": mcm.get("date"),
        }
        stats["msgs"] += 1
        print(f"      [chat_discovery] bot {new_status} in "
              f"{chat.get('type')} {chat.get('id')} {chat.get('title')!r}")
        return

    msg = upd.get("message") or upd.get("channel_post") or {}
    if not msg:
        return
    date = msg.get("date", 0)
    chat = (msg.get("chat") or {}).get("id", "")

    doc = msg.get("document")
    if doc:
        if doc.get("file_size", 0) > MAX_FILE_SIZE:
            print(f"      skip oversized {doc.get('file_name')} ({doc['file_size']} bytes)")
            return
        gf = api_call(token, "getFile", {"file_id": doc["file_id"]})
        if not gf.get("ok"):
            print(f"      getFile failed: {gf.get('description')}")
            return
        fname = safe_name(doc.get("file_name", "payload.bin"))
        dest = bot_dir / f"{date}_{uid}_{fname}"
        if download_file(token, gf["result"]["file_path"], dest):
            stats["files"] += 1
            print(f"      saved {dest.relative_to(PROJECT_DIR)} "
                  f"({doc.get('file_size', '?')} bytes, chat {chat})")

    text = msg.get("text") or msg.get("caption") or ""
    if text.strip():
        bot_dir.mkdir(parents=True, exist_ok=True)
        with (bot_dir / f"{date}_{uid}_msg.txt").open("w") as f:
            f.write(text)
        stats["msgs"] += 1


def poll_round(tokens: list[dict], state: dict, long_timeout: int = 5) -> dict:
    stats = {"files": 0, "msgs": 0, "updates": 0}
    for entry in tokens:
        token = entry["token"]
        st = state.setdefault(token, {"offset": 0, "errors": 0,
                                      "bot_id": entry["bot_id"],
                                      "bot_username": entry["bot_username"]})
        if st.get("disabled"):
            continue
        resp = api_call(token, "getUpdates",
                        {"offset": st["offset"], "timeout": long_timeout,
                         "allowed_updates": json.dumps(
                             ["message", "channel_post", "my_chat_member"])},
                        timeout=long_timeout + 15)
        if not resp.get("ok"):
            code = resp.get("error_code")
            desc = resp.get("description", "")
            if code == 401:
                st["disabled"] = "dead_401"
                print(f"  [-] bot {entry['bot_username'] or entry['bot_id']}: 401 — dead, removed")
            elif code == 409:
                st["disabled"] = "webhook_conflict"
                print(f"  [!] bot {entry['bot_username'] or entry['bot_id']}: 409 — operator "
                      f"webhook active; getUpdates blocked (deleteWebhook is operator-gated)")
            elif code == 429:
                wait = (resp.get("parameters") or {}).get("retry_after", 30)
                print(f"  [!] bot {entry['bot_username'] or entry['bot_id']}: 429 — sleep {wait}s")
                time.sleep(wait)
            else:
                st["errors"] = st.get("errors", 0) + 1
                print(f"  [!] bot {entry['bot_username'] or entry['bot_id']}: {desc}")
            continue
        st["errors"] = 0
        updates = resp.get("result") or []
        if updates:
            print(f"  [+] bot @{entry['bot_username'] or entry['bot_id']}: "
                  f"{len(updates)} update(s)")
        for upd in updates:
            st["offset"] = upd["update_id"] + 1
            stats["updates"] += 1
            process_update(token, entry, upd, stats, st)
        st["last_poll"] = now_iso()
    return stats


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("--once", action="store_true", help="single poll round, then exit")
    ap.add_argument("--interval", type=float, default=30,
                    help="seconds between poll rounds (daemon mode)")
    ap.add_argument("--long-timeout", type=int, default=5,
                    help="Bot API long-poll timeout per token")
    args = ap.parse_args()

    tokens = load_tokens()
    if not tokens:
        sys.exit("ERROR: no valid tokens in tg_bots_validated.tsv")
    print(f"[*] {len(tokens)} valid token(s) loaded")
    state = load_state()

    if args.once:
        stats = poll_round(tokens, state, args.long_timeout)
        save_state(state)
        print(f"[+] round done: {stats['updates']} updates, "
              f"{stats['files']} files, {stats['msgs']} messages")
        return 0

    print(f"[*] daemon mode, interval {args.interval}s (Ctrl-C to stop)")
    try:
        while True:
            stats = poll_round(tokens, state, args.long_timeout)
            save_state(state)
            if stats["updates"]:
                print(f"  [=] {now_iso()} +{stats['updates']} updates "
                      f"({stats['files']} files, {stats['msgs']} msgs)")
            time.sleep(args.interval)
    except KeyboardInterrupt:
        save_state(state)
        print("\n[*] stopped, state saved")
    return 0


if __name__ == "__main__":
    sys.exit(main())
