# Per-request Telegram announcement — design

audience: AI coding agents first. Implementer: codex (`-m gpt-5.6-terra -c model_reasoning_effort=medium`).
Owner intent: every new request, at the moment it registers in the db, is announced to its
project's Telegram group, so each request gets its own steering thread the owner can reply
into.

## Context

Requests register through `create()` in `collector/src/requests/requests-store.ts:104`
(SQLite `requests` table; routes in `collector/src/requests/`). Outbound Telegram goes
through the `botmaster` CLI (`modules/botmaster/notify/send.ts`), which resolves channel →
bot token via the D1 `bots` table. Inbound steering (owner replies, `#<id>` addressing)
already exists in `packaging/botmaster-proxy.ts` + the inbox hook.

## Contract

1. **CLI seam** (extend `parseArgs` in `send.ts`):
   `botmaster --group <channel> --text "<text>"` — `--group` is an alias for `--channel`
   whose target must resolve; `--text` is an explicit text flag (positional text keeps
   working). Both forms behave identically to today's send. This exists so callers outside
   the notify module have a stable, spellable contract.
2. **Announce seam** — new `collector/src/requests/requests-announce.ts`:
   ```ts
   announceRequest(row: RequestRow, deps: { send(channel: string, text: string): Promise<string> }): Promise<void>
   ```
   Called by the request-creation route immediately after `store.create()` returns (only on
   genuine creation, never on dedup/update). `deps.send` production impl spawns the
   `botmaster` binary (`--group <channel> --text <rendered>`); tests inject a fake.
3. **Channel mapping**: the request's `project` field is the channel name passed to
   `--group`; the existing D1 `bots` resolution decides whether a bot exists for it. No new
   mapping table.
4. **Rendered text** (verbatim shape):
   `New request #<id>: <title>` then a newline and `Reply to this thread to steer it.`
5. **Store delta**: `requests` gains a nullable `announced_at INTEGER` column (additive
   migration in the existing CREATE/migrate path of `requests-store.ts`), set when the send
   succeeds. Store gains `markAnnounced(id, at)`.

## Behavior

- Announcement is fire-and-forget from the route's perspective: a send failure (no bot for
  the project, network, token) is logged with the request id and the request creation still
  succeeds. `announced_at` stays NULL — that is the honest, queryable signal.
- No retry loop, no queue, no cron (edge-triggered only: one announcement per creation).
  A later slice may sweep NULL `announced_at`; out of scope here.
- Dedup path (`requests-dedup.ts` folding into an existing request) MUST NOT announce.
- The announcing send carries the request id in its text, so the owner's `#<message_id>`
  reply lands in the existing inbox flow unchanged — no proxy changes in this slice.

## Out of scope

Auto-creating Telegram groups/bots per project; announcing state transitions (done,
blocked); back-filling `announced_at` for pre-existing requests; proxy/waker changes.

## Acceptance

- `/usr/bin/bun test collector/` — new tests: creation announces exactly once with the
  verbatim text shape; dedup does not announce; send failure leaves `announced_at` NULL and
  creation still returns the row; `markAnnounced` persists.
- `/usr/bin/bun test modules/botmaster/notify/` — `--group`/`--text` parse and behave as
  `--channel`/positional text; unknown group still fails closed via `resolveChannel`.
- Live proof: create a request via the existing route on the running collector; the project
  channel receives `New request #<id>: <title>` and the row shows `announced_at` set.
