# M3 Verification — Telegram delivery + bot commands

This is the live smoke test for the M3 milestone
(**Telegram delivery + bot commands**, SPEC §8). It exercises
the new path end-to-end:

1. `telegram_bots` table (one bot per company in M3)
2. `individuals.telegram_chat_id` + `telegram_user_id` +
   `telegram_invite_code` + `mute_until`
3. **Resolver** resolves channel='telegram' targets from the
   same subscription set when `channel_mask` contains
   'telegram' AND the individual has a non-null
   `telegram_chat_id`
4. **`telegramd`** — long-polling bot loop that handles
   `/start`, `/subscribe`, `/unsubscribe`, `/preferences`,
   `/status`, `/mute`, `/unmute`
5. **`deliverd-telegram`** — per-channel delivery worker that
   consumes `deliveries.telegram.<company_id>` and posts to
   the Bot API (`sendMessage`)
6. **`faketgmd`** — fake Bot API server for the smoke test
7. **Channel split** — `deliverd` was renamed to `deliverd-fcm`
   (per the user's M3 Q2 answer: two binaries, not a registry)
8. **Smoke flow** — bot commands drive the test; alerts
   drive the delivery check; faketgmd `/admin/sent` proves
   the messages actually went out

Hard-fail: if the resolver returns zero targets, routerd drops
the alert with a log line. Same as M2.

## Prerequisites

- Stack is up: `docker compose up -d`
- Migrations applied: `migrations/004_telegram.up.sql` is part
  of the seed sidecar's `*.up.sql` walk
- Seed has been run: `docker compose up seed` (apply M1, M2,
  M3 migrations + all three seeds)
- HMAC secret for the `acme-001:prom-prod` source is
  `s3cret-acme` (matches `BA_INGESTD_SOURCES` env)
- faketgmd is reachable on `:8830` (host port mapped from
  the compose)

## Seed summary (M3 additions on top of M2)

| individual | telegram_chat_id | telegram_user_id | telegram_invite_code | notes |
|---|---|---|---|---|
| ind-acme-001 (Alice) | 1001 | 900001 | (burned) | **pre-linked** in `seed_m3.sql` so the very first POST exercises both fcm and telegram delivery paths |
| ind-acme-002 (Bob)   | NULL | NULL | `acme-bob-002` | unlinked at start; linked via `/start <code>` in step 4 |
| ind-acme-003 (Carol) | NULL | NULL | `acme-carol-003` | **never linked** in M3 — exercises "telegram skipped when no chat_id" path; quiet hours 00:00–23:59 UTC keep her out of non-inminent alerts |

Subscriptions: all three individuals now have
`channel_mask = ["fcm","telegram"]` (M2 had just `["fcm"]`).
Bob's `min_severity` is `critical` (M2 default).

One telegram bot per company: `(acme-001, "primary")` with
token `fake-tg-bot-token-acme-001`.

## Automated smoke

```bash
bash scripts/m3_smoke.sh
```

The script:
1. **Resets state** — Bob is unlinked, his subscription is
   restored to `min_severity=critical`, the `deliveries`
   table is truncated, and `faketgmd` is reset. **It also
   `docker compose restart telegramd`** so the bot's
   in-memory long-poll offset clears (see "Known quirks"
   below).
2. Walks steps 2–8 below and asserts the expected row counts.
3. Exits 0 on success, $fail_count on failure.

## Step-by-step (also documented in `M3_SMOKE_LOG.md`)

### Step 1 — confirm M3 seed applied

```bash
docker exec -i broad-announce-postgres-1 psql -U ba -d ba -c "
SELECT id, full_name, telegram_chat_id, telegram_user_id, telegram_invite_code, mute_until
  FROM individuals
 ORDER BY id;
"
```

Expected: 3 rows. Alice has `telegram_chat_id=1001`,
`telegram_user_id=900001`, no invite code (she was already
linked). Bob and Carol have NULL chat_id/user_id and a
non-null invite code.

### Step 2 — send a `warning` storage alert

```bash
SECRET=s3cret-acme
BODY='{"company_id":"acme-001","source_id":"prom-prod","severity":"warning","category":"storage","title":"Disk 80% full","body":"db-prod-04","data":{"host":"db-prod-04"},"dedupe_key":"m3-s2"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')

curl -s -X POST http://localhost:8800/v1/ingest \
  -H "Content-Type: application/json" \
  -H "X-BA-Signature: t=$TS,v1=$SIG" \
  --data "$BODY"
```

Wait 4s, then check `deliveries`:

```bash
docker exec -i broad-announce-postgres-1 psql -U ba -d ba -c "
SELECT individual_id, channel, status FROM deliveries
 ORDER BY id;
"
```

**Expected**: 2 rows — Alice fcm (sent) + Alice telegram
(sent). Bob has no telegram_chat_id (resolver returns no
telegram endpoint for him). Carol is in quiet hours.

### Step 3 — send a `critical` storage alert (Bob unlinked)

```bash
BODY='{"company_id":"acme-001","source_id":"prom-prod","severity":"critical","category":"storage","title":"Disk full","body":"db-prod-04","data":{"host":"db-prod-04"},"dedupe_key":"m3-s3"}'
# same HMAC dance
```

**Expected** (cumulative): 3 more rows this step.
- Alice fcm + Alice telegram
- Bob fcm (Bob's min_severity=critical passes, so he gets fcm)
- Bob telegram is **not** delivered because he is unlinked.

Totals after step 3: fcm=3, telegram=2.

### Step 4 — Bob links his Telegram account

```bash
curl -s -X POST http://localhost:8830/admin/queue \
  -H "Content-Type: application/json" \
  --data '{
    "bot_token": "fake-tg-bot-token-acme-001",
    "user_id":   900002,
    "chat_id":   2002,
    "text":      "/start acme-bob-002",
    "first_name":"Bob SRE"
  }'
```

Wait 4s. The bot (`telegramd`) long-polls faketgmd, sees the
queued update, runs `/start acme-bob-002` through the
handler. The handler:

1. Atomically `UPDATE individuals SET telegram_user_id=900002,
   telegram_chat_id='2002', telegram_invite_code=NULL
   WHERE telegram_invite_code='acme-bob-002'`
2. Fetches Alice's name (oh wait, Bob's name) and replies
   "Linked. Welcome, Bob SRE. …"

**Expected**:

```bash
docker exec -i broad-announce-postgres-1 psql -U ba -d ba -c "
SELECT id, telegram_chat_id, telegram_user_id, telegram_invite_code
  FROM individuals WHERE id='ind-acme-002';"
# ind-acme-002 | 2002 | 900002 | NULL

curl -sS http://localhost:8830/admin/sent | jq '.items[-1]'
# should be a "Linked. Welcome, Bob SRE. …" reply
```

### Step 5 — send another `critical` storage alert

```bash
BODY='{"company_id":"acme-001","source_id":"prom-prod","severity":"critical","category":"storage","title":"Disk full","body":"db-prod-04","data":{"host":"db-prod-04"},"dedupe_key":"m3-s5"}'
```

**Expected** (cumulative): 4 more rows this step.
- Alice fcm + Alice telegram
- Bob fcm + Bob telegram (Bob is now linked!)

Totals after step 5: fcm=5, telegram=4.

### Step 6 — send an `inminent_colapse` alert

```bash
BODY='{"company_id":"acme-001","source_id":"prom-prod","severity":"inminent_colapse","category":"power","title":"PDU overload imminent","body":"rack-B","data":{"host":"rack-b"},"dedupe_key":"m3-s6"}'
```

**Expected** (cumulative): 5 more rows this step.
- Alice fcm + Alice telegram
- Bob fcm + Bob telegram (Bob's min=critical passes since
  inminent_colapse > critical)
- Carol fcm (Carol's quiet hours 00:00–23:59 are bypassed by
  inminent_colapse, but she has no telegram_chat_id, so no
  telegram)

Totals after step 6: fcm=8, telegram=6.

### Step 7 — Bob runs `/preferences`

```bash
curl -s -X POST http://localhost:8830/admin/queue \
  -H "Content-Type: application/json" \
  --data '{
    "bot_token": "fake-tg-bot-token-acme-001",
    "user_id":   900002,
    "chat_id":   2002,
    "text":      "/preferences",
    "first_name":"Bob SRE"
  }'
```

**Expected**: faketgmd receives a bot reply whose text starts
with "Your subscriptions:" and lists the prom-prod row with
`min=critical, channels=["fcm","telegram"]`.

### Step 8 — verify faketgmd totals

```bash
curl -sS http://localhost:8830/admin/sent | jq '.count'
# should be 8: 4 alert alerts (alice×2, alice, alice+bob) + 1 linked-reply + 1 prefs-reply = 8
```

(Wait, count: 4 alert notifications: step 2 alice, step 3
alice, step 5 alice+bob, step 6 alice+bob = 6 alert msgs + 1
linked-reply + 1 prefs-reply = 8.)

## Known quirks (and why the smoke script does what it does)

1. **faketgmd's `getUpdates` is a fake.** In production,
   Telegram's getUpdates tracks the offset server-side and
   returns updates >= offset. Our fake uses an in-memory
   `offsetByBot` map. The bot (`telegramd`) keeps its offset
   in a goroutine-local `var offset int64` that advances as
   updates come in. The smoke script calls
   `docker compose restart telegramd` after every reset so
   the bot's in-process offset goes back to 0 and aligns
   with faketgmd's cleared state. In production this is a
   non-issue (Telegram is the real source of truth).

2. **faketgmd is in-process only.** It keeps `sent`/`queue`
   in memory. Restarting the container (which the smoke
   script does not do by default) clears all state. M3 does
   not promise persistence for the fake.

3. **`deliverd` was renamed to `deliverd-fcm`.** The old
   container is gone. If you have an older compose, the
   `deliverd-telegram` service will start but the old
   `deliverd` service won't, leaving you with FCM disabled.
   Always `docker compose up -d` with the M3 compose.

## What this proves about M3

1. **Multi-channel resolver works.** The single CTE
   `internal/routing/routing.go` UNION-ALLs an `fcm_rows`
   branch and a `tg_rows` branch. `fcm_rows` joins on
   `fcm_tokens`; `tg_rows` filters to `telegram_chat_id IS
   NOT NULL`. Same CTE, same cost, two channels.
2. **Per-channel delivery binaries are independent.**
   `deliverd-fcm` and `deliverd-telegram` each subscribe to
   `deliveries.<channel>.<company_id>`. Restarting one does
   not affect the other.
3. **Bot long-polling works.** `telegramd` reads the active
   bots from `telegram_bots`, long-polls each, dispatches
   commands to the handler, and replies via `sendMessage`.
4. **`/start` is the only link path.** Admin creates the
   individual with an invite code; user runs `/start <code>`.
   No self-registration. The handler atomically claims the
   code so two simultaneous `/start`s can't race.
5. **Subscriptions have channel_mask.** Bob's M2 subscription
   was `["fcm"]`; M3 expanded it to `["fcm","telegram"]` via
   the seed migration. The resolver honors the mask.
6. **Quiet hours still apply to telegram.** Carol's 00:00–23:59
   window blocked her from everything except
   `inminent_colapse`, where the bypass held.
7. **Same severity bypass as M2.** `inminent_colapse`
   bypasses quiet hours regardless of channel.
