# Inbound attachments (owner → session) — design

audience: AI coding agents first. Implementer: codex (`-m gpt-5.6-terra -c model_reasoning_effort=medium`).
Owner intent: the owner sends a file to the bot on Telegram; the addressed session receives
a local path to it and can read it.

## Context

Inbound flow today: Telegram → bot worker (`~/Projects/Botmaster/bot-template`, Cloudflare)
→ `mirrorChatMessageToD1` (`src/db/messages.ts:42`) writes text-only rows into D1
`chat_messages` → `createInboxPoller` in `packaging/botmaster-proxy.ts` (overdeck) SELECTs
them (~line 345), registers a `Message`, writes the marker, the inbox hook delivers.
Documents/photos die at the mirror: it has no columns for them. The proxy can already
decrypt each bot's Telegram token (`resolve.ts`/`crypto.ts` path used by `send.ts`).

## Contract — two halves, strict order

### Half A: bot-template repo (worker + D1 schema)

1. Additive D1 migration: `chat_messages` gains nullable `file_id TEXT`,
   `file_name TEXT`, `file_size INTEGER`, `mime_type TEXT`.
2. Wherever the worker builds `StoredMessage` from an inbound Telegram update, capture
   `message.document` (and largest `message.photo` variant): fill the four fields;
   `text` falls back to the message `caption` (or `''`). Trace the actual handler — if
   non-text messages are currently dropped before storage, stop dropping ones that carry
   a document/photo.
3. `mirrorChatMessageToD1` binds the four new values. Stays best-effort/non-fatal.
4. Deploy order: migration first, then worker. Old worker + new schema is valid (columns
   stay NULL).

### Half B: overdeck repo (proxy)

1. Poll SELECT adds the four columns (schema is migrated before this half deploys).
2. New seam `modules/botmaster/notify/files.ts`:
   ```ts
   fetchInboundFile(token: string, fileId: string, fileName: string, messageId: string,
     deps?: { fetchImpl?: typeof fetch; root?: string }): Promise<string>  // returns local path
   ```
   Downloads via Telegram `getFile` + `https://api.telegram.org/file/bot<token>/<file_path>`
   to `~/.local/state/overdeck/botmaster/files/<messageId>/<sanitized fileName>` (mode 600,
   dirs 700). Sanitize: basename only, strip path separators and leading dots; empty result
   → `attachment.bin`. Refuse over 45 MB (`file_size` pre-check AND actual byte count).
   The token must never appear in any log or error string — wrap fetch failures with the
   host stripped.
3. In the poller, when a row has `file_id`: download BEFORE registering; on success the
   registered message text becomes `<text>\n[attachment saved: <local path>]`; on failure
   `<text>\n[attachment could not be fetched: <file_name>]` — the message itself is never
   lost, and the failure is logged with the message id, never the token.
4. A row with `file_id` but no main claim / no parent follows the existing routing rules
   unchanged — attachment handling is orthogonal to addressing.

## Out of scope

Photos-as-albums (media_group), stickers/voice/video notes, retention/GC of the files
directory, outbound attachments (already shipped), any change to the inbox hook.

## Acceptance

- Half A: bot-template's own test/verify path (match its repo conventions); migration
  applies cleanly to a copy of the live schema.
- Half B: `/usr/bin/bun test modules/botmaster/notify/ packaging/botmaster-proxy.test.ts` —
  new tests: filename sanitization (traversal, empty, dotfile), size refusal (both checks),
  registered text carries the saved path, fetch failure still registers the message,
  token absent from thrown error text.
- Live proof: owner sends a file to the bot; the session's delivered message names a local
  path; `cat` of that path matches the sent file.
