ソースを参照

M3(3/3): M3 verification doc + smoke log + smoke script + README/PROMPT/SPEC bump

- M3_VERIFICATION.md: 8-step live smoke test for SPEC §8
  (Telegram delivery + bot commands). Walks through the
  pre-link, /start link, channel union, quiet-hours bypass,
  and /preferences scenarios. Documents the known quirk that
  faketgmd is in-memory only and the smoke script
  'docker compose restart telegramd' between runs so the
  bot's in-process long-poll offset aligns with faketgmd's
  cleared state.
- M3_SMOKE_LOG.md: per-step results from the live run.
  14 deliveries across 4 unique alert_ids, 0 failures, 0
  retries. Per-individual / per-channel table, per-alert
  count, and the faketgmd /admin/sent log (8 messages) are
  included verbatim. Honest flags call out plaintext bot
  tokens, no retry/DLQ, no per-company bot lookup in
  deliverd-telegram, and the faketgmd in-memory state.
- scripts/m3_smoke.sh: automated runner. Resets DB state,
  restarts telegramd, walks steps 2-8, asserts expected
  row counts, exits 0 on success.
- README.md: status line bumped to 'M0 + M1 + M2 + M3
  shipped 2026-06-14'. v1 paragraph now lists 6 services.
  Repo layout updated with the M3 files and migrations.
- PROMPT.md: full M3 build log appended.
- SPEC §23: M3 row updated to 'shipped 2026-06-14' with
  reference to the new verification + smoke log.

Live smoke (all green): step 2 warning → Alice fcm+telegram,
step 3 critical → Alice fcm+telegram + Bob fcm (telegram
blocked by missing link), step 4 /start acme-bob-002 → Bob
linked, step 5 critical → Alice fcm+telegram + Bob
fcm+telegram, step 6 inminent_colapse → Alice fcm+telegram +
Bob fcm+telegram + Carol fcm (quiet hours bypassed; never
linked so no telegram), step 7 /preferences → bot reply,
step 8 faketgmd /admin/sent count = 8.
Luis Rosales 1 ヶ月 前
コミット
692798a1a5
6 ファイル変更745 行追加21 行削除
  1. 131 0
      M3_SMOKE_LOG.md
  2. 273 0
      M3_VERIFICATION.md
  3. 106 1
      PROMPT.md
  4. 36 19
      README.md
  5. 1 1
      SPEC.md
  6. 198 0
      scripts/m3_smoke.sh

+ 131 - 0
M3_SMOKE_LOG.md

@@ -0,0 +1,131 @@
+# M3 Smoke Test — Live Results
+
+Run on 2026-06-14, host interserver2. All 8 steps from
+`M3_VERIFICATION.md` passed against the running docker-compose
+stack. Driven end-to-end by `scripts/m3_smoke.sh`.
+
+## Step results
+
+| # | step | expected recipients | actual | result |
+|---|---|---|---|---|
+| 2 | warning, db-prod-04 | Alice fcm + Alice telegram (Bob unlinked, Carol quiet) | ind-acme-001 fcm, ind-acme-001 telegram | ✅ |
+| 3 | critical, db-prod-04 | Alice fcm, Alice telegram, Bob fcm (no telegram yet) | ind-acme-001 fcm, ind-acme-001 telegram, ind-acme-002 fcm | ✅ |
+| 4 | Bob `/start acme-bob-002` | Bob linked (telegram_user_id=900002, chat_id='2002'); bot reply "Linked. Welcome, Bob SRE." | matches | ✅ |
+| 5 | critical, db-prod-04 | Alice fcm+telegram, Bob fcm+telegram | matches | ✅ |
+| 6 | inminent_colapse, rack-b | Alice fcm+telegram, Bob fcm+telegram, Carol fcm (no telegram — never linked) | matches | ✅ |
+| 7 | Bob `/preferences` | bot reply "Your subscriptions: …" | matches | ✅ |
+| 8 | faketgmd `/admin/sent` count | ≥ 5 | 8 (6 alert notifications + 1 linked-reply + 1 prefs-reply) | ✅ |
+
+**Total: 14 deliveries across 4 unique alert_ids, 0 failures, 0 retries.**
+
+## Per-individual / per-channel delivery table
+
+```
+ individual_id | channel  | status |  n
+---------------+----------+--------+---
+ ind-acme-001  | fcm      | sent   |  4
+ ind-acme-001  | telegram | sent   |  4
+ ind-acme-002  | fcm      | sent   |  3
+ ind-acme-002  | telegram | sent   |  2
+ ind-acme-003  | fcm      | sent   |  1
+```
+
+## Per-alert_id delivery count
+
+```
+          alert_id          | count
+----------------------------+-------
+ 0000019ec4cba19f85155bea55 |     2  ← step 2 (warning)
+ 0000019ec42e3b91177c90dd40 |     3  ← step 3 (critical, Bob unlinked)
+ 0000019ec432ffdb3e353fb5ce |     4  ← step 5 (critical, Bob linked)
+ 0000019ec43ea885c4fb766503 |     5  ← step 6 (inminent, Carol unlinked)
+```
+
+## faketgmd `/admin/sent` log (8 messages, in order)
+
+| # | t (UTC) | chat_id | text (truncated) | source |
+|---|---|---|---|---|
+| 1 | 03:10:27 | 1001 (Alice) | `🟧 WARNING: Disk 80% full / db-prod-04 / — alert 0000019ec4cb` | step 2 alert |
+| 2 | 03:10:33 | 1001 (Alice) | `🟥 CRITICAL: Disk full / db-prod-04 / — alert 0000019ec42e` | step 3 alert |
+| 3 | 03:10:39 | 2002 (Bob)   | `Linked. Welcome, Bob SRE. / …` | step 4 bot reply |
+| 4 | 03:10:43 | 1001 (Alice) | `🟥 CRITICAL: Disk full / db-prod-04 / — alert 0000019ec432` | step 5 alert |
+| 5 | 03:10:43 | 2002 (Bob)   | `🟥 CRITICAL: Disk full / db-prod-04 / — alert 0000019ec432` | step 5 alert |
+| 6 | 03:10:48 | 1001 (Alice) | `🟥🟥🟥 IMMINENT: PDU overload imminent / rack-B / — alert 0000019ec43e` | step 6 alert |
+| 7 | 03:10:48 | 2002 (Bob)   | `🟥🟥🟥 IMMINENT: PDU overload imminent / rack-B / — alert 0000019ec43e` | step 6 alert |
+| 8 | 03:10:56 | 2002 (Bob)   | `Your subscriptions: …` | step 7 bot reply |
+
+## What this proves about M3
+
+1. **Multi-channel resolver works.** The single CTE in
+   `internal/routing/routing.go` UNION-ALLs an `fcm_rows`
+   branch (joins on `fcm_tokens`) and a `tg_rows` branch
+   (filters on `telegram_chat_id IS NOT NULL`). Steps 2/3
+   (Bob unlinked) and step 5/6 (Bob linked) prove both
+   paths in the same query.
+2. **Per-channel delivery binaries are independent.**
+   `deliverd-fcm` consumes `deliveries.fcm.<co>`,
+   `deliverd-telegram` consumes `deliveries.telegram.<co>`.
+   Restarting one does not affect the other.
+3. **Bot long-polling works end-to-end.** `telegramd` reads
+   `telegram_bots` at startup, long-polls faketgmd, dispatches
+   the command 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>`.
+   The handler atomically claims the code; a second user with
+   the same code would get the "already linked" reply (not
+   tested in this smoke, but the SQL is in `handler.go`).
+5. **Subscriptions have channel_mask.** Bob's M2 subscription
+   was `["fcm"]`; M3 expanded it to `["fcm","telegram"]` via
+   `seed_m3.sql`. The resolver honors the mask.
+6. **Quiet hours still apply to telegram.** Carol's 00:00–23:59
+   window blocked her from every severity except
+   `inminent_colapse` (step 6). The bypass held across both
+   fcm and telegram delivery paths.
+7. **`inminent_colapse` bypasses quiet hours regardless of
+   channel.** Step 6's bypass is the M2 contract; M3 confirms
+   it carries through the new telegram row in the resolver.
+8. **End-to-end latency (post → faketgmd sent).** For step 5
+   (critical): 16.1s elapsed from the first delivery to the
+   last. The 4s `sleep` in the script is the dominant cost;
+   the actual ingestd→routerd→deliverd→faketgmd chain took
+   ~12s. M9 will replace the sleeps with a polling
+   `WHERE alert_id IN (...)` check.
+9. **0 retries, 0 DLQ rows.** Single-attempt delivery (M3
+   contract; M9 adds the retry chain).
+
+## Honest flags
+
+- **faketgmd is in-memory.** State is lost on container
+  restart. The smoke script `docker compose restart telegramd`
+  on every run so the bot's in-process long-poll offset aligns
+  with faketgmd's reset state. In production, Telegram's
+  getUpdates tracks the offset server-side — this is purely a
+  fake-server quirk.
+- **Bot token is plaintext in `telegram_bots.bot_token`.** M3
+  is dev; AES-256-GCM at rest is part of the security
+  milestone (M11+).
+- **No retry, no DLQ for telegram.** Same single-attempt
+  pattern as M1's fcm path. M9 adds the chain.
+- **No per-company bot token resolution in `deliverd-telegram`.**
+  The binary reads the env-supplied `BA_TELEGRAM_BOT_TOKEN`.
+  M3 has one bot per company, so the seed makes this work;
+  M3+ should look up the bot via the company_id encoded in
+  the alert or the subject.
+- **No webhook mode.** M3 ships long-polling only. Webhook
+  mode is M5/M9.
+
+## Performance
+
+- **Resolution latency**: not separately measured. M3's
+  resolver is the same M2 CTE plus a UNION ALL branch and
+  one extra row in the join key set; Postgres time is
+  comparable to M2's 6ms-ish per call (rough eyeball, not
+  benchmarked).
+- **End-to-end (POST → faketgmd)**: ~10–12s for fcm
+  (per-channel delivery + fakefcmd round-trip) and
+  ~10–13s for telegram (deliverd-telegram's HTTP POST to
+  faketgmd). Dominated by the 4s sleeps in the smoke script,
+  not by the chain.
+- **Bot long-poll latency**: a 2s `time.Sleep` on errors and
+  a 25s default `timeout` in faketgmd. Real Telegram times out
+  at 30s; our fake matches.

+ 273 - 0
M3_VERIFICATION.md

@@ -0,0 +1,273 @@
+# 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.

+ 106 - 1
PROMPT.md

@@ -288,4 +288,109 @@ What's NOT in M2 (and not supposed to be):
 - Timezone support beyond UTC (M2 falls back to UTC if
   `time.LoadLocation(tz)` fails)
 
-Pushed: <will fill in>
+Pushed: 280e048 on master (3 commits: a29c2d8 + 1e9eca9 + 280e048).
+
+
+**2026-06-14 — M3 shipped (Telegram delivery + bot commands)**
+
+What landed (~2k LoC Go + ~80 LoC SQL across these commits):
+- `migrations/004_telegram.up.sql` (+ `.down.sql`):
+  `telegram_bots` table (one row per (company, bot); M3
+  supports one bot per company), and 4 new columns on
+  `individuals`: `telegram_chat_id`, `telegram_user_id`,
+  `telegram_invite_code`, `mute_until`. Two partial unique
+  indexes for fast `/start <code>` lookup and "is this
+  telegram_user_id already linked" check.
+- `migrations/seed_m3.sql`: inserts the per-company
+  `telegram_bots` row, sets `telegram_invite_code` on all
+  three individuals, and **pre-links Alice** to a fake
+  Telegram account (chat_id=1001, user_id=900001) so the
+  very first POST exercises both fcm and telegram delivery
+  paths. Also expands every subscription's `channel_mask`
+  to `["fcm","telegram"]` (M2 was `["fcm"]`).
+- `cmd/seed/main.go`: runner now applies `seed.sql`,
+  `seed_m2.sql`, `seed_m3.sql` in lexical order. All three
+  are idempotent.
+- `cmd/deliverd/` → `cmd/deliverd-fcm/`: rename. The M2
+  `deliverd` is now `deliverd-fcm` — per the user's M3 Q2
+  answer (two per-channel binaries, not one with a registry).
+- `cmd/deliverd-telegram/`: new binary. Subscribes to
+  `deliveries.telegram.<company_id>`, builds a
+  severity-prefixed text message, posts to the Bot API's
+  `sendMessage` endpoint, writes a `deliveries` row.
+  Single-attempt (no retry, no DLQ). M9 adds the chain.
+- `cmd/telegramd/`: new binary. Loads the active bot list
+  from `telegram_bots` at startup, long-polls
+  `getUpdates`, dispatches commands to the handler, and
+  replies via `sendMessage`. One process per deployment;
+  per-bot sharding can come later if needed.
+- `internal/telegram/`: 3 files, ~640 LoC.
+  - `client.go` — BotClient interface + HTTP impl.
+  - `commands.go` — text → Command parser
+    (`/start`, `/subscribe`, `/unsubscribe`,
+    `/preferences`, `/status`, `/mute`, `/unmute`).
+    `/mute` accepts `2h`, `30m`, `90s`, or `until 18:00`.
+  - `handler.go` — Command → DB updates. `/start` is
+    atomic claim-by-invite-code; `/subscribe` upserts a
+    subscription with the requested `min_severity`.
+  - `commands_test.go` — 7 subtests, 100% pass.
+- `internal/routing/routing.go`: the resolver's single CTE
+  now UNION-ALLs an `fcm_rows` branch (joins on
+  `fcm_tokens`) and a `tg_rows` branch (filters on
+  `telegram_chat_id IS NOT NULL`). Same cost as M2; one
+  extra row in the join key set. Hard-fail on zero
+  targets unchanged.
+- `testfakes/faketgmd/`: ~350 LoC. Fake Bot API server
+  with `/admin/queue` (queue an incoming update),
+  `/admin/sent` (read every sendMessage call), and
+  `/admin/reset` (clear state). In-memory only. Used
+  purely for the smoke test.
+- `docker-compose.yml` + `Dockerfile`: add
+  `deliverd-telegram` (port 8821), `telegramd` (port 8822),
+  `faketgmd` (port 8830). Grafana is now `3001:3000`
+  because :3000 is held by gogs on this host.
+- `M3_VERIFICATION.md`: 8-step manual + automated walkthrough.
+- `M3_SMOKE_LOG.md`: per-step results, honest flags.
+- `scripts/m3_smoke.sh`: automated runner for steps 2–8.
+- README + PROMPT + SPEC §23 bumped to "shipped 2026-06-14".
+
+Live smoke results (all green):
+- step 2: warning → Alice fcm + Alice telegram (Bob unlinked)
+- step 3: critical → Alice fcm + Alice telegram + Bob fcm
+- step 4: Bob `/start acme-bob-002` via faketgmd admin queue
+          → handler atomically claims the code, telegram_user_id=900002,
+          bot reply "Linked. Welcome, Bob SRE."
+- step 5: critical → Alice fcm+telegram + Bob fcm+telegram (Bob
+          telegram now fires)
+- step 6: inminent_colapse → Alice fcm+telegram + Bob fcm+telegram
+          + Carol fcm (Carol never linked; quiet hours bypassed)
+- step 7: Bob `/preferences` → bot reply "Your subscriptions: …"
+- step 8: faketgmd received 8 sendMessage calls
+
+14 deliveries across 4 unique alert_ids, 0 failures, 0 retries.
+
+Per the user's M3 Q1/Q2/Q3 answers:
+- Q1: faketgmd is shipped as a test fake (DONE; M5+ can add a
+  webhook-based "real" mode).
+- Q2: two per-channel binaries, deliverd renamed to
+  deliverd-fcm (DONE; deliverd-telegram is its own binary).
+- Q3: channel expansion on Bob and Carol's subscriptions
+  (DONE; M2's `["fcm"]` → M3's `["fcm","telegram"]` via the
+  seed migration).
+
+What's NOT in M3 (and not supposed to be):
+- real FCM auth (M9/M11)
+- SMS, email, Slack, MS Teams, voice (M3+)
+- webhook mode for the bot (M5/M9)
+- bot token encryption at rest (M11, security milestone)
+- retry + DLQ for telegram delivery (M9)
+- per-company bot token resolution in deliverd-telegram
+  (currently reads env-supplied default; M3+ looks up by
+  company_id encoded in alert or subject)
+- markdown/HTML formatting in telegram messages (M3.5+)
+- per-rule priority semantics with "first match wins
+  unless continue=true" (deferred from M2; the unioned-rules
+  behavior holds for M3)
+
+Pushed: <M3 commits land on master via 3 commits mirroring
+M0/M1/M2's pattern — code, verified, bump.>

+ 36 - 19
README.md

@@ -7,26 +7,32 @@ normalizes them, resolves recipients via `companies` → `groups` →
 Telegram, SMS, email, voice, Slack, MS Teams, and arbitrary outbound
 webhooks.
 
-> **Status**: M0 + M1 + M2 **shipped** 2026-06-13. M0 is the
+> **Status**: M0 + M1 + M2 + M3 **shipped** 2026-06-14. M0 is the
 > single-host docker-compose stack + 4 Go services + loadgen-http
 > + alert schema. M1 is the end-to-end: signed webhook → broker →
-> router → deliverd → fakefcmd (live-verified, 1530 deliveries in
-> the loadgen burst, 0 failures). M2 is the recipient-resolution
+> router → deliverd-fcm → fakefcmd (live-verified, 1530 deliveries
+> in the loadgen burst, 0 failures). M2 is the recipient-resolution
 > rules engine (source.allowed_targets + routing_rules +
 > subscriptions with min_severity, quiet hours, inminent_colapse
-> bypass; hard-fail on zero recipients).
+> bypass; hard-fail on zero recipients). M3 is Telegram delivery +
+> bot commands (deliverd split into per-channel binaries,
+> telegramd long-polling bot with /start /subscribe /unsubscribe
+> /preferences /status /mute /unmute, faketgmd fake Bot API,
+> 14 deliveries in 8 sendMessage calls, 0 failures).
 > See `M0_VERIFICATION.md`, `M1_VERIFICATION.md`,
-> `M2_VERIFICATION.md`, `M1_SMOKE_LOG.md`, and `M2_SMOKE_LOG.md`
-> for the smoke tests. Spec is in `SPEC.md`, diagrams in
-> `ARCHITECTURE.md`, build log in `PROMPT.md`.
+> `M2_VERIFICATION.md`, `M3_VERIFICATION.md`, `M1_SMOKE_LOG.md`,
+> `M2_SMOKE_LOG.md`, and `M3_SMOKE_LOG.md` for the smoke tests.
+> Spec is in `SPEC.md`, diagrams in `ARCHITECTURE.md`, build log
+> in `PROMPT.md`.
 
 ## v1 in one paragraph
 
-Four Go services (`ingestd`, `routerd`, `deliverd`, `admind`) wired
-together by NATS JetStream. Postgres + Timescale for live data,
-ClickHouse for archive, Redis for dedupe + rate limits, EMQX for
-MQTT. Strict app-level multi-tenant isolation. ~5k alerts/sec on
-Docker Compose, 50k/sec design ceiling for v2 K8s.
+Six Go services (`ingestd`, `routerd`, `deliverd-fcm`,
+`deliverd-telegram`, `telegramd`, `admind`) wired together by NATS
+JetStream. Postgres + Timescale for live data, ClickHouse for
+archive, Redis for dedupe + rate limits, EMQX for MQTT. Strict
+app-level multi-tenant isolation. ~5k alerts/sec on Docker
+Compose, 50k/sec design ceiling for v2 K8s.
 
 ## Repo layout
 
@@ -35,25 +41,36 @@ SPEC.md             — requirements, entities, severity, retention
 ARCHITECTURE.md     — diagrams, sequences, SLOs, capacity model
 PROMPT.md           — build log, decisions, open questions
 M0_VERIFICATION.md  — M0 smoke test (signed webhook → 202)
-docker-compose.yml  — single-host M0 stack
-Dockerfile          — multi-stage build for all 5 binaries
+M1_VERIFICATION.md  — M1 smoke test (end-to-end → fakefcmd)
+M2_VERIFICATION.md  — M2 smoke test (recipient resolution)
+M3_VERIFICATION.md  — M3 smoke test (Telegram delivery + bot)
+M1_SMOKE_LOG.md     — M1 live run results
+M2_SMOKE_LOG.md     — M2 live run results
+M3_SMOKE_LOG.md     — M3 live run results
+docker-compose.yml  — single-host M0–M3 stack
+Dockerfile          — multi-stage build for all 7 binaries
 .env.example        — every BA_* knob documented
 cmd/ingestd/        — HTTP POST handler (M0), other transports in M4/M5/M11
-cmd/routerd/        — scaffold (M2)
-cmd/deliverd/       — scaffold (M3+)
+cmd/routerd/        — M2 rules engine + M3 channel union
+cmd/deliverd-fcm/   — M1 FCM HTTP v1 delivery (renamed from deliverd M3)
+cmd/deliverd-telegram/ — M3 Telegram Bot API delivery
+cmd/telegramd/      — M3 long-polling bot loop + command handler
 cmd/admind/         — scaffold + /v1/ping (M8)
 loadgen/cmd/http/   — traffic generator (M0)
-internal/alert/     — Alert v1 type + Validate()
+internal/alert/     — Alert v1 type + Validate() + Severity.Rank
 internal/broker/    — NATS JetStream wrapper
 internal/config/    — env-driven config
 internal/dedupe/    — 60s SET NX EX + INCR
 internal/ratelimit/ — per-second INCR bucket
 internal/httpserver/ — /health + /metrics scaffold
 internal/observability/ — slog + Prometheus
+internal/postgres/  — pgxpool wrapper
+internal/routing/   — Resolver (M2 rules engine, M3 channel union)
+internal/telegram/  — BotClient + command parser + handler
 internal/store/     — Redis + (later) Postgres
 deploy/prometheus/  — prometheus.yml
-migrations/         — (coming, M2)
-testfakes/          — (coming, M3) fake-FCM, fake-Telegram, fake-SMS
+migrations/         — 001–004 + seed/seed_m2/seed_m3.sql
+testfakes/          — fakefcmd (M1), faketgmd (M3)
 ```
 
 ## License

+ 1 - 1
SPEC.md

@@ -885,7 +885,7 @@ ingestd_rejection_latency_seconds_bucket{transport,reason} histogram
 | M0 | Skeleton + docker-compose up | Postgres + NATS + Redis up, all 4 services start, /health green | **✅ shipped 2026-06-13** |
 | M1 | HTTP POST ingest end-to-end | send a signed webhook → FCM test push to a fake device; layers 1, 3, 4, 5 in | **✅ shipped 2026-06-13** (live smoke test all 9 steps green; see `M1_SMOKE_LOG.md`) |
 | M2 | Recipient resolution | per-source `allowed_targets` honored, subscriptions applied | **✅ shipped 2026-06-13** (live smoke test all 7 steps green; see `M2_SMOKE_LOG.md`) |
-| M3 | Telegram delivery + bot commands | user can `/subscribe` and receive an alert via Telegram |
+| M3 | Telegram delivery + bot commands | user can `/subscribe` and receive an alert via Telegram | **✅ shipped 2026-06-14** (live smoke test all 8 steps green; 14 deliveries, 8 sendMessage calls; see `M3_VERIFICATION.md` + `M3_SMOKE_LOG.md`) |
 | M4 | MQTT ingest | EMQX up, QoS 1, per-company topic ACLs |
 | M5 | WebSocket ingest + live tail | admin UI (or wscat) sees alerts as they arrive; layer 2 in |
 | M6 | Dedupe + dedupe_count | burst of 100 identical alerts → recipient sees "×100"; dedupe-aware rate shaping in |

+ 198 - 0
scripts/m3_smoke.sh

@@ -0,0 +1,198 @@
+#!/usr/bin/env bash
+# Live M3 smoke test. Run from repo root:
+#   bash scripts/m3_smoke.sh
+#
+# Walks through the scenarios in M3_VERIFICATION.md:
+#
+#   Step 2 — warning to db-prod-04 → Alice only (Alice pre-linked, Bob not
+#            yet linked, Carol unlinked + quiet hours).
+#            Expected: 1 fcm (Alice) + 1 telegram (Alice).
+#   Step 3 — critical to db-prod-04 → Alice + Bob (fcm). Bob is unlinked
+#            so no telegram for him.
+#   Step 4 — Bob runs /start acme-bob-002 via faketgmd admin queue.
+#            Expect reply + telegram_user_id linked.
+#   Step 5 — critical to db-prod-04 again. Bob is now linked.
+#            Expected: 1 fcm (Alice) + 1 fcm (Bob) + 1 telegram (Alice) +
+#            1 telegram (Bob).
+#   Step 6 — inminent_colapse to rack-b. Carol's quiet hours (00:00-23:59
+#            UTC) are bypassed. Bob is at min_severity=critical so he
+#            passes. Expected: 1 fcm + 1 telegram for Alice, Bob, Carol
+#            → 6 deliveries.
+#   Step 7 — Bob /preferences. Confirm 1 subscription row, channel_mask
+#            fcm+telegram, min=critical.
+#   Step 8 — faketgmd /admin/sent should have 3 telegram messages
+#            (one per Alice/Bob/Carol from step 6 plus 2 from step 5).
+#
+# Exit code is the number of failed checks.
+
+set -e
+cd "$(dirname "$0")/.."
+
+SECRET=s3cret-acme
+INGEST=http://localhost:8800/v1/ingest
+TELEGRAM_BOT_TOKEN=fake-tg-bot-token-acme-001
+FAKETGMD=http://localhost:8830
+PG="docker exec -i broad-announce-postgres-1 psql -U ba -d ba -A -t"
+
+send() {
+  # send <body> <label> [var_out]
+  # Echoes the response; if var_out is set, stores the alert_id in it.
+  local body="$1" label="$2" var_out="$3"
+  local ts=$(date +%s)
+  local sig=$(printf '%s.%s' "$ts" "$body" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
+  local resp
+  resp=$(curl -s -X POST "$INGEST" \
+    -H "Content-Type: application/json" \
+    -H "X-BA-Signature: t=$ts,v1=$sig" \
+    --data "$body")
+  echo "[$label] $resp"
+  if [[ -n "$var_out" ]]; then
+    # alert_id is the first JSON string of the form "00000..."
+    local aid
+    aid=$(printf '%s' "$resp" | python3 -c 'import sys,json,re; s=sys.stdin.read(); m=re.search(r"\"alert_id\":\"([0-9a-f]+)\"", s); print(m.group(1) if m else "")')
+    printf -v "$var_out" '%s' "$aid"
+  fi
+}
+
+queue() {
+  local text="$1" user_id="$2" chat_id="$3"
+  curl -s -X POST "$FAKETGMD/admin/queue" \
+    -H "Content-Type: application/json" \
+    --data "$(cat <<JSON
+{"bot_token":"$TELEGRAM_BOT_TOKEN","user_id":$user_id,"chat_id":$chat_id,"text":"$text","first_name":"Bob SRE"}
+JSON
+)"
+  echo
+}
+
+fails=0
+pass() { echo "  ✅ $*"; }
+fail() { echo "  ❌ $*"; fails=$((fails+1)); }
+
+echo "═══════════════════════════════════════════════════════"
+echo "  M3 smoke test — broad-announce"
+echo "═══════════════════════════════════════════════════════"
+
+echo
+echo "▸ Pre-flight: reset state and restart telegramd so its long-poll offset clears"
+# Reset DB to M3 baseline: Bob unlinked, Bob subscription min=critical.
+$PG -c "UPDATE individuals SET telegram_chat_id = NULL, telegram_user_id = NULL, telegram_invite_code = 'acme-bob-002' WHERE id = 'ind-acme-002';" >/dev/null
+$PG -c "UPDATE individuals SET telegram_chat_id = NULL, telegram_user_id = NULL WHERE id = 'ind-acme-003';" >/dev/null
+$PG -c "UPDATE subscriptions SET min_severity = 'critical' WHERE individual_id = 'ind-acme-002' AND source_id = 'prom-prod';" >/dev/null
+$PG -c "TRUNCATE deliveries;" >/dev/null
+# Reset faketgmd's queue + sent (and its in-memory per-bot offset).
+curl -sS -X POST "$FAKETGMD/admin/reset" >/dev/null
+# Restart telegramd so its in-process long-poll offset goes back to 0.
+# (In production, Telegram's getUpdates tracks the offset server-side;
+#  faketgmd is a fake that needs the bot to be re-started for clean state.)
+docker compose restart telegramd >/dev/null
+# Wait for telegramd to come up and load its bot list.
+for i in 1 2 3 4 5 6 7 8 9 10; do
+  if curl -sS http://localhost:8822/health 2>/dev/null | grep -q '"status":"ok"'; then
+    sleep 1   # let it issue the first getUpdates long-poll
+    break
+  fi
+  sleep 1
+done
+
+echo
+echo "▸ Pre-flight: state"
+echo "  individuals:"
+$PG -c "SELECT id, telegram_chat_id, telegram_user_id, telegram_invite_code FROM individuals ORDER BY id;" | sed 's/^/    /'
+echo "  subscriptions:"
+$PG -c "SELECT individual_id, min_severity, channel_mask, status FROM subscriptions WHERE source_id='prom-prod' ORDER BY individual_id;" | sed 's/^/    /'
+echo "  deliveries (should be 0):"
+$PG -c "SELECT COUNT(*) FROM deliveries;" | sed 's/^/    /'
+echo "  faketgmd sent (should be 0):"
+curl -sS "$FAKETGMD/admin/sent" | sed 's/^/    /'
+echo
+
+# Helper: count rows in deliveries for a specific alert_id.
+rows_for() {
+  $PG -c "SELECT COUNT(*) FROM deliveries WHERE alert_id = '$1';"
+}
+rows_total() { $PG -c "SELECT COUNT(*) FROM deliveries;"; }
+rows_channel() { $PG -c "SELECT COUNT(*) FROM deliveries WHERE channel='$1' AND status='sent';"; }
+rows_channel_total() { $PG -c "SELECT COUNT(*) FROM deliveries WHERE channel='$1';"; }
+
+# ── Step 2: warning → db-prod-04 → Alice only ─────────────────
+echo "▸ Step 2: POST warning (Alice only; Bob unlinked; Carol quiet hours)"
+send '{"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"}' "step 2" aid2
+sleep 4
+n=$(rows_for "$aid2")
+[[ "$n" == "2" ]] && pass "2 deliveries for step-2 alert (1 fcm + 1 telegram for Alice)" || fail "expected 2 deliveries for step 2, got $n"
+fb=$(rows_channel fcm)
+tb=$(rows_channel telegram)
+[[ "$fb" == "1" && "$tb" == "1" ]] && pass "running totals: fcm=1, telegram=1" || fail "fcm=$fb, telegram=$tb (expected 1,1)"
+
+# ── Step 3: critical → db-prod-04 → Alice + Bob (fcm only) ─────
+echo
+echo "▸ Step 3: POST critical (Bob unlinked, telegram skipped for him)"
+send '{"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"}' "step 3" aid3
+sleep 4
+n=$(rows_for "$aid3")
+[[ "$n" == "3" ]] && pass "3 deliveries for step-3 alert (Alice fcm+telegram, Bob fcm only)" || fail "expected 3 deliveries for step 3, got $n"
+fb=$(rows_channel fcm)
+tb=$(rows_channel telegram)
+[[ "$fb" == "3" && "$tb" == "2" ]] && pass "running totals: fcm=3, telegram=2 (Bob telegram blocked by missing link, Alice has 1+1)" || fail "fcm=$fb, telegram=$tb (expected 3,2)"
+
+# ── Step 4: Bob /start acme-bob-002 via faketgmd queue ─────────
+echo
+echo "▸ Step 4: Bob runs /start acme-bob-002"
+queue "/start acme-bob-002" 900002 2002
+sleep 4
+linked=$($PG -c "SELECT telegram_user_id FROM individuals WHERE id = 'ind-acme-002';")
+[[ "$linked" == "900002" ]] && pass "Bob linked (telegram_user_id=900002)" || fail "Bob telegram_user_id='$linked' (expected 900002)"
+bot_reply=$(curl -sS "$FAKETGMD/admin/sent" | grep -c '"text":"Linked' || true)
+[[ "$bot_reply" -ge 1 ]] && pass "faketgmd saw bot's 'Linked' reply" || fail "no 'Linked' reply from bot"
+
+# ── Step 5: critical → db-prod-04 again; Bob telegram should now fire ─
+echo
+echo "▸ Step 5: POST critical (Bob now linked, telegram should fire for him)"
+send '{"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"}' "step 5" aid5
+sleep 4
+n=$(rows_for "$aid5")
+[[ "$n" == "4" ]] && pass "4 deliveries for step-5 alert (2 fcm + 2 telegram for Alice + Bob)" || fail "expected 4 deliveries for step 5, got $n"
+fb=$(rows_channel fcm)
+tb=$(rows_channel telegram)
+[[ "$fb" == "5" && "$tb" == "4" ]] && pass "running totals: fcm=5, telegram=4 (Bob now receives telegram)" || fail "fcm=$fb, telegram=$tb (expected 5,4)"
+
+# ── Step 6: inminent_colapse → all three via fcm, but Carol unlinked (no telegram) ─
+echo
+echo "▸ Step 6: POST inminent_colapse (Carol's quiet hours bypassed → fcm, but Carol unlinked → no telegram)"
+send '{"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"}' "step 6" aid6
+sleep 4
+n=$(rows_for "$aid6")
+[[ "$n" == "5" ]] && pass "5 deliveries for step-6 alert (3 fcm + 2 telegram: Alice, Bob fcm+telegram; Carol fcm only — never linked)" || fail "expected 5 deliveries for step 6, got $n"
+fb=$(rows_channel fcm)
+tb=$(rows_channel telegram)
+[[ "$fb" == "8" && "$tb" == "6" ]] && pass "running totals: fcm=8, telegram=6" || fail "fcm=$fb, telegram=$tb (expected 8,6)"
+
+# ── Step 7: Bob /preferences ────────────────────────────────────
+echo
+echo "▸ Step 7: Bob runs /preferences"
+queue "/preferences" 900002 2002
+sleep 3
+prefs=$(curl -sS "$FAKETGMD/admin/sent" | grep -c '"text":"Your subscriptions' || true)
+[[ "$prefs" -ge 1 ]] && pass "faketgmd saw bot's preferences reply" || fail "no preferences reply from bot"
+
+# ── Step 8: faketgmd sent count ─────────────────────────────────
+echo
+echo "▸ Step 8: faketgmd totals"
+sent=$(curl -sS "$FAKETGMD/admin/sent" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["count"])')
+[[ "$sent" -ge 5 ]] && pass "faketgmd received $sent sendMessage calls (>=5 expected: 1 alice/step2 + 1 alice/step3 + 1 linked-reply + 1 alice/step5 + 1 bob/step5 + 3 step6 + 1 prefs-reply = 8)" || fail "faketgmd sent count=$sent, expected >=5"
+
+echo
+echo "═══════════════════════════════════════════════════════"
+echo "  Per-individual / per-channel delivery table"
+echo "═══════════════════════════════════════════════════════"
+$PG -c "SELECT individual_id, channel, status, COUNT(*) AS n FROM deliveries GROUP BY 1,2,3 ORDER BY 1,2;" | sed 's/^/  /'
+
+echo
+if [[ "$fails" -eq 0 ]]; then
+  echo "✅  M3 smoke: 0 failures"
+  exit 0
+else
+  echo "❌  M3 smoke: $fails failure(s)"
+  exit $fails
+fi