# Telegram Bot: Group Assistant & Notification Channel

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 43  
**Depends on:** `foundation-auth-rbac`, `system-communications-notifications`, `ai-assistant`, `kb-module`, `crm-support-center`, `tasks-board-engine`, `marketing-leads-pipeline`  
**Referenced by:** `system-communications-notifications`, `ai-assistant`, `settings-module`

---

## Overview

Extends the BYOT Telegram adapter (established in `system-communications-notifications`) into a full team assistant and notification channel.

Four additions on top of the bot connection (token storage + setWebhook):

1. **Personal DM interface** — full Zync UI over Telegram for linked users. Tasks, tickets (CRM), invoices, customers, document upload, AI assistant — all via inline keyboards. Respects per-user RBAC. Telegram as a second UI surface, not a separate feature.
2. **Group assistant** — bot joins tenant's team group chats, logs all messages, responds to commands and @-mentions with full Zync context (KB, CRM, tasks, marketing, invoice status).
3. **Conversation tools** — `/summarize`, `/search` against stored chat history; voice transcription; @-mention Q&A routed through the existing `ai-assistant` RAG pipeline.
4. **Notification channel** — Telegram becomes a third delivery channel alongside in-app and email. Supports personal DMs, group chats, and named notification lists (curated sets of chat IDs for broadcast).

**Botmaster reuse:** Architecture, command patterns, message schema, summarize logic, mention handler, and chunker are directly adapted from the Botmaster project (`~/Projects/Botmaster`). Runtime is ported from Telegraf (Node subprocess) to **grammy** (Cloudflare Workers-native).

---

## Bot Runtime Architecture

```
Telegram  ──►  POST /api/webhooks/telegram/{tenantId}  (CF Worker)
                         │
                         ▼
              Token: SELECT FROM adapter_credentials   ←  Neon (Hyperdrive)
                         │
                         ▼
                  grammy bot instance
                  (reconstructed per-request from decrypted token)
                         │
              ┌──────────┴──────────────┐
              ▼                         ▼
         Chat type?                Callback query?
       (group / DM)               (inline button tap)
              │                         │
      ┌───────┴───────┐                 ▼
      ▼               ▼       CallbackEncoder.decode()
   GROUP             DM         → TelegramScreen.render()
      │               │
      │      TelegramBotIdentity resolver:
      │      chat_id → module-scope Map (5min TTL)
      │      → cache miss: Neon user_preferences lookup
      │      → RBAC permissions hydrated
      │               │
      ▼               ▼
 Message log    ConversationSession check (DO)
 Command router      │
 Mention handler ┌───┴────────────┐
                 ▼                ▼
            Pending action   TelegramScreen.render('home')
            continuation     Command handler
```

No persistent process. The Worker reconstructs the grammy instance per-request. Bot token is fetched from `adapter_credentials` on every request via Neon (Hyperdrive connection pool — ~5ms). No KV needed for the token: Hyperdrive maintains pooled connections; the token query is a trivial indexed lookup and does not justify a KV cache layer with its associated quota math.

**Framework:** `grammy` (not Telegraf). grammy is designed for Deno/edge runtimes, zero Node.js dependencies, runs natively in Cloudflare Workers.

---

## Group Chat Registration

Tenants register team group chats in `/settings/integrations/telegram`. The bot must already be a member of the group (tenant invites it manually).

### Data Model

```sql
telegram_group_chats (
  id          UUID     PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID     NOT NULL,
  chat_id     BIGINT   NOT NULL,         -- Telegram chat_id (negative for groups)
  title       TEXT,                      -- fetched from Telegram getChatAdministrators
  is_active   BOOLEAN  DEFAULT true,
  added_at    TIMESTAMPTZ DEFAULT now(),
  UNIQUE (tenant_id, chat_id)
)
```

On registration:
1. Tenant pastes group chat ID (visible in Telegram via `/start@userinfobot` or export)  
2. Zync calls `getChat` to verify bot is a member and fetches title  
3. Inserts into `telegram_group_chats`; activates message logging

---

## Message Logging

Every text message in a registered group chat is stored. Bot responses are stored alongside user messages. Voice messages are auto-transcribed before storing.

```sql
telegram_messages (
  id                 UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id          UUID        NOT NULL,
  chat_id            BIGINT      NOT NULL,
  message_id         BIGINT      NOT NULL,
  user_telegram_id   BIGINT,
  username           TEXT,
  first_name         TEXT,
  text               TEXT        NOT NULL,
  is_bot_response    BOOLEAN     DEFAULT false,
  from_voice         BOOLEAN     DEFAULT false,    -- true = transcribed from voice
  created_at         TIMESTAMPTZ NOT NULL,         -- Telegram message.date (Unix → timestamptz)
  indexed_at         TIMESTAMPTZ DEFAULT now(),
  UNIQUE (tenant_id, chat_id, message_id)
)

-- Full-text search
CREATE INDEX telegram_messages_fts
  ON telegram_messages USING GIN (to_tsvector('simple', text));

-- Inbox scan
CREATE INDEX telegram_messages_chat_date
  ON telegram_messages (tenant_id, chat_id, created_at DESC);
```

Retention: 12 months rolling. Cleanup runs via **pg_cron** (Neon extension) — no CF cron trigger needed:
```sql
SELECT cron.schedule('tg-msg-retention', '0 3 * * *',
  'DELETE FROM telegram_messages WHERE created_at < now() - interval ''12 months''');
```
pg_cron runs inside Neon's Postgres compute. Requires Neon project configured with `pg_cron` extension enabled. Retention period configurable via `TG_RETENTION_MONTHS` env var applied at migration time.

**Non-group chats are not logged.** Personal DM interactions are not stored in `telegram_messages` — they go through the DM interface flows below.

---

## User Account Linking (`/connect`)

Users link their personal Telegram chat_id to their Zync account to receive personal notifications.

Flow:
1. User sends `/connect` to the tenant's bot in a private DM  
2. Bot generates a one-time 6-digit code (stored in KV `tg_connect:{tenantId}:{code}`, 10min TTL, value = `{ chatId, userId: null }`)  
3. Bot replies: "Your code is **381920**. Enter it in Zync → Profile → Alert settings."  
4. User enters code in Zync UI → API resolves code from KV → saves `chat_id` to `user_preferences.telegram_chat_id`  
5. KV entry deleted; confirmation sent to Telegram chat

`user_preferences` gains:
```sql
-- New column:
telegram_chat_id BIGINT  -- NULL until /connect completed
```

---

## Personal DM Interface

When a user sends any message to the bot in a private chat, the Worker resolves their identity before doing anything else.

### Auth Resolution

Identity resolution uses a **module-scope Map** (in-isolate, zero quota cost) with Neon fallback on cache miss or cold start:

```ts
// packages/telegram-bot/src/identity.ts — module scope, survives across requests in same isolate
const identityCache = new Map<string, { identity: TelegramBotIdentity; expiresAt: number }>()

async function resolveIdentity(
  tenantId: string,
  chatId: bigint,
  neon: NeonClient,
): Promise<TelegramBotIdentity | null> {
  const key = `${tenantId}:${chatId}`
  const hit = identityCache.get(key)
  if (hit && hit.expiresAt > Date.now()) return hit.identity

  const row = await neon.queryOne(`
    SELECT u.id, u.email, u.role, up.telegram_chat_id
    FROM users u
    JOIN user_preferences up ON up.user_id = u.id
    WHERE up.telegram_chat_id = $1
      AND u.tenant_id = $2
      AND u.frozen_at IS NULL
  `, [chatId, tenantId])

  if (!row) return null

  const identity = buildIdentity(row)  // hydrates permissions via RBAC
  identityCache.set(key, { identity, expiresAt: Date.now() + 5 * 60 * 1000 })
  return identity
}
```

Warm isolate (same CF Worker instance, same PoP): Map hit, zero latency, zero Neon cost.  
Cold start / new isolate: one Neon query, cached for 5 minutes in isolate memory.

`→ not found: reply "Send /connect to link your Zync account." — stop`

All subsequent logic runs under the resolved user identity. Permission checks use the same `hasPermission(user, key)` helper as web API routes. Module availability checked against `tenant_modules`.

### Telegram UI Patterns

Telegram inline keyboards replace web UI affordances:

| Web UI | Telegram equivalent |
|--------|---------------------|
| Button / action | Inline keyboard button |
| Dropdown | Inline keyboard row of options |
| Pagination | `◀ Prev` / `Next ▶` buttons with offset in callback_data |
| Modal confirm | Follow-up message: "Are you sure? [Yes, confirm] [Cancel]" |
| Form field | Bot sends "Type X:" with `force_reply: true`; next user message = field value |
| Navigation tabs | Top-level home menu buttons |
| Toast | Short reply message, auto-deletes after 3s where possible |

**Callback data** — grammy encodes up to 64 bytes per button. Format:
```
{action}:{entityId}:{page}
e.g. "task_status:uuid-here:0"
     "ticket_reply:uuid-here"
     "inv_filter:overdue:0"
```

### Session State (Durable Objects)

Multi-step flows (replies, comments, new entries) require persistent state between messages. KV is disqualified: its 30-second minimum `cacheTtl` means a session written at step 1 may return the pre-step-1 value on step 2 if both arrive within 30 seconds. DO transactional storage is strongly consistent — write immediately visible on next read.

**`TelegramSessionDO`** — one DO instance per `tenantId:chatId`, named exactly that way:

```ts
// packages/telegram-bot/src/session-do.ts
export class TelegramSessionDO implements DurableObject {
  private state: DurableObjectState

  async fetch(request: Request): Promise<Response> {
    const { op, data } = await request.json()
    if (op === 'get')   return Response.json(await this.state.storage.get('s') ?? null)
    if (op === 'set')   { await this.state.storage.put('s', data); return Response.json({ ok: true }) }
    if (op === 'clear') { await this.state.storage.delete('s');    return Response.json({ ok: true }) }
    return new Response('bad op', { status: 400 })
  }
}
```

`ConversationSession` interface (see §Interface Contracts) wraps DO access for callers:

```ts
// Session state shape:
interface SessionState {
  action: 'ticket_reply' | 'task_comment' | 'task_create_title' | 'invoice_upload_confirm'
  entityId?: string
  step: number
  data: Record<string, unknown>
  expiresAt: number   // Unix ms — DO storage has no native TTL; checked on read
}
```

DO instance auto-expires via `expiresAt` field: `get()` returns null if `expiresAt < Date.now()` and deletes stale entry. Default TTL: 5 minutes. `set()` always writes fresh `expiresAt = now + ttl`.

Incoming DM text message handler calls `session.get(chatId)` first. If non-null → continue flow. If null and not a command → route to AI assistant.

### Home Menu

On `/start` or `/menu` (also shown to unrecognized freetext when no session):

```
👋 Hi [FirstName]

[📋 Tasks]     [🎫 Tickets]
[📄 Invoices]  [👥 Customers]
[📚 KB]        [🤖 Ask AI]
```

Buttons only shown for modules the user has permission to access. Layout is 2×N inline keyboard. If user has zero accessible modules, shows only `[🤖 Ask AI]`.

---

### Module: Tasks

**Entry:** `/tasks` or [📋 Tasks] from home menu  
**Permission:** `tasks.view` (own tasks only for VIEWER; all tasks for ADMIN/MANAGER)

**Task list:**
```
📋 My Open Tasks

• Fix login bug — Due Jun 3 [🔍]
• Design new dashboard — No due date [🔍]
• Review API spec — Due Jun 1 ⚠️ [🔍]

[◀ Prev]  1/3  [Next ▶]
[➕ New Task]  [🏠 Menu]
```

5 tasks per page. Each row has a `[🔍]` button → opens task detail.

**Task detail:**
```
📋 Fix login bug
Status: In Progress  Priority: High
Assigned: Dana Cohen
Due: Jun 3, 2026
Project: Client Portal v2

Auth token refresh fails on mobile Safari...

[✏️ Comment]  [🔄 Status]  [👁 View in Zync]
[⬅ Back]
```

**[🔄 Status]** → inline keyboard with valid next statuses:
```
Change status to:
[To Do]  [In Progress]  [Done]  [Blocked]
[Cancel]
```

**[✏️ Comment]** → sets KV session `{ action: 'task_comment', entityId: taskId }` → bot sends:
```
Reply with your comment (or /cancel):
```
With `force_reply: true`. Next text from user → POST to task comment API → confirm reply.

**[➕ New Task]** → multi-step:
1. "Task title?" (force_reply) → KV session step 1
2. "Due date? (e.g. Jun 5, or skip)" → step 2
3. Confirm card → [Create] [Cancel] → POST to tasks API

---

### Module: Tickets (CRM)

**Entry:** `/tickets` or [🎫 Tickets] from home menu  
**Permission:** `tickets.view`

**Ticket list:**
```
🎫 Open Tickets  [All ▼]

• Acme Ltd — Can't export invoices — 2h ago [🔍]
• Dan Cohen — Password reset not working — 1d ago [🔍]
• New Lead — Pricing inquiry — 3d ago [🔍]

[◀ Prev]  1/5  [Next ▶]
[🏠 Menu]
```

Filter button `[All ▼]` → inline keyboard: `[All] [Open] [Pending] [Resolved]`

**Ticket detail:**
```
🎫 #CRM-0042 — Acme Ltd
Status: Open  Priority: Normal
Subject: Can't export invoices

Customer reports PDF export fails on Firefox...

Last reply: Dana (1h ago): "Looking into this..."

[✏️ Reply]  [✅ Close]  [🔄 Assign]  [👁 View in Zync]
[⬅ Back]
```

**[✏️ Reply]** → KV session `{ action: 'ticket_reply', entityId: ticketId }` → bot: "Type your reply:" (force_reply) → POST to ticket message API → sends outbound email/Telegram to customer if configured.

**[✅ Close]** → confirm: "Close this ticket? [Yes, close] [Cancel]" → PATCH ticket status.

**[🔄 Assign]** → inline keyboard of team members with `tickets.manage` permission.

---

### Module: Invoices

**Entry:** `/invoices` or [📄 Invoices] from home menu  
**Permission:** `invoices.view`

**Invoice list:**
```
📄 Invoices  [All ▼]

• #INV-0183 — Acme Ltd — ₪4,500 — Sent [🔍]
• #INV-0182 — Dan Cohen — ₪1,200 — Overdue ⚠️ [🔍]
• #INV-0181 — TechCo — ₪8,000 — Draft [🔍]

[◀ Prev]  1/8  [Next ▶]
[🏠 Menu]
```

Filter: `[All] [Draft] [Sent] [Overdue] [Paid]`

**Invoice detail:**
```
📄 INV-0183 — Acme Ltd
Amount: ₪4,500  Status: Sent
Issue date: May 28  Due: Jun 12

Lines:
• Web Development (8h × ₪400) — ₪3,200
• Domain renewal — ₪1,300

[📤 Send Reminder]  [✅ Mark Paid]  [👁 View in Zync]
[⬅ Back]
```

**[📤 Send Reminder]** → confirms "Send payment reminder to acme@example.com? [Send] [Cancel]" → POST to invoice reminder API.

**Document upload → Expense:**  
User sends a photo or PDF document in DM → bot detects file type → runs OCR (existing `ai-assistant` expense OCR pipeline) → shows extracted data with inline keyboard:
```
📎 Document received

Detected expense:
  Vendor: Office Depot
  Amount: ₪342
  Date: May 30

[✅ Save as Expense]  [✏️ Edit]  [❌ Discard]
```
[✏️ Edit] → KV session for field correction (amount, vendor, date, category).

---

### Module: Customers

**Entry:** `/customers` or [👥 Customers] from home menu  
**Permission:** `customers.view`

**Customer search:**  
`/customer <name>` or [👥 Customers] → prompt: "Search customer name:" (force_reply)

**Customer card:**
```
👤 Acme Ltd
Email: billing@acme.com  Phone: 050-1234567

Open tickets: 2  Unpaid invoices: 1 (₪4,500)
Last activity: 2 days ago

[🎫 Tickets]  [📄 Invoices]  [➕ New Ticket]
[👁 View in Zync]  [⬅ Back]
```

[🎫 Tickets] / [📄 Invoices] → filtered list for that customer.

---

### Module: KB Search

**Entry:** `/kb <query>` or [📚 KB] from home menu  
**Permission:** `kb.view`

```
📚 "invoice export"

Found 3 articles:

1. How to export invoices as PDF
   …click Settings → Invoices → Export…  [Read]

2. Invoice PDF template customization
   …under Branding settings… [Read]

3. Bulk invoice export
   …select multiple invoices… [Read]

[🏠 Menu]
```

[Read] → sends full article text via `sendChunked`.

---

### AI Assistant in DMs

Any freetext message with no pending session → routes to `ai-assistant` RAG pipeline with:
- Full user context (their tasks, customers, invoices)
- KB chunks from vector search
- Last 5 DM exchanges as conversation history (from `ai_chat_messages` where `metadata.telegram_chat_id = X`)

Same as web AI chat, different input surface. Replies via `sendChunked`.

`/ask <question>` explicitly invokes this (bypasses session check).

---

## Commands

### Team Commands (available in registered group chats + personal DMs)

| Command | Description |
|---------|-------------|
| `/summarize <query>` | NL time query: "last 2 hours", "today about client Acme", "last week by Dana about invoices" |
| `/search <keywords>` | FTS across stored group messages |
| `/help` | List available commands |

**`/summarize` implementation** (adapted from Botmaster `summarize.ts`):
1. Parse natural language query via Claude (`ANTHROPIC_API_KEY`) → structured `{ fromDate, limit, userFilter, topicFilter }`
2. Query `telegram_messages` with filters
3. Format transcript (max 500 messages, 24k chars, IL timezone timestamps)
4. Generate summary via Claude; send via `sendChunked`
5. Language-aware: respond in Hebrew if messages are Hebrew, English if English

### Zync Module Commands (available in group chats + personal DMs)

| Command | Description | Tier |
|---------|-------------|------|
| `/tasks` | My open tasks (top 5 by due date) | Business+ |
| `/task <id or title>` | Task detail + status | Business+ |
| `/invoice <customer>` | Latest invoice for customer | Business+ |
| `/customer <name>` | CRM customer card (name, balance, last activity) | Business+ |
| `/lead <name>` | Lead status + stage | Business+ |
| `/kb <query>` | KB search → top 3 articles | Business+ |

Commands in **group chats** run as the authenticated tenant context (no per-user auth). Commands in **personal DMs** run under the linked user's identity and permissions. All responses respect `tenant_modules` enable/disable state.

### @-Mention Handler

When the bot is @-mentioned in a group chat:

1. Extract question from message text
2. Fetch recent chat history (last 30 messages for context, same chat)
3. Route to `ai-assistant` RAG pipeline: `retrieveContext(tenantId, question)` returns KB + CRM + task chunks
4. Build prompt: system context + Zync data chunks + recent chat history + question
5. Reply via `sendChunked`, reply-threaded to the original message

This routes through the existing `ai-assistant` infrastructure. No duplicate LLM call chain. The Telegram handler is just a new entry point to the same `ai-assistant` Worker.

Per `ai-assistant` spec (line 179): no persistent Telegram session — recent chat history from `telegram_messages` serves as conversation context.

### Voice Messages

Voice messages in registered group chats:
1. Bot downloads OGG file via Telegram `getFile` + bot token
2. Transcribes via `ai-assistant` Worker (Cloudflare Workers AI: `@cf/openai/whisper-tiny-en` or route to Whisper API)
3. Stores transcribed text in `telegram_messages` (`from_voice = true`)
4. Replies with the transcription (italic, collapsible): _[Transcription: "..."]_

---

## Notification Channel: Telegram

Telegram is a full peer to email and push notifications — any `NotificationType` that can go to email can go to Telegram. Users opt in per-type; admins configure tenant-level routing to groups and lists.

### Adapter Position

The notification delivery pipeline already fans out to multiple channels:

```
Notification created
    → in-app (always)
    → email (if user opted in for type)
    → Telegram (if user opted in OR tenant routing rule matches)
```

Telegram delivery Worker: `sendMessage` via `https://api.telegram.org/bot{TENANT_TOKEN}/sendMessage`. Tenant token from `adapter_credentials`. No Telegram bot connection required to *receive* — delivery is outbound only.

### Personal Notifications (user opt-in)

Each user controls which notification types reach their personal Telegram (linked via `/connect`):

```ts
interface NotificationPreferences {
  email: NotificationType[]       // existing
  telegram: NotificationType[]    // personal — requires /connect completed
}
```

Configured in Profile → Alert settings. Same UI as email toggles, new Telegram column. Requires `user_preferences.telegram_chat_id` to be set.

If user has Telegram opted in but hasn't `/connect`-ed, notification delivery silently skips Telegram (no error — same as email with no address).

### Tenant-Level Routing (admin broadcast)

Admins route specific notification types to group chats or named lists — independent of per-user opt-in:

```sql
telegram_notification_lists (
  id          UUID      PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID      NOT NULL,
  name        TEXT      NOT NULL,          -- e.g., "Finance Team", "All Staff"
  chat_ids    BIGINT[]  NOT NULL DEFAULT '{}',  -- user chat_ids + group chat_ids (bot must be member)
  created_at  TIMESTAMPTZ DEFAULT now(),
  updated_at  TIMESTAMPTZ DEFAULT now()
)

telegram_notification_routing (
  id                UUID   PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id         UUID   NOT NULL,
  notification_type TEXT   NOT NULL,   -- NotificationType value or '*' (wildcard = all types)
  target_type       TEXT   NOT NULL,   -- 'list' | 'group' | 'chat_id'
  target_id         TEXT   NOT NULL,   -- list UUID | group chat_id | personal chat_id
  created_at        TIMESTAMPTZ DEFAULT now(),
  UNIQUE (tenant_id, notification_type, target_type, target_id)
)
```

Delivery resolution:
1. Collect personal recipients: users with `telegram_chat_id` who opted in for this type
2. Collect tenant routing recipients: all `target_id`s matching `notification_type` + `'*'` wildcard rows → expand lists to their `chat_ids` arrays
3. Union and deduplicate all chat_ids
4. Send one `sendMessage` per unique chat_id

### Notification Message Format

```
🔔 *Invoice Paid*
Customer: Acme Ltd
Amount: ₪4,500 · Invoice #INV-2024-0183
[View →](https://app.zync.is/invoices/...)
```

- `parse_mode: 'Markdown'`
- Each `NotificationType` has a template function: `packages/notifications/src/channels/telegram.ts`
- Templates parallel the existing email templates — same data, different format
- Deep link buttons where applicable (links into the Zync web app)
- **Actionable notifications**: for DM recipients with linked accounts, buttons are appended where immediate action makes sense (e.g., `invoice.paid` → `[View Invoice]`; `ticket.created` → `[Reply] [View]`)

### Full NotificationType coverage

All types currently delivered to email are also deliverable to Telegram:

| Type | Suggested emoji | Action buttons (DM only) |
|------|----------------|--------------------------|
| `task.assigned` | 📋 | [View Task] [Comment] |
| `task.updated` | 📋 | [View Task] |
| `task.comment` | 💬 | [Reply] [View Task] |
| `invoice.updated` | 📄 | [View Invoice] |
| `invoice.overdue` | ⚠️ | [Send Reminder] [View] |
| `ticket.created` | 🎫 | [Reply] [View] |
| `ticket.replied` | 💬 | [Reply] [Close] [View] |
| `ticket.resolved` | ✅ | [View] |
| `user.invited` | 👤 | — |
| `user.approved` | ✅ | — |
| `user.frozen` | 🔒 | — |
| `approval.required` | ⏳ | [Approve] [Reject] |

Action buttons on DM notifications are inline keyboards that trigger the same flows as the DM interface (session-based confirmation where needed).

---

## Settings UI (`/settings/integrations/telegram`)

Tabs:

### Bot Setup (existing from comms spec, displayed here)
- Token input + BotFather checklist (adapted from Botmaster `BotFatherChecklist`)
- Bot status: connected / disconnected / error
- Disconnect button (calls `deleteWebhook`, removes from `adapter_credentials`)

### Group Chats
- Table: Title, Chat ID, Status, Message count, Added date
- "Add group" — paste chat ID → validates membership → adds
- Toggle active/inactive per group (pauses logging without removing)
- Remove group (stops logging; existing messages retained)

### Notification Lists
- Table: Name, Members count (chat_ids), Routes count
- Create/edit list: name + comma-separated chat IDs or group IDs
- Notification routing table: per notification type → target list/group/chat_id
- "Test" button: sends a test message to target

### Personal Connections
- Admin view: table of users with linked/unlinked Telegram accounts
- "Unlink" action per user (clears `user_preferences.telegram_chat_id`)

### Message History (admin only)
- Per-group log viewer: date range, user filter, keyword search (adapted from Botmaster `BotHistoryViewer`)
- Export to CSV

---

## Permissions

| Permission | Role | Notes |
|------------|------|-------|
| `telegram.manage` | Owner, Admin | Bot setup, group registration, notification lists |
| `telegram.view_history` | Owner, Admin | Message history viewer in settings |
| `telegram.connect_personal` | All roles | `/connect` personal account linking + DM interface access |

DM interface actions use the same permission keys as the web UI. A user who can't `tickets.view` in the web app can't see tickets in Telegram DMs either. No new permission keys for DM actions — they delegate to existing module permissions.

---

## API Endpoints

```
# Group chats
GET    /api/telegram/groups                      → list registered groups
POST   /api/telegram/groups                      → { chatId } → validate + register
PATCH  /api/telegram/groups/:id                  → { isActive }
DELETE /api/telegram/groups/:id                  → remove

# Notification lists
GET    /api/telegram/notification-lists          → list
POST   /api/telegram/notification-lists          → { name, chatIds }
PUT    /api/telegram/notification-lists/:id      → full update
DELETE /api/telegram/notification-lists/:id      → remove

# Notification routing
GET    /api/telegram/notification-routing        → list routes
POST   /api/telegram/notification-routing        → { notificationType, targetType, targetId }
DELETE /api/telegram/notification-routing/:id    → remove

# Personal connection
POST   /api/telegram/connect                     → { code } → links chat_id to current user
DELETE /api/telegram/connect                     → unlink current user

# Message history (admin)
GET    /api/telegram/messages                    → { groupId, from, to, keyword, limit }

# Test notification
POST   /api/telegram/test-notification           → { targetType, targetId, type }
```

---

## Foundation Deltas

| Addition | Type | Notes |
|----------|------|-------|
| `telegram_group_chats` | Table | |
| `telegram_messages` | Table | |
| `telegram_notification_lists` | Table | |
| `telegram_notification_routing` | Table | |
| `user_preferences.telegram_chat_id` | Column delta | Nullable BIGINT on existing table |
| `tg_connect:{tenantId}:{code}` | KV namespace `KV_CACHE` | 10min TTL for /connect codes only |
| `TelegramSessionDO` | Durable Object class | Per-`tenantId:chatId` session state for DM multi-step flows; named DO |
| pg_cron job `tg-msg-retention` | Neon pg_cron | Daily retention cleanup; no CF cron trigger |
| `TelegramBotIdentity` auth cache | Module-scope Map | In-isolate, 5min TTL; no KV namespace needed |
| Bot token | No cache | Fetched from `adapter_credentials` via Hyperdrive on each request; fast enough |

---

## Interface Contracts

Seven seams with real depth. Each hides non-trivial complexity behind a stable interface; each has two or more plausible implementations (Service Binding vs HTTP, DO vs KV, etc.).

### 1. `ZyncBotClient` — Module Data Access via Service Binding

Telegram Worker never calls Neon directly for module data. All reads/writes go through `ZyncBotClient`, backed by a CF Service Binding to the main `zync-api` Worker. Zero network overhead; same auth middleware; no duplicated DB queries.

```ts
// packages/telegram-bot/src/zync-client.ts
interface ZyncBotClient {
  // Tasks
  listTasks(params: { userId: string; page: number; pageSize: number }): Promise<PageResult<Task>>
  getTask(taskId: string): Promise<Task | null>
  updateTaskStatus(taskId: string, status: string): Promise<void>
  addTaskComment(taskId: string, text: string): Promise<void>
  createTask(data: { title: string; dueDate?: string }): Promise<Task>

  // Tickets
  listTickets(params: { status?: string; page: number }): Promise<PageResult<Ticket>>
  getTicket(ticketId: string): Promise<Ticket | null>
  replyTicket(ticketId: string, text: string): Promise<void>
  closeTicket(ticketId: string): Promise<void>
  assignTicket(ticketId: string, assigneeId: string): Promise<void>

  // Invoices
  listInvoices(params: { status?: string; page: number }): Promise<PageResult<Invoice>>
  getInvoice(invoiceId: string): Promise<Invoice | null>
  markInvoicePaid(invoiceId: string): Promise<void>
  sendInvoiceReminder(invoiceId: string): Promise<void>

  // Customers
  searchCustomers(query: string): Promise<Customer[]>
  getCustomer(customerId: string): Promise<Customer | null>

  // KB
  searchKb(query: string): Promise<KbArticle[]>
}
```

Service Binding adapter (`ZyncServiceBindingClient`) wraps all calls in the identity context of the resolved `TelegramBotIdentity` — passes `X-Bot-UserId` and `X-Bot-TenantId` headers to the zync-api Worker, which enforces RBAC as normal.

Test adapter (`ZyncStubClient`) enables unit tests without live Worker. Two adapters = real seam.

### 2. `ConversationSession` — DO-backed Multi-step Flow State

Hides `TelegramSessionDO` addressing, operation encoding, and TTL logic behind a 3-method interface. Callers (command handlers) never know about Durable Objects.

```ts
// packages/telegram-bot/src/session.ts
interface ConversationSession {
  get(chatId: bigint): Promise<SessionState | null>
  set(chatId: bigint, state: Omit<SessionState, 'expiresAt'>, ttlMs?: number): Promise<void>
  clear(chatId: bigint): Promise<void>
}
```

Backed by `TelegramSessionDO` named `${tenantId}:${chatId}`. In-memory stub for tests. Two adapters = real seam.

### 3. `TelegramBotIdentity` — RBAC Seam Between Runtimes

Encapsulates Telegram-to-Zync user resolution. Callers ask `identity.hasPermission('tickets.view')` — not "look up the user in `user_preferences` and then join to `rbac_assignments`". The resolution logic (module-scope Map → Neon fallback, defined in §Auth Resolution) is invisible to all command handlers.

```ts
// packages/telegram-bot/src/identity.ts
interface TelegramBotIdentity {
  readonly userId: string
  readonly tenantId: string
  readonly email: string
  readonly chatId: bigint
  readonly role: string
  hasPermission(key: string): boolean        // delegates to RBAC helper
  canAccessModule(moduleKey: string): boolean // checks tenant_modules
}
```

Null identity (unlinked user) is represented by `null` return from `resolveIdentity()`, not a partial object. Callers check for null once at the top of the DM handler — all subsequent code assumes non-null identity.

### 4. `NotificationAdapter` — Delivery Channel Seam (lives in comms spec)

Two delivery adapters now exist (email, Telegram). Interface belongs in `system-communications-notifications`. Defined there; referenced here.

```ts
// packages/notifications/src/adapter.ts  (in comms spec scope)
interface NotificationAdapter {
  readonly id: string  // 'email' | 'telegram' | 'push'
  canDeliver(userId: string, type: NotificationType): Promise<boolean>
  deliver(userId: string, notification: DeliverableNotification): Promise<DeliveryResult>
}

interface DeliverableNotification {
  type: NotificationType
  title: string
  body: string
  entityType?: string
  entityId?: string
  actionButtons?: ActionButton[]   // adapter renders or ignores as appropriate
}

interface DeliveryResult {
  delivered: boolean
  error?: string
}
```

`TelegramNotificationAdapter` implements this: `canDeliver` checks `user_preferences.telegram_chat_id` is set and type is in `notification_channels.telegram[]`. `deliver` formats + sends via Bot API. Email adapter pre-exists; adding Telegram = genuine second implementation = real seam.

### 5. `CallbackEncoder` — 64-byte Telegram Invariant

Telegram enforces a hard 64-byte limit on `callback_data`. Two UUIDs (72 bytes) already exceeds it. `CallbackEncoder` encodes module-action + entity reference into ≤ 64 bytes and decodes it back. Encoding strategy: 8-char UUID prefix + action mnemonic + separator.

```ts
// packages/telegram-bot/src/callback.ts
interface CallbackEncoder {
  encode(action: ActionCode, entityId: string, extras?: string[]): string
  decode(raw: string): { action: ActionCode; entityId: string; extras: string[] }
}

// Action mnemonics (2-4 chars, registered in ACTION_REGISTRY):
type ActionCode =
  | 'tk_s'   // task_status
  | 'tk_c'   // task_comment
  | 'tkt_r'  // ticket_reply
  | 'tkt_cl' // ticket_close
  | 'inv_p'  // invoice_paid
  | 'inv_r'  // invoice_reminder
  | 'cust'   // customer_view
  // ... (full registry in implementation)

// Format: "{action}|{entityId8}|{extras...}"
// entityId8 = first 8 chars of UUID; collision risk negligible at tenant scale
// Max example: "tkt_cl|a1b2c3d4|0" = 18 bytes. Headroom for extras.
```

`encode()` throws `CallbackTooLargeError` if output > 64 bytes. Caught at bot build time in tests — never silently truncated in prod.

### 6. `TelegramScreen` — Navigation Model

Each DM module is a `TelegramScreen`. Navigation (`[⬅ Back]` to list from detail, `[👥 Customers]` from ticket customer link) goes through the screen registry — not hardcoded cross-module references.

```ts
// packages/telegram-bot/src/screens.ts
interface TelegramScreen {
  readonly screenId: string
  render(
    ctx: grammy.Context,
    identity: TelegramBotIdentity,
    client: ZyncBotClient,
    session: ConversationSession,
    params?: Record<string, unknown>,
  ): Promise<void>
}

// Registry — all screens registered at bot init:
const SCREEN_REGISTRY = new Map<string, TelegramScreen>()

function navigate(ctx: grammy.Context, screenId: string, params?: Record<string, unknown>): Promise<void> {
  const screen = SCREEN_REGISTRY.get(screenId)
  if (!screen) throw new Error(`Unknown screen: ${screenId}`)
  return screen.render(ctx, identity, client, session, params)
}
```

Screen IDs: `'home'`, `'tasks.list'`, `'tasks.detail'`, `'tickets.list'`, `'tickets.detail'`, `'invoices.list'`, `'invoices.detail'`, `'customers.search'`, `'customers.detail'`, `'kb.search'`.

Navigation graph encoded in CallbackEncoder action codes: each button tap decodes to `(action, entityId)` → `navigate(screenId, { entityId })`. No hardcoded `screenId` strings outside `SCREEN_REGISTRY`.

### 7. `ConversationContext` — RAG Retrieval for Group Mention Handler

The @-mention handler in group chats needs KB + CRM + task context to answer questions. This retrieval logic belongs in a dedicated module, not inline in the grammy middleware. Mirrors how the web AI chat uses `retrieveContext()` from the `ai-assistant` Worker.

```ts
// packages/telegram-bot/src/conversation-context.ts
interface ConversationContext {
  retrieve(params: {
    tenantId: string
    question: string
    recentMessages: TelegramMessage[]  // last N messages from telegram_messages for this chat
    userId?: string                    // undefined = group context (no user-scoped data)
  }): Promise<ContextChunk[]>
}

interface ContextChunk {
  source: 'kb' | 'task' | 'ticket' | 'customer' | 'invoice'
  text: string
  score: number
}
```

`AIAssistantContextRetriever` implements this by calling the `ai-assistant` Worker (Service Binding) `retrieveContext(tenantId, question)`. Stub retriever for tests. Two adapters = real seam.

`ConversationContext` separates retrieval from prompt construction. The mention handler calls `retrieve()`, gets chunks, builds the Claude prompt. If the retrieval strategy changes (vector search vs BM25 vs hybrid), zero changes to prompt construction code.

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Bot framework | grammy | Edge-native (Deno/CF Workers design), zero Node deps, same API surface as Telegraf for command/middleware/handler patterns; Telegraf uses Node-only APIs |
| Bot runtime model | Stateless Worker per-request | No persistent process; token fetched from Neon via Hyperdrive (no KV cache needed — Hyperdrive pooling makes token query ~5ms); fits CF Workers execution model |
| Message storage | Neon (Postgres) | Per-tenant; consistent with all other Zync data; FTS via `pg_tsvector`; Botmaster uses SQLite (per-bot flat file) which doesn't fit multi-tenant SaaS |
| AI Q&A routing | Delegate to `ai-assistant` spec | Avoids duplicate RAG pipeline; Telegram is a new input surface, not a new AI implementation |
| Summarize logic | Adapted from Botmaster `summarize.ts` | NL time query → structured parse → message window → Claude summary is proven; port from Gemini CLI to `ANTHROPIC_API_KEY` inline call |
| Chunker | Port from Botmaster `chunker.ts` | Code-fence-aware 4000-char splitter; already handles Telegram's edge cases |
| Personal linking | `/connect` code flow | Stateless; no OAuth; works in any Telegram client; token in KV avoids extra DB table |
| Notification routing | Separate `telegram_notification_routing` table | Decouples routing rules from list membership; allows fine-grained per-type targeting without denormalizing lists |
| Voice transcription | CF Workers AI Whisper | Already available via `AI` binding; no new secret; Botmaster used external Vibeflare/Groq which aren't in Zync stack |
| Group ID input | Manual paste | Telegram has no "select from your groups" API accessible to bots; user must paste ID — same friction as Slack channel IDs |
| Message retention | 12 months rolling | Balances search utility against storage cost; configurable via env var `TG_RETENTION_MONTHS` |
| DM auth model | chat_id → user_preferences, module-scope Map cached | No per-message Telegram auth; users pre-link via /connect; module-scope Map (in-isolate, 5min TTL) avoids KV quota; cold starts hit Neon — same cost as KV cache miss |
| DM multi-step state | Durable Objects (`TelegramSessionDO`) | KV disqualified: 30s minimum cacheTtl = stale session reads mid-flow (step 1 write invisible to step 2 read within 30s). DO transactional storage is strongly consistent; named by `tenantId:chatId` |
| Token caching | None — direct Neon via Hyperdrive | KV for token = quota burn (every webhook = 1 read; one active tenant = thousands/day). Hyperdrive pool makes Neon query ~5ms. Adding KV cache adds complexity with no meaningful latency gain. |
| Message retention | pg_cron in Neon | No CF cron trigger needed; pg_cron runs inside Neon compute; avoids consuming one of the paid plan's 250 cron slots |
| DM UI primitives | grammy InlineKeyboard + force_reply | InlineKeyboard covers 90% of web UI actions without state; force_reply handles single-field text input without a full form session |
| Notification adapter | Parallel to email, same NotificationType set | Telegram is a delivery channel, not a feature — same event types, different format; keeps notification system extensible (future: WhatsApp, Slack) |
| Actionable notifications | Inline buttons on DM notifications | Deep-links into web app for group/list recipients; inline action buttons for DM recipients who have linked accounts and active bot DM |
