# Telegram Bot: Group Assistant & Notification Channel — Implementation Plan

**Spec:** docs/specs/2026-05-31-telegram-bot.md  ·  **Slug:** telegram-bot  ·  **Wave:** 8
**Depends on:** ai-assistant, crm-support-center, foundation-auth-rbac, kb-module, marketing-leads-pipeline, system-communications-notifications, tasks-board-engine

## Goal
Extend the BYOT Telegram adapter (already established in `system-communications-notifications`: token storage in `adapter_credentials` + `setWebhook`) into a full team assistant and a third notification delivery channel. Delivers: a personal DM interface (Tasks/Tickets/Invoices/Customers/KB/AI as inline-keyboard screens under per-user RBAC), a group assistant that logs messages and answers `/summarize`, `/search`, and @-mentions, account linking via `/connect`, and a `TelegramNotificationAdapter` that fans every `NotificationType` out to personal DMs, group chats, and named broadcast lists. The bot runs stateless on Cloudflare Workers using `grammy`, reconstructing the bot instance per webhook request from the decrypted tenant token.

## Architecture
- **New package `@zync/telegram-bot`** (`packages/telegram-bot`) holds the bot runtime: webhook entry, identity resolution, session DO, callback encoder, screen registry, conversation-context retriever, summarize/search, and the `ZyncBotClient` Service-Binding adapter.
- **Inbound:** `POST /api/webhooks/telegram/:tenantId` (route already declared upstream in comms + CRM specs; this plan extends its handler) lives in `zync-api`. It looks up the bot token from `adapter_credentials` (`adapter_id='telegram'`) via Hyperdrive→Neon using `loadAdapterCredential` + `decryptCredential`, verifies the `X-Telegram-Bot-Api-Secret-Token` header, builds a `grammy` `Bot`, and dispatches.
- **DM identity:** module-scope `Map` cache (5 min TTL) → Neon fallback joining `users` + `user_preferences` on the new `user_preferences.telegram_chat_id` column. RBAC hydrated via `hasScope`/`requirePermission` helpers and `getTenantModules` (`tenant_modules`) from `foundation-auth-rbac` / `module-management`.
- **Module data access:** the bot never queries module tables directly. All reads/writes go through `ZyncBotClient` (Service Binding to `zync-api`), which forwards `X-Bot-UserId`/`X-Bot-TenantId` and re-uses existing REST routes: `/api/tasks*` (tasks-board-engine), `/api/tickets*` (crm-support-center), `/api/invoices*` and `/api/invoices/:id/reminder`/`/mark-paid` (invoices-core), `/api/customers*` (customers-module), `/api/kb/search` (kb-module), `/api/leads*` (marketing-leads-pipeline).
- **Group logging:** every text/voice message in a registered `telegram_group_chats` row is stored in `telegram_messages` (FTS GIN index). Voice transcribed via Workers AI Whisper through the `ai-assistant` Worker. Retention via Neon `pg_cron`.
- **AI Q&A:** @-mentions and `/ask` route through `ConversationContext.retrieve()` → `ai-assistant` Worker `retrieveContext(tenantId, question)`; no duplicate RAG. Last-5 DM history pulled from `ai_chat_messages` where `metadata.telegram_chat_id = X`.
- **Notifications:** `TelegramNotificationAdapter` implements the `NotificationAdapter` interface defined in `system-communications-notifications` and is registered in `packages/notifications/src/deliver.ts` `ADAPTERS` array alongside `EmailNotificationAdapter` and `WebPushNotificationAdapter`. Routing rows in `telegram_notification_routing` + `telegram_notification_lists` enable admin broadcast independent of per-user opt-in (`user_preferences.notification_channels.telegram[]`).
- **Settings UI:** new tabbed page `apps/zync-app` at `/settings/integrations/telegram` (Group Chats, Notification Lists, Personal Connections, Message History) gated on `telegram.manage` / `telegram.view_history`.

## Tech Stack
- **Package:** `@zync/telegram-bot` (new) — depends on `grammy`, `@zync/db`, `@zync/auth`, `@zync/notifications`, `@zync/types`, `@zync/ai`, `@zync/config`.
- **Notifications:** `@zync/notifications` (new file `src/channels/telegram.ts` for templates, `src/adapters/telegram.ts` for the adapter).
- **API:** `zync-api` Hono Worker — new route module `src/routes/telegram.ts`; extend existing `src/routes/webhooks/telegram.ts`.
- **App:** `apps/zync-app` (Vite+React) — settings page + Profile→Alert-settings Telegram column.
- **DB:** Drizzle schema in `@zync/db` (`src/schema/telegram.ts`); Neon Postgres via Hyperdrive.
- **Cloudflare bindings:** Durable Object `TelegramSessionDO` (new binding `TELEGRAM_SESSION_DO`); `KV_CACHE` (existing, for `/connect` codes only); `AI` (Workers AI Whisper, via ai-assistant); Service Bindings `ZYNC_API` and `AI_ASSISTANT` from the telegram-bot Worker; `RATE_LIMITER_WEBHOOK` (existing); secrets `INTEGRATION_ENCRYPTION_KEY`, `ANTHROPIC_API_KEY`.
- **Neon extension:** `pg_cron` enabled on the project; retention months from `TG_RETENTION_MONTHS` (default 12) applied at migration time.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 8a — schema | 1, 2 | `packages/db/src/schema/telegram.ts`, migrations, seed perms | Task 1 then 2 (2 seeds perms, parallel-safe with 1 done) |
| 8b — seams | 3, 4, 5, 6, 7 | `packages/telegram-bot/src/{callback,session-do,session,identity,zync-client,conversation-context}.ts` | Yes, all independent after Task 1 |
| 8c — runtime | 8, 9 | `packages/telegram-bot/src/{bot,screens,chunker,summarize,group}.ts`; webhook route | 8 then 9 |
| 8d — notifications | 10, 11 | `packages/notifications/src/channels/telegram.ts`, `adapters/telegram.ts`, `deliver.ts` | 10 then 11 |
| 8e — API | 12 | `zync-api/src/routes/telegram.ts` | After 1, 3–7 |
| 8f — UI | 13, 14 | `apps/zync-app/.../settings/integrations/telegram`, Profile Alert settings | After 12 |
| 8g — infra | 15, 16 | wrangler configs, pg_cron migration, tests | After all |

## Tasks

### Task 1: Database schema — Telegram tables + `user_preferences` column delta
**Blocks:** 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 16  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/telegram.ts`
- Create: `packages/db/migrations/0NNN_telegram_bot.sql`
- Modify: `packages/db/src/schema/index.ts` (export telegram schema)
- Modify: `packages/db/src/schema/auth.ts` (add `telegramChatId` to `user_preferences`)
**Steps:**
- [ ] Define the four tables below in Drizzle (`pgTable`) mirroring the canonical DDL.
- [ ] Add `telegram_chat_id BIGINT` (nullable) to the existing `user_preferences` table — column delta, do NOT recreate the table.
- [ ] Add the FTS GIN index and the chat-date scan index on `telegram_messages`.
- [ ] All `tenant_id` columns are `UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE`; `chat_id`/`message_id`/`user_telegram_id` are `BIGINT` (Telegram IDs, NOT UUIDs).
- [ ] Generate the SQL migration; verify it runs against a Neon branch.
**Schema / Interfaces:**
```sql
CREATE TABLE telegram_group_chats (
  id         UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id  UUID        NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  chat_id    BIGINT      NOT NULL,            -- Telegram chat_id (negative for groups)
  title      TEXT,                            -- fetched via getChat
  is_active  BOOLEAN     NOT NULL DEFAULT true,
  added_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, chat_id)
);
CREATE INDEX telegram_group_chats_tenant ON telegram_group_chats (tenant_id);

CREATE TABLE telegram_messages (
  id               UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id        UUID        NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  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     NOT NULL DEFAULT false,
  from_voice       BOOLEAN     NOT NULL DEFAULT false,  -- true = transcribed from voice
  created_at       TIMESTAMPTZ NOT NULL,                -- Telegram message.date (Unix → timestamptz)
  indexed_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, chat_id, message_id)
);
CREATE INDEX telegram_messages_fts
  ON telegram_messages USING GIN (to_tsvector('simple', text));
CREATE INDEX telegram_messages_chat_date
  ON telegram_messages (tenant_id, chat_id, created_at DESC);

CREATE TABLE telegram_notification_lists (
  id         UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id  UUID        NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name       TEXT        NOT NULL,            -- e.g. "Finance Team", "All Staff"
  chat_ids   BIGINT[]    NOT NULL DEFAULT '{}',  -- user + group chat_ids (bot must be member)
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX telegram_notification_lists_tenant ON telegram_notification_lists (tenant_id);

CREATE TABLE telegram_notification_routing (
  id                UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id         UUID        NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  notification_type TEXT        NOT NULL,     -- a NotificationType value, or '*' (all types)
  target_type       TEXT        NOT NULL CHECK (target_type IN ('list','group','chat_id')),
  target_id         TEXT        NOT NULL,     -- list UUID | group chat_id | personal chat_id
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, notification_type, target_type, target_id)
);
CREATE INDEX telegram_notification_routing_lookup
  ON telegram_notification_routing (tenant_id, notification_type);

-- Column delta on existing table (do NOT recreate):
ALTER TABLE user_preferences ADD COLUMN telegram_chat_id BIGINT;  -- NULL until /connect completed
```
**Acceptance:**
- [ ] Migration applies cleanly on a fresh Neon branch; `\d telegram_messages` shows both indexes.
- [ ] `user_preferences.telegram_chat_id` exists, nullable, type `bigint`.
- [ ] Drizzle types compile and are exported from `@zync/db`.

### Task 2: Seed Telegram permissions into RBAC
**Blocks:** 12, 13  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/auth/src/seed.ts` (or wherever `seedPermissions`/`seedSystemRoles` define the permission catalog)
**Steps:**
- [ ] Add three permission keys to the catalog consumed by `seedPermissions`: `telegram.manage`, `telegram.view_history`, `telegram.connect_personal`.
- [ ] In `seedSystemRoles`/`role_permissions` grants: `telegram.manage` → Owner, Admin; `telegram.view_history` → Owner, Admin; `telegram.connect_personal` → all roles.
- [ ] DM interface actions reuse existing module keys (`tasks:read`/`tasks:write`, `tickets:read`/`tickets:write`, `invoices:view`/`invoices:write`, `customers:view`/`customers:write`, `kb:read`, `marketing:read`) — do NOT mint new per-action keys.
**Schema / Interfaces:**
```ts
// Permission catalog additions (key, description, default roles)
{ key: 'telegram.manage',           roles: ['OWNER','ADMIN'] }          // bot setup, groups, lists, routing
{ key: 'telegram.view_history',     roles: ['OWNER','ADMIN'] }          // message history viewer
{ key: 'telegram.connect_personal', roles: ['OWNER','ADMIN','MANAGER','MEMBER','VIEWER'] } // /connect + DM access
```
**Acceptance:**
- [ ] After `seedPermissions` + `seedSystemRoles`, querying `permissions` returns the three new rows.
- [ ] `role_permissions` grants match the table above.
- [ ] `requirePermission('telegram.manage')` resolves for Owner/Admin sessions only.

### Task 3: `CallbackEncoder` — 64-byte Telegram callback_data invariant
**Blocks:** 8, 9  ·  **Blocked by:** 1
**Files:**
- Create: `packages/telegram-bot/src/callback.ts`
- Create: `packages/telegram-bot/package.json`, `tsconfig.json`, `src/index.ts` (package scaffold; export bindings)
**Steps:**
- [ ] Implement `encode(action, entityId, extras?)` producing `"{action}|{entityId8}|{extras...}"` where `entityId8` is the first 8 chars of the UUID.
- [ ] Implement `decode(raw)` reversing it into `{ action, entityId, extras }`. Note: `entityId` returned is the 8-char prefix; screens resolve the full UUID via the page's already-fetched list (no full UUID round-trip needed at tenant scale).
- [ ] Throw `CallbackTooLargeError` when the UTF-8 byte length of the encoded string exceeds 64.
- [ ] Define and export the `ActionCode` union and a frozen `ACTION_REGISTRY` mapping each code to its target `screenId`.
**Schema / Interfaces:**
```ts
export type ActionCode =
  | 'tk_s'   // task_status
  | 'tk_c'   // task_comment
  | 'tk_new' // task_create
  | 'tk_v'   // task_view (detail)
  | 'tkt_r'  // ticket_reply
  | 'tkt_cl' // ticket_close
  | 'tkt_a'  // ticket_assign
  | 'tkt_v'  // ticket_view
  | 'inv_p'  // invoice_mark_paid
  | 'inv_r'  // invoice_reminder
  | 'inv_v'  // invoice_view
  | 'inv_f'  // invoice_filter
  | 'cust'   // customer_view
  | 'cust_t' // customer_tickets
  | 'cust_i' // customer_invoices
  | 'kb_r'   // kb_read
  | 'pg'     // pagination (extras[0]=screenId offset)
  | 'home'   // home menu
  | 'exp_s'  // expense_save
  | 'exp_e'  // expense_edit
  | 'exp_x'  // expense_discard

export interface CallbackEncoder {
  encode(action: ActionCode, entityId: string, extras?: string[]): string
  decode(raw: string): { action: ActionCode; entityId: string; extras: string[] }
}
export class CallbackTooLargeError extends Error {}
export const ACTION_REGISTRY: Readonly<Record<ActionCode, { screenId: string }>>
```
**Acceptance:**
- [ ] `encode('tkt_cl','a1b2c3d4-...', ['0'])` returns `"tkt_cl|a1b2c3d4|0"` (18 bytes).
- [ ] Encoding any registered action with one UUID + one extra never exceeds 64 bytes.
- [ ] `decode(encode(...))` round-trips action + extras; `encode` throws `CallbackTooLargeError` on contrived >64-byte input.

### Task 4: `TelegramSessionDO` + `ConversationSession` — DO-backed multi-step state
**Blocks:** 8, 9, 12  ·  **Blocked by:** 1
**Files:**
- Create: `packages/telegram-bot/src/session-do.ts`
- Create: `packages/telegram-bot/src/session.ts`
**Steps:**
- [ ] Implement `TelegramSessionDO` with `fetch` handling `op ∈ {get,set,clear}` against `state.storage` key `'s'`.
- [ ] On `get`: read state; if `expiresAt < Date.now()` delete it and return `null` (DO storage has no native TTL).
- [ ] Implement `ConversationSession` wrapping the DO: address instance by name `${tenantId}:${chatId}` via `env.TELEGRAM_SESSION_DO.idFromName(...)`.
- [ ] `set(chatId, state, ttlMs = 5*60*1000)` always writes a fresh `expiresAt = Date.now() + ttlMs`.
- [ ] Provide an in-memory stub `InMemoryConversationSession` for tests (second adapter = real seam).
**Schema / Interfaces:**
```ts
export interface SessionState {
  action: 'ticket_reply' | 'task_comment' | 'task_create_title' | 'task_create_due'
        | 'invoice_upload_confirm' | 'customer_search' | 'kb_search'
  entityId?: string
  step: number
  data: Record<string, unknown>
  expiresAt: number   // Unix ms; checked on read
}
export interface ConversationSession {
  get(chatId: bigint): Promise<SessionState | null>
  set(chatId: bigint, state: Omit<SessionState, 'expiresAt'>, ttlMs?: number): Promise<void>
  clear(chatId: bigint): Promise<void>
}
export class TelegramSessionDO implements DurableObject {
  fetch(request: Request): Promise<Response>
}
```
**Acceptance:**
- [ ] `set` then `get` within TTL returns the stored state (strong consistency — no stale read).
- [ ] `get` after `expiresAt` returns `null` and the entry is deleted.
- [ ] In-memory stub passes the same contract test suite as the DO-backed impl.

### Task 5: `TelegramBotIdentity` — RBAC resolution with module-scope cache
**Blocks:** 8, 9, 12  ·  **Blocked by:** 1
**Files:**
- Create: `packages/telegram-bot/src/identity.ts`
**Steps:**
- [ ] Module-scope `Map<string,{identity;expiresAt}>` keyed `${tenantId}:${chatId}`; 5-minute TTL; survives across requests in the same isolate.
- [ ] `resolveIdentity(tenantId, chatId, db)`: cache hit returns immediately; on miss query Neon joining `users` + `user_preferences` on `telegram_chat_id` filtered by `tenant_id` and active user (`frozen_at IS NULL`).
- [ ] On no row → return `null` (caller replies "Send /connect to link your Zync account." and stops).
- [ ] `buildIdentity(row)` hydrates `hasPermission(key)` (delegating to the RBAC `hasScope`/permission helper from `@zync/auth`) and `canAccessModule(moduleKey)` (checks `tenant_modules` via `getTenantModules`/`getEnabledModuleIds`).
- [ ] Cache the built identity for 5 minutes.
**Schema / Interfaces:**
```ts
export interface TelegramBotIdentity {
  readonly userId: string
  readonly tenantId: string
  readonly email: string
  readonly chatId: bigint
  readonly role: string
  hasPermission(key: string): boolean
  canAccessModule(moduleKey: string): boolean
}
export function resolveIdentity(
  tenantId: string, chatId: bigint, db: Db,
): Promise<TelegramBotIdentity | null>
// SQL: 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
```
**Acceptance:**
- [ ] Warm-isolate second call hits the Map (zero Neon queries — assert via query spy).
- [ ] Unlinked chat_id returns `null`.
- [ ] `hasPermission`/`canAccessModule` mirror web-route results for the same user.

### Task 6: `ZyncBotClient` — module data access via Service Binding
**Blocks:** 8, 9  ·  **Blocked by:** 1
**Files:**
- Create: `packages/telegram-bot/src/zync-client.ts`
**Steps:**
- [ ] Define the `ZyncBotClient` interface (below) and `PageResult<T>`.
- [ ] Implement `ZyncServiceBindingClient`: each method calls the `zync-api` Worker via `env.ZYNC_API.fetch(...)` against the existing REST routes, forwarding `X-Bot-UserId` and `X-Bot-TenantId` headers (resolved identity) so the API enforces RBAC normally.
  - Tasks → `GET/POST /api/tasks`, `PATCH /api/tasks/:id` (status), `POST /api/tasks/:id/comment`.
  - Tickets → `GET/POST /api/tickets`, `GET /api/tickets/:id`, `POST /api/tickets/:id/reply`, `PATCH /api/tickets/:id` (close/assign).
  - Invoices → `GET /api/invoices`, `GET /api/invoices/:id`, `PATCH /api/invoices/:id` (mark paid), `POST /api/invoices/:id/reminder`.
  - Customers → `GET /api/customers?q=`, `GET /api/customers/:id`.
  - KB → `GET /api/kb/search?q=`.
- [ ] Implement `ZyncStubClient` test adapter returning fixtures (second adapter = real seam).
- [ ] Add a matching `X-Bot-*` header trust path in `zync-api` auth middleware (Task 12) — bot headers only honored on Service-Binding-internal requests, never from the public edge.
**Schema / Interfaces:**
```ts
export interface PageResult<T> { items: T[]; page: number; pageCount: number; total: number }
export interface ZyncBotClient {
  // Tasks (tasks-board-engine)
  listTasks(p: { userId: string; page: number; pageSize: number }): Promise<PageResult<TaskObject>>
  getTask(taskId: string): Promise<TaskObject | null>
  updateTaskStatus(taskId: string, statusId: string): Promise<void>
  addTaskComment(taskId: string, text: string): Promise<void>
  createTask(d: { title: string; dueDate?: string }): Promise<TaskObject>
  // Tickets (crm-support-center)
  listTickets(p: { status?: string; page: number }): Promise<PageResult<TicketObject>>
  getTicket(ticketId: string): Promise<TicketObject | null>
  replyTicket(ticketId: string, text: string): Promise<void>
  closeTicket(ticketId: string): Promise<void>
  assignTicket(ticketId: string, assigneeId: string): Promise<void>
  // Invoices (invoices-core)
  listInvoices(p: { status?: string; page: number }): Promise<PageResult<InvoiceObject>>
  getInvoice(invoiceId: string): Promise<InvoiceObject | null>
  markInvoicePaid(invoiceId: string): Promise<void>
  sendInvoiceReminder(invoiceId: string): Promise<void>
  // Customers (customers-module)
  searchCustomers(query: string): Promise<CustomerObject[]>
  getCustomer(customerId: string): Promise<CustomerObject | null>
  // KB (kb-module)
  searchKb(query: string): Promise<KbArticle[]>
}
export interface KbArticle { id: string; title: string; snippet: string; body: string }
```
**Acceptance:**
- [ ] Every method maps to an existing `zync-api` route (no new module endpoints invented).
- [ ] Service-Binding client forwards `X-Bot-UserId`/`X-Bot-TenantId`; a stripped/edge request is rejected by API middleware.
- [ ] `ZyncStubClient` satisfies the full interface and is used in screen unit tests.

### Task 7: `ConversationContext` — RAG retrieval seam for group mentions
**Blocks:** 8, 9  ·  **Blocked by:** 1
**Files:**
- Create: `packages/telegram-bot/src/conversation-context.ts`
**Steps:**
- [ ] Define `ConversationContext` + `ContextChunk` (below).
- [ ] Implement `AIAssistantContextRetriever`: calls the `ai-assistant` Worker via Service Binding `env.AI_ASSISTANT.fetch(...)` invoking `retrieveContext(tenantId, question)`; map returned chunks to `ContextChunk[]` (kb/task/ticket/customer/invoice with score). When `userId` undefined → group context (no user-scoped data).
- [ ] Implement `StubContextRetriever` for tests.
- [ ] Keep retrieval separate from prompt construction (mention handler in Task 9 builds the Claude prompt).
**Schema / Interfaces:**
```ts
export interface ContextChunk {
  source: 'kb' | 'task' | 'ticket' | 'customer' | 'invoice'
  text: string
  score: number
}
export interface ConversationContext {
  retrieve(p: {
    tenantId: string
    question: string
    recentMessages: TelegramMessageRow[]  // last N from telegram_messages for this chat
    userId?: string                        // undefined = group context
  }): Promise<ContextChunk[]>
}
```
**Acceptance:**
- [ ] `AIAssistantContextRetriever` issues exactly one Service-Binding call per retrieve (no inline duplicate RAG).
- [ ] Stub retriever returns deterministic chunks for tests.

### Task 8: Bot runtime — webhook entry, grammy wiring, DM router, home menu, chunker
**Blocks:** 9, 12  ·  **Blocked by:** 3, 4, 5, 6
**Files:**
- Create: `packages/telegram-bot/src/bot.ts` (buildBot + dispatch)
- Create: `packages/telegram-bot/src/chunker.ts` (port from Botmaster)
- Create: `packages/telegram-bot/src/screens.ts` (screen registry + `home`, `tasks.*`, `tickets.*`, `invoices.*`, `customers.*`, `kb.search`)
- Modify: `apps/zync-api/src/routes/webhooks/telegram.ts` (extend existing webhook to invoke `@zync/telegram-bot`)
**Steps:**
- [ ] In the webhook handler: apply `RATE_LIMITER_WEBHOOK`; load token via `loadAdapterCredential(tenantId,'telegram')` + `decryptCredential`; verify `X-Telegram-Bot-Api-Secret-Token` against the stored secret (timing-safe via `timingSafeEqual` from `@zync/auth`) — reject mismatches with 401; build `new Bot(token)` and dispatch the update.
- [ ] `buildBot()` registers: chat-type router (group vs private), callback-query handler (`CallbackEncoder.decode` → `navigate`), command handlers, force_reply continuation, message handler.
- [ ] DM message handler order: `ConversationSession.get(chatId)` first → if non-null continue pending flow; if `resolveIdentity` is null → reply "Send /connect to link your Zync account." and stop; `/start`|`/menu`|unrecognized freetext with no session → render `home`.
- [ ] Home menu: 2×N inline keyboard, buttons only for modules where `identity.canAccessModule` + relevant `hasPermission` are true (Tasks→`tasks:read`, Tickets→`tickets:read`, Invoices→`invoices:view`, Customers→`customers:view`, KB→`kb:read`, Ask AI always). Zero accessible modules → only `[🤖 Ask AI]`.
- [ ] Implement `SCREEN_REGISTRY` (Map) and `navigate(ctx, screenId, params)`; screen IDs: `'home'`, `'tasks.list'`, `'tasks.detail'`, `'tickets.list'`, `'tickets.detail'`, `'invoices.list'`, `'invoices.detail'`, `'customers.search'`, `'customers.detail'`, `'kb.search'`.
- [ ] Implement each DM screen exactly per spec layouts: Tasks (5/page list, detail, `[🔄 Status]` valid-next, `[✏️ Comment]` force_reply→`addTaskComment`, `[➕ New Task]` 2-step session→`createTask`); Tickets (filter `[All/Open/Pending/Resolved]`, detail `[✏️ Reply]`/`[✅ Close]`/`[🔄 Assign]`); Invoices (filter `[All/Draft/Sent/Overdue/Paid]`, detail `[📤 Send Reminder]` confirm, `[✅ Mark Paid]`); Customers (`/customer <name>` or force_reply search → card with open tickets/unpaid count, `[🎫 Tickets]`/`[📄 Invoices]`/`[➕ New Ticket]`); KB (`/kb <query>` → top-3, `[Read]`→`sendChunked`).
- [ ] Pagination via `◀ Prev`/`Next ▶` buttons encoding offset in callback_data (action `'pg'`).
- [ ] Port `chunker.ts`: code-fence-aware ≤4000-char splitter; expose `sendChunked(ctx, text)`.
- [ ] Document upload → Expense: photo/PDF in DM → call `ai-assistant` OCR (`POST /api/ai/categorize-expense` via ZyncBotClient/Service Binding) → show extracted card with `[✅ Save as Expense]`/`[✏️ Edit]`/`[❌ Discard]`; `[✏️ Edit]` opens session field correction.
- [ ] All DM actions check `identity.hasPermission(...)` before rendering/mutating (a user without `tickets:read` sees no tickets in Telegram).
**Schema / Interfaces:**
```ts
export interface TelegramScreen {
  readonly screenId: string
  render(ctx: Context, identity: TelegramBotIdentity, client: ZyncBotClient,
         session: ConversationSession, params?: Record<string, unknown>): Promise<void>
}
export const SCREEN_REGISTRY: Map<string, TelegramScreen>
export function navigate(ctx: Context, screenId: string, params?: Record<string, unknown>): Promise<void>
export function buildBot(env: Env, token: string, deps: BotDeps): Bot
export function sendChunked(ctx: Context, text: string): Promise<void>
```
**Acceptance:**
- [ ] Webhook rejects requests with a wrong/absent `X-Telegram-Bot-Api-Secret-Token` (timing-safe compare) — 401, no bot dispatch.
- [ ] Home menu shows only permitted module buttons; unlinked DM user gets the `/connect` prompt.
- [ ] Each screen renders the spec layout; `[🔄 Status]`/`[✅ Mark Paid]`/`[📤 Send Reminder]`/`[✏️ Reply]` invoke the correct `ZyncBotClient` method.
- [ ] `sendChunked` never emits a chunk >4096 bytes and never splits inside a code fence.

### Task 9: Group assistant — message logging, `/summarize`, `/search`, @-mention, voice, team/module commands
**Blocks:** 12  ·  **Blocked by:** 7, 8
**Files:**
- Create: `packages/telegram-bot/src/group.ts` (logging + command router + mention handler)
- Create: `packages/telegram-bot/src/summarize.ts` (port from Botmaster `summarize.ts`)
- Modify: `packages/telegram-bot/src/bot.ts` (wire group handlers)
**Steps:**
- [ ] Message logging: for every text/voice message in a chat present + active in `telegram_group_chats`, upsert into `telegram_messages` (ON CONFLICT `(tenant_id,chat_id,message_id)` DO NOTHING); store `created_at` from Telegram `message.date` (Unix→timestamptz). Bot responses stored with `is_bot_response = true`. **Non-group/DM chats are never logged.**
- [ ] Voice: download OGG via `getFile` + token → transcribe via `ai-assistant` Worker (Workers AI `@cf/openai/whisper-tiny-en` or Whisper route) → store with `from_voice = true` → reply with italic collapsible `_[Transcription: "..."]_`.
- [ ] `/summarize <query>` (port from Botmaster): parse NL time query via Claude (`ANTHROPIC_API_KEY`) → `{ fromDate, limit, userFilter, topicFilter }`; query `telegram_messages` with filters; format transcript (≤500 msgs, ≤24k chars, Asia/Jerusalem timestamps); Claude summary; `sendChunked`; respond in Hebrew if source messages Hebrew, else English.
- [ ] `/search <keywords>`: Postgres FTS over `telegram_messages` (`to_tsvector('simple', text)` GIN index) scoped to tenant+chat; render matches.
- [ ] `/help`: list available commands.
- [ ] @-mention handler: extract question; fetch last 30 messages for the chat from `telegram_messages`; `ConversationContext.retrieve({tenantId, question, recentMessages})`; build prompt (system + chunks + recent history + question); reply via `sendChunked` reply-threaded to the original message. No persistent Telegram session.
- [ ] Zync module commands (group + DM): `/tasks`, `/task <id|title>`, `/invoice <customer>`, `/customer <name>`, `/lead <name>`, `/kb <query>` — Business+ tier (`requireTier('business')`), respect `tenant_modules`. In group chats run as tenant context (no per-user auth); in DMs run under linked user identity/permissions.
- [ ] `/ask <question>` explicitly routes to `ConversationContext` + Claude, bypassing the session check.
**Schema / Interfaces:**
```ts
export interface TelegramMessageRow {
  id: string; tenantId: string; chatId: bigint; messageId: bigint
  userTelegramId: bigint | null; username: string | null; firstName: string | null
  text: string; isBotResponse: boolean; fromVoice: boolean; createdAt: Date
}
export interface SummarizeQuery { fromDate: Date; limit: number; userFilter?: string; topicFilter?: string }
export function parseSummarizeQuery(nl: string, anthropicKey: string): Promise<SummarizeQuery>
export function summarizeMessages(rows: TelegramMessageRow[], anthropicKey: string): Promise<string>
export function logGroupMessage(db: Db, row: Omit<TelegramMessageRow,'id'>): Promise<void>
```
**Acceptance:**
- [ ] Group text + bot reply both land in `telegram_messages`; DM messages do not.
- [ ] `/search` returns FTS-ranked matches; `/summarize "last 2 hours"` parses the window and summarizes; Hebrew transcript → Hebrew summary.
- [ ] @-mention reply is threaded and uses `ai-assistant` retrieval (single Service-Binding RAG call).
- [ ] Voice message stored with `from_voice=true` and echoed as a collapsible transcription.

### Task 10: Telegram notification templates (`packages/notifications/src/channels/telegram.ts`)
**Blocks:** 11  ·  **Blocked by:** 1
**Files:**
- Create: `packages/notifications/src/channels/telegram.ts`
**Steps:**
- [ ] One template function per `NotificationType` (parallel to existing email templates) returning `{ text, parseMode: 'Markdown', buttons?: ActionButton[] }`.
- [ ] Apply the emoji + DM-only action-button matrix from the spec (e.g. `invoice.overdue` 📄/⚠️ → `[Send Reminder] [View]`; `ticket.created` 🎫 → `[Reply] [View]`; `approval.required` ⏳ → `[Approve] [Reject]`).
- [ ] Deep-link buttons (`url`) for group/list recipients; inline `callbackAction` buttons (encoded via `CallbackEncoder`) only for DM recipients with linked accounts.
- [ ] Message format per spec: bold title line, body lines, optional `[View →](deeplink)`.
**Schema / Interfaces:**
```ts
export interface TelegramMessagePayload {
  text: string
  parseMode: 'Markdown'
  buttons?: ActionButton[]   // ActionButton from @zync/types (label, url?, callbackAction?)
}
export function renderTelegramNotification(
  type: NotificationType,
  notification: DeliverableNotification,
  opts: { isDmRecipient: boolean; deepLinkBase: string },
): TelegramMessagePayload
```
**Acceptance:**
- [ ] Every `NotificationType` in the spec coverage table has a template; group/list payloads carry only `url` buttons, DM payloads may carry `callbackAction` buttons.
- [ ] Output is valid Telegram Markdown (`parse_mode: 'Markdown'`).

### Task 11: `TelegramNotificationAdapter` + delivery routing
**Blocks:** —  ·  **Blocked by:** 1, 10
**Files:**
- Create: `packages/notifications/src/adapters/telegram.ts`
- Modify: `packages/notifications/src/deliver.ts` (register adapter in `ADAPTERS`)
**Steps:**
- [ ] Implement `TelegramNotificationAdapter implements NotificationAdapter` (`id = 'telegram'`).
- [ ] `canDeliver(userId, type)`: true iff `user_preferences.telegram_chat_id` is set AND `type ∈ user_preferences.notification_channels.telegram[]`. If opted in but not `/connect`-ed (no chat_id) → false (silent skip, like email with no address).
- [ ] `deliver(userId, notification)`: resolve the tenant bot token from `adapter_credentials` (`adapter_id='telegram'`, decrypt); render via `renderTelegramNotification`; `POST https://api.telegram.org/bot{token}/sendMessage`.
- [ ] Tenant-level routing resolver `resolveTelegramRecipients(db, tenantId, type)`:
  1. Personal recipients: users with `telegram_chat_id` opted in for `type`.
  2. Tenant routing: `telegram_notification_routing` rows matching `notification_type = type` OR `'*'`; expand `target_type='list'` to that list's `chat_ids`, `'group'`→group chat_id, `'chat_id'`→literal.
  3. Union + dedup all chat_ids; one `sendMessage` per unique chat_id.
- [ ] Register `telegramAdapter` in `deliver.ts` `ADAPTERS` array (no change to `deliverNotification` fanout logic).
**Schema / Interfaces:**
```ts
export class TelegramNotificationAdapter implements NotificationAdapter {
  readonly id = 'telegram' as const
  canDeliver(userId: string, type: NotificationType): Promise<boolean>
  deliver(userId: string, notification: DeliverableNotification): Promise<DeliveryResult>
}
export function resolveTelegramRecipients(
  db: Db, tenantId: string, type: NotificationType,
): Promise<bigint[]>   // deduplicated chat_ids (personal opt-in ∪ tenant routing)
```
**Acceptance:**
- [ ] Adapter registered; `deliverNotification` fans out to it with zero pipeline edits.
- [ ] Opted-in-but-unlinked user is silently skipped (no error).
- [ ] `resolveTelegramRecipients` unions personal + list/group/chat routing (incl. `'*'` wildcard) and dedups.

### Task 12: API routes — groups, lists, routing, connect, messages, test notification
**Blocks:** 13, 14  ·  **Blocked by:** 1, 2, 3, 4, 5, 6, 8, 9
**Files:**
- Create: `apps/zync-api/src/routes/telegram.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router; honor `X-Bot-UserId`/`X-Bot-TenantId` only on internal Service-Binding requests)
**Steps:**
- [ ] Implement all routes below with Zod validation (`require-zod-validation-in-routes`), `authMiddleware`, `requirePermission`, and `tenantQuery(db, tenantId)` scoping (`no-raw-drizzle-from-routes`).
- [ ] `POST /api/telegram/groups`: call `getChat` with tenant bot token to verify bot membership + fetch `title`; insert into `telegram_group_chats`; activate logging. `telegram.manage`.
- [ ] `GET/PATCH/DELETE /api/telegram/groups[/:id]`: list / toggle `is_active` / remove (messages retained). `telegram.manage`.
- [ ] `GET/POST/PUT/DELETE /api/telegram/notification-lists[/:id]`: CRUD on `telegram_notification_lists` (`chat_ids` array). `telegram.manage`.
- [ ] `GET/POST/DELETE /api/telegram/notification-routing[/:id]`: CRUD on `telegram_notification_routing`. `telegram.manage`.
- [ ] `POST /api/telegram/connect`: body `{ code }` — resolve KV `tg_connect:{tenantId}:{code}` (10-min TTL, value `{chatId,userId:null}`); on hit set `user_preferences.telegram_chat_id = chatId` for current user; delete KV entry; send confirmation via bot `sendMessage`. `telegram.connect_personal`. (The bot side that issues the code on `/connect` lives in Task 8's command set, writing the KV entry.)
- [ ] `DELETE /api/telegram/connect`: clear `user_preferences.telegram_chat_id` for current user (self-unlink). `telegram.connect_personal`.
- [ ] `GET /api/telegram/messages`: `{ groupId, from, to, keyword, limit }` history query over `telegram_messages` (FTS for `keyword`). `telegram.view_history`.
- [ ] `POST /api/telegram/test-notification`: `{ targetType, targetId, type }` — render + `sendMessage` a test to the target. `telegram.manage`.
- [ ] Trust `X-Bot-UserId`/`X-Bot-TenantId` ONLY when the request arrives via the Service Binding (internal); strip/ignore on public edge requests (security boundary for Task 6).
**Schema / Interfaces:**
```
GET    /api/telegram/groups                   → list registered groups
POST   /api/telegram/groups                   → { chatId } → validate membership + register
PATCH  /api/telegram/groups/:id               → { isActive }
DELETE /api/telegram/groups/:id               → remove (logging stops; messages retained)
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
GET    /api/telegram/notification-routing     → list routes
POST   /api/telegram/notification-routing     → { notificationType, targetType, targetId }
DELETE /api/telegram/notification-routing/:id → remove
POST   /api/telegram/connect                  → { code } → links chat_id to current user
DELETE /api/telegram/connect                  → unlink current user
GET    /api/telegram/messages                 → { groupId, from, to, keyword, limit }
POST   /api/telegram/test-notification        → { targetType, targetId, type }
```
**Acceptance:**
- [ ] Every route enforces the permission in its comment; non-permitted sessions get 403.
- [ ] `POST /groups` rejects a chat the bot is not a member of (getChat failure → 422).
- [ ] `POST /connect` with a valid KV code links the chat_id and deletes the code; expired/invalid code → 400.
- [ ] All bodies validated by Zod; queries go through `tenantQuery`.

### Task 13: Settings UI — `/settings/integrations/telegram`
**Blocks:** —  ·  **Blocked by:** 12, 2
**Files:**
- Create: `apps/zync-app/src/pages/settings/integrations/telegram/index.tsx`
- Create: `apps/zync-app/src/pages/settings/integrations/telegram/{GroupChatsTab,NotificationListsTab,PersonalConnectionsTab,MessageHistoryTab}.tsx`
- Create: `apps/zync-app/src/hooks/useTelegramSettings.ts`
- Modify: settings route registry / integrations nav
**Steps:**
- [ ] Tabbed page (`Tabs` from `@zync/ui`) gated on `telegram.manage`; Message History tab additionally gated on `telegram.view_history`.
- [ ] **Bot Setup tab** (display existing comms-spec connection): token status connected/disconnected/error; Disconnect (`deleteWebhook` + remove from `adapter_credentials`) — reuse comms integration endpoint.
- [ ] **Group Chats tab:** `DataTable` (Title, Chat ID, Status, Message count, Added). "Add group" → paste chat ID → `POST /api/telegram/groups`; toggle active (`PATCH`); remove (`DELETE`).
- [ ] **Notification Lists tab:** lists table (Name, members count, routes count); create/edit (name + comma-separated chat/group IDs → `chatIds`); routing table per `NotificationType` → list/group/chat_id (`POST`/`DELETE` routing); "Test" button → `POST /api/telegram/test-notification`.
- [ ] **Personal Connections tab (admin):** table of users with linked/unlinked Telegram; "Unlink" per user (`DELETE /api/telegram/connect` admin variant clearing that user's chat_id).
- [ ] **Message History tab (admin):** per-group log viewer with date range + user filter + keyword search (`GET /api/telegram/messages`); Export to CSV.
- [ ] Use `@zync/ui` primitives only (`no-hardcoded-colors`, `no-hardcoded-spacing`, `no-raw-html-in-pages`); RTL-correct (logical properties) and Hebrew-localized labels; respect `prefers-reduced-motion`; aria roles on tables/dialogs/tabs.
**Acceptance:**
- [ ] All four tabs render and call the Task 12 endpoints; permission gating hides Message History from non-`view_history` users.
- [ ] Add-group validates membership server-side; "Test" sends a real message.
- [ ] Page is RTL-correct in Hebrew and passes axe (aria roles, focus order).

### Task 14: Profile → Alert settings — Telegram opt-in column
**Blocks:** —  ·  **Blocked by:** 12
**Files:**
- Modify: `apps/zync-app/src/pages/settings/profile/AlertSettings.tsx` (or notification-preferences component)
**Steps:**
- [ ] Add a Telegram column to the per-`NotificationType` toggle matrix (parallel to the existing email column), bound to `user_preferences.notification_channels.telegram[]` via `PATCH /api/user/preferences` (`updateUserPreferencesSchema`).
- [ ] Show a "/connect to enable" hint + disabled toggles when `user_preferences.telegram_chat_id` is null; surface the `/connect` code-entry field that posts to `POST /api/telegram/connect`.
- [ ] `Switch`/`Checkbox` from `@zync/ui`; RTL + Hebrew labels; aria-labels per row.
**Acceptance:**
- [ ] Toggling a type adds/removes it from `notification_channels.telegram[]`.
- [ ] Telegram toggles are disabled until the user links via `/connect`.
- [ ] Entering a valid `/connect` code links the account and enables the column.

### Task 15: Wrangler bindings, Service Bindings, secrets, pg_cron retention
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-api/wrangler.toml` (or telegram-bot worker config)
- Create: `packages/db/migrations/0NNN_telegram_pg_cron.sql`
**Steps:**
- [ ] Declare the `TelegramSessionDO` Durable Object binding (`TELEGRAM_SESSION_DO`) + migration tag for the new DO class.
- [ ] Declare Service Bindings: telegram-bot Worker → `ZYNC_API` (zync-api) and → `AI_ASSISTANT` (ai-assistant Worker).
- [ ] Confirm `KV_CACHE`, `AI`, `RATE_LIMITER_WEBHOOK` bindings present; secrets `INTEGRATION_ENCRYPTION_KEY`, `ANTHROPIC_API_KEY` set.
- [ ] pg_cron migration: enable `pg_cron`; schedule `tg-msg-retention` daily at 03:00 deleting `telegram_messages` older than `TG_RETENTION_MONTHS` (default 12) months. No CF cron trigger.
**Schema / Interfaces:**
```sql
-- requires pg_cron enabled on the Neon project
SELECT cron.schedule('tg-msg-retention', '0 3 * * *',
  'DELETE FROM telegram_messages WHERE created_at < now() - interval ''12 months''');
-- interval value substituted from TG_RETENTION_MONTHS at migration time
```
**Acceptance:**
- [ ] `wrangler deploy --dry-run` resolves the DO + Service Bindings.
- [ ] `cron.job` table shows the scheduled `tg-msg-retention` entry; a row older than the window is deleted on next run.

### Task 16: Tests — seams, encoder, session consistency, recipient resolution
**Blocks:** —  ·  **Blocked by:** 3, 4, 6, 7, 11
**Files:**
- Create: `packages/telegram-bot/test/{callback,session,identity,zync-client,group}.test.ts`
- Create: `packages/notifications/test/telegram-adapter.test.ts`
**Steps:**
- [ ] `CallbackEncoder`: round-trip + `CallbackTooLargeError` on >64 bytes.
- [ ] `ConversationSession`: strong-consistency (set→get same value within TTL), TTL expiry returns null + deletes; run the same suite against DO-backed and in-memory stub.
- [ ] `TelegramBotIdentity`: warm-isolate Map hit (zero Neon calls via spy), unlinked → null, permission/module parity with web.
- [ ] `ZyncBotClient`: stub satisfies interface; Service-Binding client forwards `X-Bot-*` headers; edge request without binding is rejected.
- [ ] `resolveTelegramRecipients`: personal ∪ list ∪ group ∪ `'*'` wildcard, deduplicated; opted-in-unlinked skipped.
- [ ] Group `/summarize` query parse + `/search` FTS happy path with stub Claude.
**Acceptance:**
- [ ] All listed tests pass in CI.
- [ ] Both adapters (real + stub) pass the shared `ConversationSession` and `ZyncBotClient` contract suites.

## Cross-cutting compliance
- **Security:** webhook secret-token check uses `timingSafeEqual` (no string `==`); bot tokens never in URL paths (tenantId-keyed webhook); `X-Bot-*` headers trusted only on internal Service-Binding requests; per-tenant tokens stay AES-256-GCM encrypted in `adapter_credentials` (`INTEGRATION_ENCRYPTION_KEY`); `RATE_LIMITER_WEBHOOK` on the inbound endpoint; all DM actions re-check `identity.hasPermission`.
- **A11y:** settings tables/tabs/dialogs carry aria roles and correct focus order; toggles have aria-labels.
- **i18n/RTL:** all UI labels translated (Hebrew default); logical CSS properties for RTL; `/summarize` and notifications are language-aware (Hebrew transcript → Hebrew output).
- **Performance:** identity module-scope Map avoids per-message Neon/KV cost; token fetched via Hyperdrive pool (no KV quota); pg_cron retention runs in Neon compute (no CF cron slot); `prefers-reduced-motion` respected in UI.
