From afea1526cc083f4f8cd9a05e0cf2049f7671e75e Mon Sep 17 00:00:00 2001 From: tomicodesdev <32748319+tomicodesdev@users.noreply.github.com> Date: Tue, 19 May 2026 01:21:53 +0100 Subject: [PATCH 1/2] feat(channels): ship public web channels --- README.md | 3 +- docs/architecture.md | 21 +- docs/operations.md | 8 + docs/roadmap.md | 13 +- .../20260519_000000_public_channels.sql | 47 ++ src/api/channels.ts | 60 ++ src/api/routes.ts | 2 + src/channels/index.ts | 556 ++++++++++++++++++ src/channels/routes.ts | 303 ++++++++++ src/http/app.ts | 45 +- src/lib/ids.ts | 2 + src/lib/workspace-admin.ts | 117 ++-- src/types/channels.ts | 52 ++ src/types/workspace.ts | 1 + src/ui/api.ts | 30 + src/ui/styles/components.css | 31 + src/ui/views/PublicChannelsSection.tsx | 176 ++++++ src/ui/views/Settings.tsx | 2 + tests/channels.test.ts | 222 +++++++ tests/helpers/workspace-db.ts | 58 +- wrangler.jsonc | 21 +- 21 files changed, 1705 insertions(+), 65 deletions(-) create mode 100644 migrations/20260519_000000_public_channels.sql create mode 100644 src/api/channels.ts create mode 100644 src/channels/index.ts create mode 100644 src/channels/routes.ts create mode 100644 src/types/channels.ts create mode 100644 src/ui/views/PublicChannelsSection.tsx create mode 100644 tests/channels.test.ts diff --git a/README.md b/README.md index 872b597..39355aa 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Ranse turns support email into a real-time, multi-agent support workspace built - **Historical evals** — resolved conversations become anonymized replay cases; `ranse eval` catches prompt/procedure regressions before they ship. - **Forkable procedure library** — install vetted support workflows with evals, provenance checksums, and MCP reference contracts, then customize them in your repo. - **Insights loop** — score conversations, surface evidence-backed unresolved intents, draft reviewable KB suggestions with lineage, and detect source-specific drift from successful replies. +- **Public web channels** — embed chat widgets or hosted support forms that create normal tickets in the same inbox. - **One-click deploy** to your own Cloudflare account — customer-owned from day one. - **Open source** (Apache-2.0). @@ -86,7 +87,7 @@ Then open http://localhost:5173 (or http://localhost:8787 for the Worker directl Ranse is heading from "AI-assisted shared inbox" to a full autonomous customer-service agent — but not as an OSS clone of [Fin](https://fin.ai/) or [Decagon](https://decagon.ai/). The goal is the agent those products *structurally cannot become*: sovereign by construction, per-step model choice, procedures-as-code, MCP-native actions, eval-first against your own ticket history, and a forkable procedure library. -The shape, in short: **retrieval → workspace management → agentic retrieval → autonomous resolution → procedures → MCP actions → evals → procedure library → insights → multi-channel.** Phase 0 (bootstrap, inbound email, supervisor DO, draft + approval), Phase 1 (retrieval foundations), Phase 1.5 (workspace management & tenant isolation), Phase 2 (agentic multi-hop retrieval), Phase 3 (autonomous resolution), Phase 4 (procedures as code), Phase 5 (MCP-native actions), Phase 6 (historical evals), Phase 7 (procedure library), and Phase 8 (insights & auto-improving KB) are shipped. +The shape, in short: **retrieval → workspace management → agentic retrieval → autonomous resolution → procedures → MCP actions → evals → procedure library → insights → multi-channel.** Phase 0 (bootstrap, inbound email, supervisor DO, draft + approval), Phase 1 (retrieval foundations), Phase 1.5 (workspace management & tenant isolation), Phase 2 (agentic multi-hop retrieval), Phase 3 (autonomous resolution), Phase 4 (procedures as code), Phase 5 (MCP-native actions), Phase 6 (historical evals), Phase 7 (procedure library), Phase 8 (insights & auto-improving KB), and Phase 9 web channels are shipped. Full pipeline, principles, and how to contribute to a phase: **[docs/roadmap.md](docs/roadmap.md)**. It's directional, not committed — if you want to work on something further down the list, open a discussion and we'll happily reorder. diff --git a/docs/architecture.md b/docs/architecture.md index 88bae5e..ca1b698 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,6 +17,7 @@ Function-based, not DOs. They live in `src/agents/specialists/` and return struc - `summarize` — thread summary + next-step hint. - `knowledge` — manual/URL/PDF/resolved-ticket ingestion, Workers AI embeddings, Vectorize search, reranking, and keyword fallback. - `insights` — conversation rubric scoring, aggregate operational metrics, unresolved-intent KB suggestions, and knowledge drift detection. +- `channels` — public chat/form channel configuration, origin-scoped session tokens, hosted forms, widget script, and ticket creation. - `draft` — generate a reply with citations; flag review risks. - `escalation` — decide whether to route to a human/team. - `sla` — deterministic, no LLM; computes breach status. @@ -53,7 +54,7 @@ triageAndDraft (runs in DO alarm, async) | System | Purpose | |---|---| | DO SQLite | Workspace state, mailbox counters, BYOK-encrypted secrets | -| D1 | Tickets, messages, audit, approvals, outcomes, feedback, daily rollups, users, sessions, knowledge, LLM config, procedures, MCP registry/tool calls, eval cases/runs/results, conversation scores, KB suggestions, drift signals | +| D1 | Tickets, messages, audit, approvals, outcomes, feedback, daily rollups, users, sessions, knowledge, LLM config, procedures, MCP registry/tool calls, eval cases/runs/results, conversation scores, KB suggestions, drift signals, public channels/sessions | | R2 | Raw MIME, text/html bodies, attachments, exports | | KV | Rate limits, idempotency, lightweight flags | | Vectorize | Per-workspace knowledge chunk embeddings | @@ -168,6 +169,24 @@ Suggestions are review records, not automatic content edits. They require repeat Scheduled insights maintenance is workspace-isolated: one workspace failure is returned as an `ok: false` result for that workspace instead of failing the entire cron run. +## Public web channels + +``` +Settings -> Public channels + ├─ create chat/form channel for a workspace mailbox + ├─ configure allowed origins, welcome text, and email requirement + └─ copy ` + : ``; +} diff --git a/src/ui/views/Settings.tsx b/src/ui/views/Settings.tsx index 1f15cbb..7a1a201 100644 --- a/src/ui/views/Settings.tsx +++ b/src/ui/views/Settings.tsx @@ -4,6 +4,7 @@ import { NotificationsSection } from './NotificationsSection'; import { KnowledgeSection } from './KnowledgeSection'; import { WorkspaceMembersSection } from './WorkspaceMembersSection'; import { WorkspaceMailboxesSection } from './WorkspaceMailboxesSection'; +import { PublicChannelsSection } from './PublicChannelsSection'; import { WorkspacePlatformSection } from './WorkspacePlatformSection'; import { ModelSettingsSection } from './ModelSettingsSection'; import { McpActionsSection } from './McpActionsSection'; @@ -63,6 +64,7 @@ export function SettingsView() { +

Workspace branding

diff --git a/tests/channels.test.ts b/tests/channels.test.ts new file mode 100644 index 0000000..262bae5 --- /dev/null +++ b/tests/channels.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from 'vitest'; +import { apiApp } from '../src/api/routes'; +import { app } from '../src/http/app'; +import { + addMember, + createWorkspaceTestDb, + login, + seedMailbox, + seedUser, + seedWorkspace, +} from './helpers/workspace-db'; + +vi.mock('agents', () => ({ + getAgentByName: () => ({}), + Agent: class {}, + callable: () => () => undefined, + routeAgentRequest: () => null, +})); + +async function seedOwner() { + const { db, env } = createWorkspaceTestDb(); + await seedUser(db, 'owner', 'owner@example.com'); + seedWorkspace(db, 'ws_a', 'Alpha'); + addMember(db, 'ws_a', 'owner', 'owner'); + seedMailbox(db, 'ws_a', 'mb_a', 'support@example.com'); + const cookie = await login(env, 'owner@example.com'); + return { db, env, cookie }; +} + +async function createChannel( + env: ReturnType['env'], + cookie: string, + body: Record = {}, +) { + const res = await apiApp.request( + '/channels/public', + { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ + kind: 'chat', + mailbox_id: 'mb_a', + name: 'Website support', + allowed_origins: ['https://example.com'], + welcome_message: 'How can we help?', + ...body, + }), + }, + env, + ); + expect(res.status).toBe(200); + return (await res.json()) as any; +} + +describe('public channels', () => { + it('lets owners create and update public channels while blocking viewers', async () => { + const { db, env, cookie } = await seedOwner(); + await seedUser(db, 'viewer', 'viewer@example.com'); + addMember(db, 'ws_a', 'viewer', 'viewer'); + const viewerCookie = await login(env, 'viewer@example.com'); + + const forbidden = await apiApp.request( + '/channels/public', + { + method: 'POST', + headers: { cookie: viewerCookie, 'content-type': 'application/json' }, + body: JSON.stringify({ kind: 'chat', mailbox_id: 'mb_a', name: 'Nope' }), + }, + env, + ); + expect(forbidden.status).toBe(403); + + const created = await createChannel(env, cookie); + expect(created.channel.public_key).toMatch(/^pub_/); + + const patched = await apiApp.request( + `/channels/public/${created.channel.id}`, + { + method: 'PATCH', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ enabled: false, name: 'Website chat' }), + }, + env, + ); + const patchedBody: any = await patched.json(); + expect(patched.status).toBe(200); + expect(patchedBody.channel.enabled).toBe(0); + expect(patchedBody.channel.name).toBe('Website chat'); + }); + + it('creates tickets from public chat sessions and protects session reads with tokens', async () => { + const { db, env, cookie } = await seedOwner(); + const created = await createChannel(env, cookie); + const key = created.channel.public_key; + + const denied = await app.request( + `/public/channels/${key}/config`, + { headers: { origin: 'https://evil.example' } }, + env, + ); + expect(denied.status).toBe(404); + + const config = await app.request( + `/public/channels/${key}/config`, + { headers: { origin: 'https://example.com' } }, + env, + ); + expect(config.status).toBe(200); + + const started = await app.request( + `/public/channels/${key}/sessions`, + { + method: 'POST', + headers: { origin: 'https://example.com', 'content-type': 'application/json' }, + body: JSON.stringify({ + email: 'customer@example.com', + name: 'Customer', + subject: 'Need help', + message: 'I need help with my subscription invoice.\nPlease keep the line break.', + }), + }, + env, + ); + const startBody: any = await started.json(); + expect(started.status).toBe(200); + expect(startBody.session_token).toMatch(/^pst_/); + expect(db.prepare(`SELECT subject, requester_email FROM ticket`).get()).toEqual({ + subject: 'Need help', + requester_email: 'customer@example.com', + }); + + const invalidRead = await app.request(`/public/sessions/${startBody.session_id}`, {}, env); + expect(invalidRead.status).toBe(401); + + const deniedRead = await app.request( + `/public/sessions/${startBody.session_id}`, + { + headers: { + origin: 'https://evil.example', + authorization: `Bearer ${startBody.session_token}`, + }, + }, + env, + ); + expect(deniedRead.status).toBe(403); + + const appended = await app.request( + `/public/sessions/${startBody.session_id}/messages`, + { + method: 'POST', + headers: { + origin: 'https://example.com', + authorization: `Bearer ${startBody.session_token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ message: 'Adding more detail.' }), + }, + env, + ); + expect(appended.status).toBe(200); + + const timeline = await app.request( + `/public/sessions/${startBody.session_id}`, + { headers: { authorization: `Bearer ${startBody.session_token}` } }, + env, + ); + const timelineBody: any = await timeline.json(); + expect(timeline.status).toBe(200); + expect(timelineBody.messages.map((msg: any) => msg.body)).toEqual([ + 'I need help with my subscription invoice.\nPlease keep the line break.', + 'Adding more detail.', + ]); + }); + + it('serves hosted forms and widget scripts for public channels', async () => { + const { db, env, cookie } = await seedOwner(); + const created = await createChannel(env, cookie, { + kind: 'form', + name: 'Contact support', + require_email: false, + allowed_origins: ['https://customer.example'], + }); + const key = created.channel.public_key; + + const form = await app.request( + `/forms/${key}`, + { headers: { origin: 'https://ranse.example' } }, + env, + ); + expect(form.status).toBe(200); + const formHtml = await form.text(); + expect(formHtml).toContain('Contact support'); + expect(formHtml).toContain(' statement.run())), }; + const blobStore = new Map(); + return { db, env: { DB: envDb, COOKIE_SIGNING_KEY: 'test-secret', - BLOB: { put: async () => undefined, get: async () => null }, + BLOB: { + put: async (key: string, body: string | ArrayBuffer | Uint8Array) => { + const bytes = + typeof body === 'string' + ? new TextEncoder().encode(body) + : body instanceof Uint8Array + ? body + : new Uint8Array(body); + blobStore.set(key, bytes); + }, + get: async (key: string) => { + const bytes = blobStore.get(key); + if (!bytes) return null; + return { + text: async () => new TextDecoder().decode(bytes), + arrayBuffer: async () => + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + }; + }, + delete: async (key: string) => { + blobStore.delete(key); + }, + }, + WEBHOOKS: { send: async () => undefined }, + RATE_LIMIT_INGEST: { limit: async () => ({ success: true }) }, } as any, }; } diff --git a/wrangler.jsonc b/wrangler.jsonc index 3c46ca6..09e1c2f 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -21,7 +21,18 @@ "directory": "dist", "not_found_handling": "single-page-application", "binding": "ASSETS", - "run_worker_first": ["/api/*", "/setup/*", "/auth/*", "/agents/*", "/assets/workspace/*", "/assets/user/*", "/healthz"] + "run_worker_first": [ + "/api/*", + "/setup/*", + "/auth/*", + "/agents/*", + "/assets/workspace/*", + "/assets/user/*", + "/public/*", + "/forms/*", + "/widget/*", + "/healthz" + ] }, "version_metadata": { "binding": "CF_VERSION" }, @@ -44,13 +55,9 @@ } ], - "r2_buckets": [ - { "binding": "BLOB", "bucket_name": "ranse-blob" } - ], + "r2_buckets": [{ "binding": "BLOB", "bucket_name": "ranse-blob" }], - "kv_namespaces": [ - { "binding": "CACHE", "id": "ranse-cache" } - ], + "kv_namespaces": [{ "binding": "CACHE", "id": "ranse-cache" }], "queues": { "producers": [ From ee0c928f4966c13146a38b52ba1b2f63f7459453 Mon Sep 17 00:00:00 2001 From: tomicodesdev <32748319+tomicodesdev@users.noreply.github.com> Date: Tue, 19 May 2026 08:03:50 +0100 Subject: [PATCH 2/2] feat: implement multi-channel communication framework with unified adapters and voice support --- docs/architecture.md | 143 +++++ docs/roadmap.md | 41 +- .../20260519_000000_public_channels.sql | 90 ++- migrations/20260519_010000_voice.sql | 86 +++ .../20260519_020000_notifications_secrets.sql | 136 ++++ src/agents/specialists/sla.ts | 43 +- src/agents/supervisor/replies.ts | 44 +- src/api/channels.ts | 85 ++- src/channels/adapters/apple-business.ts | 140 +++++ src/channels/adapters/chat.ts | 16 + src/channels/adapters/discord.ts | 166 +++++ src/channels/adapters/email.ts | 24 + src/channels/adapters/form.ts | 18 + src/channels/adapters/index.ts | 47 ++ src/channels/adapters/instagram.ts | 123 ++++ src/channels/adapters/messenger.ts | 117 ++++ src/channels/adapters/meta-shared.ts | 68 ++ src/channels/adapters/rcs.ts | 123 ++++ src/channels/adapters/slack.ts | 167 +++++ src/channels/adapters/sms.ts | 163 +++++ src/channels/adapters/teams.ts | 158 +++++ src/channels/adapters/telegram.ts | 156 +++++ src/channels/adapters/webhook.ts | 157 +++++ src/channels/adapters/whatsapp.ts | 174 ++++++ src/channels/admin.ts | 215 +++++++ src/channels/capabilities.ts | 127 ++++ src/channels/egress.ts | 375 +++++++++++ src/channels/identity.ts | 172 +++++ src/channels/index.ts | 585 +----------------- src/channels/ingress-state.ts | 155 +++++ src/channels/ingress.ts | 228 +++++++ src/channels/lookup.ts | 50 ++ src/channels/registry.ts | 33 + src/channels/routes.ts | 153 ++--- src/channels/session-replies.ts | 149 +++++ src/channels/sessions.ts | 234 +++++++ src/channels/surfaces.ts | 89 +++ src/channels/utils.ts | 103 +++ src/channels/voice/adapter.ts | 144 +++++ src/channels/voice/index.ts | 27 + src/channels/voice/ingest.ts | 291 +++++++++ src/channels/voice/providers/elevenlabs.ts | 209 +++++++ src/channels/voice/providers/gemini-live.ts | 59 ++ .../voice/providers/twilio-realtime.ts | 157 +++++ src/channels/voice/registry.ts | 35 ++ src/channels/voice/store.ts | 218 +++++++ src/channels/voice/streams/gemini-stream.ts | 293 +++++++++ src/channels/voice/streams/mulaw.ts | 51 ++ .../voice/streams/turn-orchestrator.ts | 145 +++++ src/channels/voice/streams/twilio-stream.ts | 287 +++++++++ src/env.ts | 4 + src/jobs/cascade-sweep.ts | 24 + src/jobs/dispatch-retry-sweep.ts | 39 ++ src/jobs/scheduled.ts | 15 + src/jobs/sla-sweep.ts | 10 +- src/lib/ids.ts | 10 + src/lib/secrets.ts | 181 ++++++ src/lib/storage.ts | 8 + src/notifications/cascade/advancer.ts | 244 ++++++++ src/notifications/cascade/index.ts | 24 + src/notifications/cascade/runner.ts | 181 ++++++ src/notifications/cascade/store.ts | 238 +++++++ src/notifications/cascade/templates.ts | 131 ++++ src/notifications/channels/email.ts | 26 +- src/notifications/channels/index.ts | 2 +- src/notifications/channels/slack.ts | 2 +- src/notifications/preferences.ts | 212 +++++++ src/procedures/library-data.ts | 90 ++- src/procedures/library-mcp-tools.ts | 42 ++ src/types/channels.ts | 238 ++++++- src/types/notifications.ts | 117 ++++ src/types/voice.ts | 134 ++++ src/ui/api.ts | 10 + src/ui/views/PublicChannelsSection.tsx | 367 +++++++++-- tests/channel-adapters.test.ts | 436 +++++++++++++ tests/helpers/workspace-db.ts | 181 ++++++ tests/new-adapters.test.ts | 242 ++++++++ tests/notifications-secrets.test.ts | 196 ++++++ tests/procedure-library.test.ts | 1 + tests/voice.test.ts | 325 ++++++++++ 80 files changed, 10055 insertions(+), 744 deletions(-) create mode 100644 migrations/20260519_010000_voice.sql create mode 100644 migrations/20260519_020000_notifications_secrets.sql create mode 100644 src/channels/adapters/apple-business.ts create mode 100644 src/channels/adapters/chat.ts create mode 100644 src/channels/adapters/discord.ts create mode 100644 src/channels/adapters/email.ts create mode 100644 src/channels/adapters/form.ts create mode 100644 src/channels/adapters/index.ts create mode 100644 src/channels/adapters/instagram.ts create mode 100644 src/channels/adapters/messenger.ts create mode 100644 src/channels/adapters/meta-shared.ts create mode 100644 src/channels/adapters/rcs.ts create mode 100644 src/channels/adapters/slack.ts create mode 100644 src/channels/adapters/sms.ts create mode 100644 src/channels/adapters/teams.ts create mode 100644 src/channels/adapters/telegram.ts create mode 100644 src/channels/adapters/webhook.ts create mode 100644 src/channels/adapters/whatsapp.ts create mode 100644 src/channels/admin.ts create mode 100644 src/channels/capabilities.ts create mode 100644 src/channels/egress.ts create mode 100644 src/channels/identity.ts create mode 100644 src/channels/ingress-state.ts create mode 100644 src/channels/ingress.ts create mode 100644 src/channels/lookup.ts create mode 100644 src/channels/registry.ts create mode 100644 src/channels/session-replies.ts create mode 100644 src/channels/sessions.ts create mode 100644 src/channels/surfaces.ts create mode 100644 src/channels/utils.ts create mode 100644 src/channels/voice/adapter.ts create mode 100644 src/channels/voice/index.ts create mode 100644 src/channels/voice/ingest.ts create mode 100644 src/channels/voice/providers/elevenlabs.ts create mode 100644 src/channels/voice/providers/gemini-live.ts create mode 100644 src/channels/voice/providers/twilio-realtime.ts create mode 100644 src/channels/voice/registry.ts create mode 100644 src/channels/voice/store.ts create mode 100644 src/channels/voice/streams/gemini-stream.ts create mode 100644 src/channels/voice/streams/mulaw.ts create mode 100644 src/channels/voice/streams/turn-orchestrator.ts create mode 100644 src/channels/voice/streams/twilio-stream.ts create mode 100644 src/jobs/cascade-sweep.ts create mode 100644 src/jobs/dispatch-retry-sweep.ts create mode 100644 src/lib/secrets.ts create mode 100644 src/notifications/cascade/advancer.ts create mode 100644 src/notifications/cascade/index.ts create mode 100644 src/notifications/cascade/runner.ts create mode 100644 src/notifications/cascade/store.ts create mode 100644 src/notifications/cascade/templates.ts create mode 100644 src/notifications/preferences.ts create mode 100644 src/types/notifications.ts create mode 100644 src/types/voice.ts create mode 100644 tests/channel-adapters.test.ts create mode 100644 tests/new-adapters.test.ts create mode 100644 tests/notifications-secrets.test.ts create mode 100644 tests/voice.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index ca1b698..7b79b7f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -187,6 +187,149 @@ Visitor browser Public channels are derivatives of the ticket model, not a separate inbox. A chat or form submission creates a normal `ticket`, stores inbound text in `message_index` plus R2, emits the same notification events, records audit rows, starts ticket-created procedures, and lets operators answer from the existing ticket console. Origin allowlists and unguessable session tokens scope public browser access; internal notes are never returned through public session reads. +## Channel adapters (Phase 9.1) + +Every async surface — chat widget, hosted form, Slack, SMS, Discord, Telegram, WhatsApp — implements the same `ChannelAdapter` contract in `src/channels/adapters/`: + +```ts +interface ChannelAdapter { + kind: ChannelKind; + capabilities: ChannelCapabilities; + validateConfig(input: unknown): Record; + onActivate?(env, channel): Promise; + verifyWebhook(env, channel, headers, rawBody): Promise<{ ok: boolean; reason?: string }>; + parseIngress(env, channel, headers, rawBody): Promise; + handleChallenge?(env, channel, request): Promise; + egress(env, channel, message): Promise; +} +``` + +Inbound flow for any third-party channel: + +``` +POST /public/channels/:key/webhook + └─ getPublicChannelByKey(key) + └─ adapter = getAdapter(channel.kind) + ├─ adapter.handleChallenge?(req) # Slack url_verification, Meta hub.challenge, Discord PING + ├─ adapter.verifyWebhook(headers, rawBody) + ├─ adapter.parseIngress(rawBody) → IngressMessage | null + └─ ingestInboundMessage(channel, msg) + ├─ dedup on (workspace, channel, external_id) + ├─ resolveCustomerIdentity(...) → customer_id (stitch by email/phone) + ├─ open new ticket OR continue existing thread (channel.id + customer_id) + ├─ message_index INSERT + R2 body + ├─ emit ticket.created / message.inbound + └─ startTriggeredProcedureRuns(channel: { kind, capabilities }) +``` + +Outbound flow when an operator (or autonomous reply pipeline) sends a reply: + +``` +sendThreadedReply + ├─ loadReplyContext(ticket) → { origin_channel_kind, origin_channel_id, ... } + ├─ if origin_channel_kind === 'email': use the legacy MIME + reply-address pipeline. + └─ else: dispatchOutbound({ ticketId, messageId, text, fromName }) + ├─ load public_channel by ticket.origin_channel_id + ├─ getAdapter(kind).egress(channel, message) + ├─ record channel_outbound_dispatch { status, attempts, last_error, external_id } + └─ stamp message_index.rfc_message_id with the external thread id so the + next inbound reply continues the same ticket +``` + +Identity stitching: + +- One row in `customer` per person; one row in `channel_identity` per (channel, external_id). +- Stitching is conservative: when an ingress payload carries an email or phone that already exists on another identity (or on a customer's primary contact), we reuse that customer; otherwise we open a fresh customer. Operators can manually merge in the UI later. +- This is what gives the operator a single chronological history when the same person reaches out over email today and SMS tomorrow. + +Capabilities + procedures: + +- Each adapter exports a `ChannelCapabilities` map (`supportsOtpDelivery`, `supportsButtons`, `supportsRichText`, `maxMessageLength`, …). +- Procedures receive `channel.capabilities` in their evaluation context and can branch on it with `if { var: 'channel.capabilities.supportsOtpDelivery', equals: true }`. +- The reference library procedure `verify-identity-channel-aware` shows the pattern: SMS/Telegram/WhatsApp tickets go through OTP, chat/form fall back to a magic link over email. + +Adding a new channel: + +1. Implement `ChannelAdapter` in `src/channels/adapters/.ts` (capability map, signature verify, parse, egress). +2. Register it in `src/channels/adapters/index.ts`. +3. Add the kind to `ChannelKind` and `PUBLIC_CHANNEL_KINDS` in `src/types/channels.ts`. +4. Add the kind to the settings UI's `CHANNEL_KINDS` and `CONFIG_FIELDS` map. + +No schema migration is needed — config is opaque JSON the adapter validates. + +## Voice (Phase 9 voice) + +Voice is a single `ChannelAdapter` (`kind = 'voice'`) that delegates to one of three pluggable provider modules selected by `config.provider`: + +| Provider | Inbound media path | Reply path | Best for | +|--------------------|-----------------------------------------------------|-------------------------------------------------------|---------------------------------------------------| +| `elevenlabs` | Provider-owned phone number → ElevenLabs agent | Post-call webhook delivers full transcript + audio | Fastest setup, managed agent, premium voice | +| `twilio_realtime` | Twilio Voice number → `` to Worker WS | Worker bridges Whisper (STT) + LLM + MeloTTS in-flight| Self-hosted, single-stack Cloudflare deploy | +| `gemini_live` | Browser/Twilio WS → Worker WS → Gemini Live | Native bidi audio from Gemini | Best latency + native multimodal | + +Data model: + +- `voice_call` — one row per phone call. Tracks status (ringing → connected → completed/failed/missed), caller/callee numbers, duration, R2 keys for full recording + transcript, summary, agent mode (`autonomous` | `human` | `mixed`). +- `voice_call_turn` — every utterance (caller) and every reply (agent), with per-turn audio R2 key, model name, latency, optional confidence and interruption flag. +- `voice_provider_event` — raw provider payloads (signed-webhook bodies, status callbacks) stored to R2 with a D1 index for replay/debugging. + +Every turn is also written to `message_index` with `direction='inbound'` (caller) or `'outbound'` (agent) and `rfc_message_id = voice::thread::` so the existing reply pipeline, procedures, identity stitching, and operator UI see voice tickets transparently. + +Webhook + WebSocket routing: + +``` +/public/channels/:key/webhook + ├─ ?answer=1 POST → provider.answerCall() returns TwiML + ├─ POST → provider.parseEvent() (post-call transcript / status callback) + └─ Upgrade: websocket → provider.handleStream() bridges audio in real time + +/public/channels/:key/voice/ws → friendly alias for the WebSocket upgrade, + used by the browser-side Gemini Live client. +``` + +Capability flags exposed to procedures: `supportsVoice`, `supportsStreaming`, `supportsOtpDelivery` (true — an agent can read OTP aloud), `maxMessageLength: 600`. Procedures branching on `channel.capabilities.supportsVoice === true` should keep replies short, avoid links and dictation-only material, and prefer SMS/email follow-up for anything the customer would have to write down. + +Adding a new voice provider: + +1. Implement `VoiceProviderModule` in `src/channels/voice/providers/.ts` (`validateConfig`, `verifyEvent`, `parseEvent`, optionally `answerCall` + `handleStream`). +2. Register it in `src/channels/voice/index.ts`. +3. Add the provider kind to `VoiceProviderKind` and `VOICE_PROVIDER_KINDS` in `src/types/channels.ts`. +4. Add a UI option entry to `PublicChannelsSection.tsx` (`KIND_OPTIONS` + `CONFIG_FIELDS`). + +No schema migration required. + +## Customer preferences, encryption, cascade, retries (Phase 9 final) + +**Secret encryption at rest.** Each `ChannelAdapter` declares `secretFields: string[]` for the credential keys in its config. The admin layer (`channels/admin.ts`) splits the validated config on persist into `config_json` (plaintext, indexable, visible in operator UI) and `secrets_ciphertext` (AES-GCM-256, IV per write, workspace-derived key via HKDF-SHA256 with the workspace id as salt against `env.SECRET_ENCRYPTION_KEY`). On read, `parseChannelConfigAsync(env, channel)` decrypts and merges; the synchronous `parseChannelConfig` returns only the plaintext half (capability lookups, UI rendering). Existing channels with plaintext `config_json` keep working — the read path tolerates the legacy format and the next save re-seals. + +**Customer channel preferences.** `customer_channel_preference (workspace, customer, channel_kind, status, quiet_hours_*, timezone)` rows let customers opt in/out per surface. `canDeliverTo()` is called by both the outbound dispatcher and the cascade engine; `opted_out` is a hard block (no retry), `quiet_hours` schedules the next retry to the window edge. STOP/UNSUBSCRIBE/CANCEL keywords on inbound text auto-disable the channel via `applyStopKeyword` — wired into `channels/ingress.ts` so every adapter benefits. + +**Omnichannel notification cascade.** + +``` +notifyCustomer({ workspaceId, customerId, ticketId?, templateSlug?, payload, urgency, cascade? }) + ├─ resolve template (default channels + per-channel bodies) or use explicit cascade + ├─ insertPlan(...) + insertStep(...) for each step + └─ advancePlan(plan) → fireStep(first) immediately + +scheduled tick (cascade-sweep, every 5 minutes) + ├─ findPlansDueBefore(now) → due steps across active plans + ├─ canDeliverTo() preference check; skip step + schedule next on block + ├─ dispatchOutbound(ticket, message) through the adapter for that step's channel + ├─ updateStepStatus('sent' | 'failed' | 'skipped') + recordDeliveryEvent + └─ scheduleNextStepAfter() based on trigger_on rules + +inbound on any channel (channels/ingress.ts) + └─ acknowledgePlansForCustomer(workspace, customer, channelKind) + └─ matching sent step → status='read', plan='completed' +``` + +Cascade step `trigger_on` values: `immediate`, `previous_failed`, `previous_unread`, `previous_no_ack`, `time_elapsed`. Default inter-step delay is urgency-aware (urgent: 1m, high: 5m, normal: 30m, low: 4h). Templates carry per-channel bodies under `bodies_json` and render `{{ payload.foo }}` at materialize-time. + +**Retry queue + DLQ.** `channel_outbound_dispatch` gained `next_attempt_at` + `max_attempts`. Failed sends schedule the next attempt with `retryBackoffMs(attempt)` — 60s, 5m, 30m, 2h, 8h with ±10% jitter — and the `dispatch-retry-sweep` job (5-minute cadence, sharing the SLA-sweep heartbeat) calls `retryPendingDispatch(id)` which re-fires the adapter and either upgrades the row to `delivered`, schedules the next backoff slot, or settles to `failed` after `max_attempts`. Preference-blocked sends never get a retry slot. + +**Generic outbound webhook.** The `webhook` adapter is the meta-channel: HMAC-SHA256 signed in both directions, JSON body for inbound (`{ external_id, external_thread_id, text, from: {…} }`) and outbound (`{ kind, message, sent_at }`). Operators paste an `endpoint_url` + `shared_secret` and a custom-headers JSON blob — they can route Ranse into any internal system without writing a new adapter or shipping new code. + ## Scaling model - One `WorkspaceSupervisorAgent` DO per workspace. The email handler pins by `idFromName(workspaceId)` so all events for a workspace funnel through one instance — consistent state, no cross-DO coordination needed. diff --git a/docs/roadmap.md b/docs/roadmap.md index 4f5cab7..abc44b3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -55,7 +55,7 @@ Most "AI agent" tools are chat shaped and bolt email on. Real B2B support lives **Phase 8 — Insights & auto-improving KB** is shipped. Workspaces get conversation rubric scoring, aggregate insight dashboards, unresolved-intent KB suggestions, accepted-suggestion publishing into the knowledge base, drift signals against successful replies, and weekly scheduled insight maintenance. -**Phase 9 — Multi-channel web surfaces** is shipped for chat widgets and hosted support forms. Public channels create normal tickets in the same inbox, with origin allowlists, session tokens, public thread reads, notification events, audit rows, and procedure triggers. +**Phase 9 — Multi-channel** is shipped end-to-end across thirteen channel kinds + three voice providers behind one `ChannelAdapter` contract: chat widget, hosted form, Slack, SMS (Twilio), Discord, Telegram, WhatsApp Business, Microsoft Teams, Facebook Messenger, Instagram DM, Google Business Messages (RCS), Apple Messages for Business, a generic outbound webhook, and voice (ElevenLabs Conversational AI, Twilio + Cloudflare Workers AI, Gemini Live). Surrounding the adapters: signed-webhook verification, replay-safe ingress dedup, capability-aware procedure branching, per-channel SLA overrides, cross-channel identity stitching, customer channel preferences (with STOP-keyword honoring + quiet hours), workspace-keyed AES-GCM encryption of every adapter secret at rest, an omnichannel notification cascade engine (`notifyCustomer({customer, template, urgency, cascade})` fans across channels with read-receipt acknowledgement), and an exponential-backoff retry queue with dead-letter for failed outbound dispatches. That's now a retrieval-grounded early Fin **Copilot** equivalent with workspace isolation, traceable multi-hop retrieval, a conservative autonomous-send path, a procedure-driven agent loop, external action execution through the open MCP protocol, a regression gate against the workspace's own ticket history, a forkable procedure library, a sovereign insights loop that turns real support history into reviewed KB improvements, and public web surfaces that feed the same ticket model. Voice remains deliberately last. @@ -213,14 +213,43 @@ The `customer_data` search scope still fails closed with an explicit trace; proc - Weekly scheduled insight maintenance scores recent conversations, refreshes unresolved-intent suggestions, detects KB drift, prunes old recomputable score rows, and isolates per-workspace failures inside the customer's Cloudflare account. ## Phase 9 — Multi-channel + voice -**Status: web surfaces shipped; voice remains deliberately last.** +**Status: every channel shipped, voice included.** *Principle 7 — email is the wedge; other channels are derivatives* -- Embeddable chat widget: shipped as a hosted `/widget/.js` script backed by D1 channel config, origin allowlists, token-scoped visitor sessions, and the existing ticket/message tables. -- Web form → ticket bridge: shipped as hosted `/forms/` pages and JSON session APIs that create normal support tickets. -- Operators manage public channels in Settings, choose the mailbox, copy embed code, disable channels, and keep all replies inside the same inbox/audit/procedure surfaces. -- Voice last — Cloudflare Realtime + Whisper + TTS. Hardest channel, lowest leverage for v1, ship only when email + chat are something we'd want to talk to. +Channels are now plug-and-play behind a single `ChannelAdapter` contract (`src/channels/adapters/`). Adding a new built-in channel is one adapter file (signature verify, ingress parse, egress send, capability map, optional `onActivate` to register the webhook with the provider) plus one line in `adapters/index.ts`. Adapter config lives in `public_channel.config_json` so new channels do not require schema migrations. + +**Shipped surfaces:** + +- **Embedded chat widget** at `/widget/.js` and **hosted form** at `/forms/` — first-party surfaces that talk to `/public/*` directly. +- **Slack** Events API (signed-webhook ingress, `chat.postMessage` egress, thread_ts threading). +- **SMS** via Twilio Messages API (HMAC-SHA1 signature verification, `Messages.json` egress; provider field future-proofs Vonage/Plivo). +- **Discord** Interactions endpoint (Ed25519 signature verification, bot REST egress). +- **Telegram** Bot API (secret-token webhook header, `setWebhook` on activation, `sendMessage` egress). +- **WhatsApp Business Cloud API** (X-Hub-Signature-256 verification, multi-WABA filtering by phone_number_id, Graph `/messages` egress). +- **Microsoft Teams** (Bot Framework activity webhooks + Azure client-credentials bearer token for outbound). +- **Facebook Messenger** (Meta Graph webhook, per-Page access token, `/me/messages` egress with `messaging_type: RESPONSE`). +- **Instagram DM** (Meta Graph webhook with `object='instagram'`, IG-business-account-scoped outbound). +- **Google Business Messages (RCS)** (HMAC-signed partner webhook + OAuth bearer outbound). +- **Apple Messages for Business** (`x-apple-webhook-secret` or HMAC-of-body inbound, JWT bearer outbound through the MSP gateway). +- **Generic outbound webhook** — the meta-channel. Signed HMAC in both directions, lets operators plug any system into Ranse without writing an adapter. +- **Voice** — single `voice` channel kind, three dynamic providers selected per workspace via `config.provider`: + - **ElevenLabs Conversational AI** — signed post-call webhook ingests full transcript, recording (mp3 → R2), summary, and per-turn rows; tool calls flow back into the MCP/procedure surface. + - **Twilio Voice + Cloudflare Workers AI** — TwiML `` answers the call, the Worker bridges μ-law audio through Whisper (STT) + Llama (reply) + MeloTTS (speech), turn-by-turn persistence happens inside the WebSocket relay. + - **Gemini Live API** — browser/Twilio WebSocket relayed straight into Google's `BidiGenerateContent` channel; bidirectional audio + inline transcripts. + Calls land in a normal `ticket` with `origin_channel_kind='voice'`. Every call gets a `voice_call` row and every utterance a `voice_call_turn` plus a `message_index` entry, so the reply pipeline, procedures, identity stitching, and operator UI see voice transparently. + +**Cross-cutting infrastructure:** + +- Single `/public/channels//webhook` endpoint dispatches to the right adapter; the route does not care which provider. +- `channel_outbound_dispatch` records every egress attempt with status, attempts, last_error, and provider message id for audit and retry. +- `customer` + `channel_identity` stitch (workspace, channel, external_id) records to one customer id; operators see one history per person across surfaces. Stitching is conservative — same email/phone merges, ambiguity creates separate records. +- Per-channel SLA + default priority + default assignee override the workspace baseline for tickets that originate on that channel. +- Procedures get `channel.capabilities` in context (`supportsOtpDelivery`, `supportsButtons`, `supportsRichText`, `maxMessageLength`, `supportsVoice`, `supportsStreaming`) so the same workflow takes the strongest identity-proof path the originating channel supports. `verify-identity-channel-aware` ships in the library as the reference branch; the voice path adds capability-aware reply-length trimming (the LLM is told to stay under ~30 words because the reply will be spoken). +- **Customer channel preferences.** `customer_channel_preference` rows gate every outbound — opt-out is hard-blocking, and quiet-hours windows roll cascade plans forward instead of breaching. STOP/UNSUBSCRIBE keywords on inbound text auto-disable the channel for that customer. Operators see + edit preferences in the customer drawer. +- **Workspace-keyed encryption at rest.** Adapters declare `secretFields` (bot tokens, API keys, auth tokens). Channel admin partitions the validated config into `config_json` (public, visible in dashboards) and `secrets_ciphertext` (AES-GCM-256 with HKDF-derived per-workspace key). Existing channels keep working — the read path tolerates legacy plaintext until the next save. +- **Notification cascade engine.** `notifyCustomer({customer, template, urgency, cascade})` materializes a `notification_plan` plus `notification_step` rows. The scheduled tick advances plans, an inbound customer reply on any channel ack's all pending plans for that customer, and templates render with `{{ payload.field }}` substitution. Cascade trigger reasons: `immediate`, `previous_failed`, `previous_unread`, `previous_no_ack`, `time_elapsed`. +- **Retry queue + DLQ.** Failed outbound dispatches schedule `next_attempt_at` with 60s/5m/30m/2h/8h exponential backoff (±10% jitter); the periodic `dispatch-retry-sweep` re-fires them through the adapter, settling into status `failed` after `max_attempts`. Preference-blocked sends never retry. ## What we are explicitly not building diff --git a/migrations/20260519_000000_public_channels.sql b/migrations/20260519_000000_public_channels.sql index f2623bc..a218536 100644 --- a/migrations/20260519_000000_public_channels.sql +++ b/migrations/20260519_000000_public_channels.sql @@ -1,16 +1,27 @@ --- Phase 9 public web channels: embeddable chat and hosted support forms. +-- Phase 9 channels: hosted chat widget, hosted forms, plus every async +-- third-party surface (Slack, SMS, Discord, Telegram, WhatsApp) behind one +-- adapter contract. Adapter config is opaque JSON so adding a new channel +-- does not require a schema migration. CREATE TABLE IF NOT EXISTS public_channel ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, mailbox_id TEXT NOT NULL, - kind TEXT NOT NULL CHECK(kind IN ('chat','form')), + kind TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL UNIQUE, enabled INTEGER NOT NULL DEFAULT 1, require_email INTEGER NOT NULL DEFAULT 1, allowed_origins_json TEXT NOT NULL DEFAULT '[]', welcome_message TEXT, + config_json TEXT NOT NULL DEFAULT '{}', + secret_ciphertext TEXT, + signing_secret TEXT, + sla_first_response_minutes INTEGER, + sla_resolution_minutes INTEGER, + default_priority TEXT, + default_assignee_user_id TEXT, + last_event_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, @@ -20,6 +31,10 @@ CREATE TABLE IF NOT EXISTS public_channel ( CREATE INDEX IF NOT EXISTS idx_public_channel_workspace ON public_channel(workspace_id, kind, updated_at DESC); +-- Session-based public surfaces (chat widget, hosted form). Third-party +-- channels (Slack/SMS/Discord/Telegram/WhatsApp) do NOT use this table — +-- their threads live on message_index.rfc_message_id with a kind-prefixed +-- thread id. CREATE TABLE IF NOT EXISTS public_conversation_session ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, @@ -45,3 +60,74 @@ CREATE INDEX IF NOT EXISTS idx_public_session_channel CREATE INDEX IF NOT EXISTS idx_public_session_ticket ON public_conversation_session(workspace_id, ticket_id); + +-- Cross-channel customer identity. One customer can have many +-- (channel_kind, external_id) pairs — same person, different surfaces. +CREATE TABLE IF NOT EXISTS customer ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + display_name TEXT, + primary_email TEXT, + primary_phone TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_customer_workspace_email + ON customer(workspace_id, primary_email) WHERE primary_email IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_customer_workspace_phone + ON customer(workspace_id, primary_phone) WHERE primary_phone IS NOT NULL; + +CREATE TABLE IF NOT EXISTS channel_identity ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + customer_id TEXT NOT NULL, + channel_kind TEXT NOT NULL, + external_id TEXT NOT NULL, + display_name TEXT, + email TEXT, + phone TEXT, + first_seen_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + FOREIGN KEY (customer_id) REFERENCES customer(id) ON DELETE CASCADE, + UNIQUE(workspace_id, channel_kind, external_id) +); +CREATE INDEX IF NOT EXISTS idx_channel_identity_customer + ON channel_identity(workspace_id, customer_id); + +-- Outbound dispatch tracking. One row per outbound message routed through +-- a third-party adapter; carries the provider result + retry state. Email +-- replies are owned by src/agents/supervisor/replies.ts and do not land here. +CREATE TABLE IF NOT EXISTS channel_outbound_dispatch ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + message_id TEXT NOT NULL, + channel_kind TEXT NOT NULL, + channel_id TEXT, + status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + external_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + FOREIGN KEY (ticket_id) REFERENCES ticket(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_channel_dispatch_ticket + ON channel_outbound_dispatch(workspace_id, ticket_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_channel_dispatch_status + ON channel_outbound_dispatch(workspace_id, status, updated_at) WHERE status = 'pending'; + +-- Tickets get an explicit origin channel so the outbound dispatcher knows +-- which adapter to use. Email tickets stay origin_channel_kind = 'email' +-- with no channel_id (they live on mailbox). +ALTER TABLE ticket ADD COLUMN origin_channel_kind TEXT NOT NULL DEFAULT 'email'; +ALTER TABLE ticket ADD COLUMN origin_channel_id TEXT; +ALTER TABLE ticket ADD COLUMN customer_id TEXT; + +CREATE INDEX IF NOT EXISTS idx_ticket_customer + ON ticket(workspace_id, customer_id) WHERE customer_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_ticket_origin_channel + ON ticket(workspace_id, origin_channel_id) WHERE origin_channel_id IS NOT NULL; diff --git a/migrations/20260519_010000_voice.sql b/migrations/20260519_010000_voice.sql new file mode 100644 index 0000000..5413e14 --- /dev/null +++ b/migrations/20260519_010000_voice.sql @@ -0,0 +1,86 @@ +-- Phase 9 voice. Calls are first-class: one `voice_call` row per phone call +-- (or browser session), with each utterance and reply persisted as a +-- `voice_call_turn`. The same turns also land in `message_index` so the +-- existing reply/procedure pipeline sees voice tickets the same way it +-- sees email or SMS tickets — just with audio attachments. + +CREATE TABLE IF NOT EXISTS voice_call ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + customer_id TEXT, + provider TEXT NOT NULL, + external_call_id TEXT NOT NULL, + caller_number TEXT, + callee_number TEXT, + direction TEXT NOT NULL DEFAULT 'inbound', + status TEXT NOT NULL DEFAULT 'ringing', + agent_mode TEXT NOT NULL DEFAULT 'autonomous', + started_at INTEGER NOT NULL, + connected_at INTEGER, + ended_at INTEGER, + duration_ms INTEGER, + recording_r2_key TEXT, + transcript_r2_key TEXT, + summary TEXT, + error TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + FOREIGN KEY (channel_id) REFERENCES public_channel(id) ON DELETE CASCADE, + FOREIGN KEY (ticket_id) REFERENCES ticket(id) ON DELETE CASCADE, + FOREIGN KEY (customer_id) REFERENCES customer(id) ON DELETE SET NULL, + UNIQUE(workspace_id, channel_id, external_call_id) +); +CREATE INDEX IF NOT EXISTS idx_voice_call_ticket + ON voice_call(workspace_id, ticket_id); +CREATE INDEX IF NOT EXISTS idx_voice_call_channel + ON voice_call(workspace_id, channel_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_voice_call_status + ON voice_call(workspace_id, status, updated_at DESC) WHERE status IN ('ringing','connected'); + +CREATE TABLE IF NOT EXISTS voice_call_turn ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + call_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + message_id TEXT, + sequence INTEGER NOT NULL, + role TEXT NOT NULL, + text TEXT, + audio_r2_key TEXT, + duration_ms INTEGER, + model TEXT, + confidence REAL, + interrupted INTEGER NOT NULL DEFAULT 0, + started_at INTEGER NOT NULL, + completed_at INTEGER, + metadata_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + FOREIGN KEY (call_id) REFERENCES voice_call(id) ON DELETE CASCADE, + FOREIGN KEY (ticket_id) REFERENCES ticket(id) ON DELETE CASCADE, + UNIQUE(call_id, sequence) +); +CREATE INDEX IF NOT EXISTS idx_voice_turn_call + ON voice_call_turn(call_id, sequence); + +-- Raw provider events kept for replay + debugging. Bounded by a periodic +-- cleanup job (insights maintenance is already wired weekly). +CREATE TABLE IF NOT EXISTS voice_provider_event ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + call_id TEXT, + channel_id TEXT NOT NULL, + provider TEXT NOT NULL, + event_type TEXT NOT NULL, + payload_r2_key TEXT, + received_at INTEGER NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + FOREIGN KEY (channel_id) REFERENCES public_channel(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_voice_event_channel + ON voice_provider_event(workspace_id, channel_id, received_at DESC); +CREATE INDEX IF NOT EXISTS idx_voice_event_call + ON voice_provider_event(call_id, received_at) WHERE call_id IS NOT NULL; diff --git a/migrations/20260519_020000_notifications_secrets.sql b/migrations/20260519_020000_notifications_secrets.sql new file mode 100644 index 0000000..fc7210f --- /dev/null +++ b/migrations/20260519_020000_notifications_secrets.sql @@ -0,0 +1,136 @@ +-- Phase 9 final pass: secret encryption, customer channel preferences, +-- omnichannel notification cascade, retry/DLQ for outbound dispatch. + +-- Secrets at rest. The migration adds the ciphertext column; the application +-- splits new writes into config_json (public) + secrets_ciphertext (sealed +-- with workspace-keyed AES-GCM). Existing rows keep their plaintext config +-- and continue to work — the read path merges both sources transparently. +ALTER TABLE public_channel ADD COLUMN secrets_ciphertext TEXT; + +-- Retry plumbing on the dispatch row. The reaper job + queue consumer +-- update next_attempt_at and increment attempts; max_attempts caps the loop. +ALTER TABLE channel_outbound_dispatch ADD COLUMN next_attempt_at INTEGER; +ALTER TABLE channel_outbound_dispatch ADD COLUMN max_attempts INTEGER NOT NULL DEFAULT 5; +CREATE INDEX IF NOT EXISTS idx_channel_dispatch_retry + ON channel_outbound_dispatch(workspace_id, status, next_attempt_at) + WHERE status = 'pending'; + +-- Per-customer per-channel preference. One row per (workspace, customer, +-- channel_kind). status='disabled' is a hard block; 'enabled' is the default. +-- quiet_hours_* are minute-of-day in the customer's timezone; the dispatcher +-- skips delivery during the window and rolls cascade plans forward. +CREATE TABLE IF NOT EXISTS customer_channel_preference ( + workspace_id TEXT NOT NULL, + customer_id TEXT NOT NULL, + channel_kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'enabled', + quiet_hours_start_minutes INTEGER, + quiet_hours_end_minutes INTEGER, + timezone TEXT, + consent_source TEXT, + consent_at INTEGER, + updated_at INTEGER NOT NULL, + PRIMARY KEY (workspace_id, customer_id, channel_kind), + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + FOREIGN KEY (customer_id) REFERENCES customer(id) ON DELETE CASCADE +); + +-- Notification templates. Operator-defined message templates that the +-- cascade engine fills with payload and sends. Channel-specific bodies +-- live in bodies_json keyed by channel_kind (so the same template renders +-- short for SMS and long for email). +CREATE TABLE IF NOT EXISTS notification_template ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + slug TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + default_channels_json TEXT NOT NULL DEFAULT '[]', + bodies_json TEXT NOT NULL DEFAULT '{}', + metadata_json TEXT NOT NULL DEFAULT '{}', + archived_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + UNIQUE (workspace_id, slug) +); + +-- A cascade plan — one per "deliver this notification to that customer". +-- The plan owns the global status; the individual channel attempts live in +-- notification_step rows. +CREATE TABLE IF NOT EXISTS notification_plan ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + customer_id TEXT NOT NULL, + ticket_id TEXT, + template_id TEXT, + template_slug TEXT, + urgency TEXT NOT NULL DEFAULT 'normal', + status TEXT NOT NULL DEFAULT 'pending', + payload_json TEXT NOT NULL DEFAULT '{}', + acknowledged_at INTEGER, + completed_at INTEGER, + cancelled_reason TEXT, + created_by_user_id TEXT, + source TEXT NOT NULL DEFAULT 'api', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + FOREIGN KEY (customer_id) REFERENCES customer(id) ON DELETE CASCADE, + FOREIGN KEY (ticket_id) REFERENCES ticket(id) ON DELETE SET NULL, + FOREIGN KEY (template_id) REFERENCES notification_template(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_notification_plan_status + ON notification_plan(workspace_id, status, updated_at) + WHERE status IN ('pending','active'); + +-- Each cascade step is a single attempt over a single channel. The cascade +-- engine wakes the next pending step when: +-- - schedule_at <= now, AND +-- - the previous step (if any) reached its trigger_on condition. +-- trigger_on: 'immediate' | 'previous_failed' | 'previous_unread' | +-- 'previous_no_ack' | 'time_elapsed'. +CREATE TABLE IF NOT EXISTS notification_step ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + plan_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + channel_kind TEXT NOT NULL, + channel_id TEXT, + trigger_on TEXT NOT NULL DEFAULT 'immediate', + delay_ms INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + scheduled_at INTEGER, + attempted_at INTEGER, + delivered_at INTEGER, + read_at INTEGER, + acknowledged_at INTEGER, + external_id TEXT, + last_error TEXT, + body_text TEXT, + body_html TEXT, + body_json TEXT, + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + FOREIGN KEY (plan_id) REFERENCES notification_plan(id) ON DELETE CASCADE, + UNIQUE (plan_id, sequence) +); +CREATE INDEX IF NOT EXISTS idx_notification_step_due + ON notification_step(workspace_id, status, scheduled_at) + WHERE status = 'pending'; + +-- Per-step delivery telemetry — sent, delivered, read, clicked, failed. +-- Receipts from providers (Twilio status callbacks, WhatsApp read receipts, +-- email click-tracking) land here; the cascade engine reads them to decide +-- whether to advance to the next step. +CREATE TABLE IF NOT EXISTS notification_delivery_event ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + step_id TEXT NOT NULL, + kind TEXT NOT NULL, + occurred_at INTEGER NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + FOREIGN KEY (step_id) REFERENCES notification_step(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_notification_delivery_step + ON notification_delivery_event(step_id, occurred_at DESC); diff --git a/src/agents/specialists/sla.ts b/src/agents/specialists/sla.ts index b4d061b..01a289e 100644 --- a/src/agents/specialists/sla.ts +++ b/src/agents/specialists/sla.ts @@ -46,18 +46,31 @@ export async function findBreachingTickets( ): Promise> { const rows = await env.DB.prepare( `SELECT t.id, t.subject, t.priority, t.created_at, t.last_message_at, t.status, + c.sla_first_response_minutes AS channel_fr_minutes, + c.sla_resolution_minutes AS channel_res_minutes, (SELECT MIN(sent_at) FROM message_index WHERE ticket_id = t.id AND direction = 'outbound') AS first_resp, (SELECT MAX(created_at) FROM audit_event WHERE ticket_id = t.id AND action = 'ticket.resolved') AS resolved FROM ticket t + LEFT JOIN public_channel c ON c.id = t.origin_channel_id AND c.workspace_id = t.workspace_id WHERE t.workspace_id = ? AND t.status IN ('open','pending')`, ) .bind(workspaceId) - .all<{ id: string; subject: string; priority: any; created_at: number; first_resp: number | null; resolved: number | null }>(); + .all<{ + id: string; + subject: string; + priority: any; + created_at: number; + first_resp: number | null; + resolved: number | null; + channel_fr_minutes: number | null; + channel_res_minutes: number | null; + }>(); const out: Array<{ id: string; subject: string; priority: string; breach: SLAStatus }> = []; for (const r of rows.results ?? []) { + const effectivePolicy = applyChannelOverrides(policy, r.channel_fr_minutes, r.channel_res_minutes); const breach = computeSLA({ - policy, + policy: effectivePolicy, priority: r.priority, firstMessageAt: r.created_at, firstResponseAt: r.first_resp ?? undefined, @@ -69,3 +82,29 @@ export async function findBreachingTickets( } return out; } + +function applyChannelOverrides( + policy: SLAPolicy, + channelFirstResponseMinutes: number | null, + channelResolutionMinutes: number | null, +): SLAPolicy { + if (channelFirstResponseMinutes === null && channelResolutionMinutes === null) return policy; + // A channel-specific SLA overrides the priority curve uniformly. This is + // intentional: a customer reaching out over SMS expects the same response + // regardless of how the operator triaged the priority. + const fr = channelFirstResponseMinutes; + const res = channelResolutionMinutes; + return { + first_response_minutes: { + normal: fr ?? policy.first_response_minutes.normal, + high: fr ?? policy.first_response_minutes.high, + urgent: fr ?? policy.first_response_minutes.urgent, + }, + resolution_hours: { + normal: res ? Math.max(1, Math.round(res / 60)) : policy.resolution_hours.normal, + high: res ? Math.max(1, Math.round(res / 60)) : policy.resolution_hours.high, + urgent: res ? Math.max(1, Math.round(res / 60)) : policy.resolution_hours.urgent, + }, + business_hours_only: policy.business_hours_only, + }; +} diff --git a/src/agents/supervisor/replies.ts b/src/agents/supervisor/replies.ts index ff561bf..9ef7e87 100644 --- a/src/agents/supervisor/replies.ts +++ b/src/agents/supervisor/replies.ts @@ -1,4 +1,5 @@ import type { Env } from '../../env'; +import { dispatchOutbound } from '../../channels'; import { buildHtmlWithSignature, buildMultipartReply, @@ -21,11 +22,19 @@ export function makeSendThreadedReply( if (!ctx) throw new Error('ticket_not_found'); const agent = args.actorUserId ? await loadAgent(env, args.actorUserId) : null; + const messageId = ids.message(); + + if (ctx.origin_channel_kind && ctx.origin_channel_kind !== 'email') { + const text = await persistAndDispatchNonEmail(env, workspaceId, args, ctx, agent, messageId); + await refreshCounts(); + void text; + return { messageId }; + } + const lastInbound = await loadLastInbound(env, workspaceId, args.ticketId); const references = await loadReferences(env, workspaceId, args.ticketId); const addresses = await buildReplyAddresses(ctx, args.ticketId, agent); const subject = (args.subject ?? `Re: ${ctx.ticket_subject}`).replace(/^(re:\s*)+/i, 'Re: '); - const messageId = ids.message(); const rfcMessageId = `${messageId}@${addresses.sendingDomain}`; const feedbackLinks = await buildFeedbackLinks(env, { workspaceId, @@ -64,9 +73,40 @@ export function makeSendThreadedReply( }; } +async function persistAndDispatchNonEmail( + env: Env, + workspaceId: string, + args: Parameters[0], + ctx: NonNullable>>, + agent: Awaited>, + messageId: string, +): Promise { + const subject = args.subject ?? ctx.ticket_subject; + const fromName = agent?.name ?? ctx.workspace_name ?? 'Support'; + await persistOutboundReply(env, workspaceId, args, { + messageId, + rfcMessageId: `${ctx.origin_channel_kind}:${messageId}`, + fromAddress: `${ctx.origin_channel_kind}:${ctx.mailbox_address}`, + toAddress: ctx.requester_email, + subject, + inReplyTo: null, + }); + const dispatch = await dispatchOutbound(env, { + workspaceId, + ticketId: args.ticketId, + messageId, + text: args.body, + fromName, + }); + if (dispatch.status === 'failed') { + throw new Error(`channel_dispatch_failed:${dispatch.channelKind}:${dispatch.error ?? 'unknown'}`); + } +} + async function loadReplyContext(env: Env, workspaceId: string, ticketId: string) { return env.DB.prepare( `SELECT t.subject AS ticket_subject, t.requester_email, t.mailbox_id, + t.origin_channel_kind, t.origin_channel_id, m.address AS mailbox_address, m.reply_signing_secret, w.name AS workspace_name, w.settings_json AS workspace_settings FROM ticket t @@ -79,6 +119,8 @@ async function loadReplyContext(env: Env, workspaceId: string, ticketId: string) ticket_subject: string; requester_email: string; mailbox_id: string; + origin_channel_kind: string | null; + origin_channel_id: string | null; mailbox_address: string; reply_signing_secret: string; workspace_name: string; diff --git a/src/api/channels.ts b/src/api/channels.ts index 3e35119..0405c20 100644 --- a/src/api/channels.ts +++ b/src/api/channels.ts @@ -2,16 +2,40 @@ import type { Hono } from 'hono'; import { z } from 'zod'; import { createPublicChannel, listPublicChannels, updatePublicChannel } from '../channels'; import { apiError } from '../lib/errors'; -import { OWNER_OR_ADMIN, requireWorkspaceRole, type Ctx } from './context'; +import { PUBLIC_CHANNEL_KINDS } from '../types/channels'; +import { type Ctx, OWNER_OR_ADMIN, requireWorkspaceRole } from './context'; + +// All Phase 9 channels live behind one CRUD: the per-adapter config goes +// into `config` and is opaque to this route — the adapter validates it. + +const kindSchema = z.enum(PUBLIC_CHANNEL_KINDS as unknown as [string, ...string[]]); +const prioritySchema = z.enum(['low', 'normal', 'high', 'urgent']); const channelBody = z.object({ - kind: z.enum(['chat', 'form']), + kind: kindSchema, mailbox_id: z.string().min(1), name: z.string().min(1).max(80), enabled: z.boolean().optional(), require_email: z.boolean().optional(), allowed_origins: z.array(z.string().max(300)).max(20).optional(), welcome_message: z.string().max(240).nullable().optional(), + config: z.record(z.unknown()).optional(), + sla_first_response_minutes: z + .number() + .int() + .min(1) + .max(60 * 24 * 30) + .nullable() + .optional(), + sla_resolution_minutes: z + .number() + .int() + .min(1) + .max(60 * 24 * 30) + .nullable() + .optional(), + default_priority: prioritySchema.nullable().optional(), + default_assignee_user_id: z.string().min(1).max(120).nullable().optional(), }); const channelPatch = channelBody.omit({ kind: true, mailbox_id: true }).partial(); @@ -27,34 +51,63 @@ export function registerChannelRoutes(apiApp: Hono) { const body = channelBody.parse(await c.req.json()); try { const channel = await createPublicChannel(c.env, s.workspaceId, s.userId, { - kind: body.kind, + kind: body.kind as never, mailboxId: body.mailbox_id, name: body.name, enabled: body.enabled, requireEmail: body.require_email, allowedOrigins: body.allowed_origins, welcomeMessage: body.welcome_message, + config: body.config, + slaFirstResponseMinutes: body.sla_first_response_minutes, + slaResolutionMinutes: body.sla_resolution_minutes, + defaultPriority: body.default_priority, + defaultAssigneeUserId: body.default_assignee_user_id, }); return c.json({ channel }); } catch (err) { - if (err instanceof Error && err.message === 'mailbox_not_found') { - return apiError(c, 'not_found', 'Mailbox not found.'); - } - throw err; + return mapChannelError(c, err); } }); apiApp.patch('/channels/public/:id', requireWorkspaceRole(OWNER_OR_ADMIN), async (c) => { const s = c.get('session'); const body = channelPatch.parse(await c.req.json()); - const channel = await updatePublicChannel(c.env, s.workspaceId, s.userId, c.req.param('id'), { - name: body.name, - enabled: body.enabled, - requireEmail: body.require_email, - allowedOrigins: body.allowed_origins, - welcomeMessage: body.welcome_message, - }); - if (!channel) return apiError(c, 'not_found', 'Channel not found.'); - return c.json({ channel }); + try { + const channel = await updatePublicChannel(c.env, s.workspaceId, s.userId, c.req.param('id'), { + name: body.name, + enabled: body.enabled, + requireEmail: body.require_email, + allowedOrigins: body.allowed_origins, + welcomeMessage: body.welcome_message, + config: body.config, + slaFirstResponseMinutes: body.sla_first_response_minutes, + slaResolutionMinutes: body.sla_resolution_minutes, + defaultPriority: body.default_priority, + defaultAssigneeUserId: body.default_assignee_user_id, + }); + if (!channel) return apiError(c, 'not_found', 'Channel not found.'); + return c.json({ channel }); + } catch (err) { + return mapChannelError(c, err); + } }); } + +function mapChannelError(c: any, err: unknown): Response { + if (err instanceof Error) { + if (err.message === 'mailbox_not_found') { + return apiError(c, 'not_found', 'Mailbox not found.'); + } + if (err.message === 'channel_adapter_not_found' || err.message === 'unknown_channel_kind') { + return apiError(c, 'validation_error', 'Unknown channel kind.', 400); + } + if (err.message.startsWith('config_invalid:')) { + return apiError(c, 'validation_error', err.message.replace('config_invalid:', ''), 400); + } + if (err.message.startsWith('activation_failed:')) { + return apiError(c, 'validation_error', err.message, 400); + } + } + throw err; +} diff --git a/src/channels/adapters/apple-business.ts b/src/channels/adapters/apple-business.ts new file mode 100644 index 0000000..e82a738 --- /dev/null +++ b/src/channels/adapters/apple-business.ts @@ -0,0 +1,140 @@ +import { hmacSign, hmacVerify } from '../../lib/crypto'; +import type { ChannelAdapter, IngressMessage } from '../../types/channels'; +import { parseChannelConfigAsync } from '../utils'; + +// Apple Messages for Business (the brand previously called Apple Business +// Chat). Once a business is approved in Apple Business Register, Apple's +// gateway POSTs inbound messages signed with `Authorization: ApplePay +// keyId=…, signature=…` and a JWS-style separator. For deployments that +// don't run a full JWS validator we accept the operator-set `webhook_secret` +// echoed as `X-Apple-Webhook-Secret` — Apple proxies the request through +// the operator's MSP, which can pin that header in their config. A future +// enhancement can replace this with full JWS verification using the +// public-key bundle Apple publishes. +// +// Outbound: POST https://mspgw.push.apple.com/v1/message with a JWT-signed +// bearer header. We let the operator paste a pre-minted bearer that they +// rotate out-of-band; sign-on-demand JWTs are a future enhancement. + +interface AppleBusinessConfig { + business_id: string; + msp_id: string; + source_id: string; + webhook_secret: string; + bearer_token: string; + [k: string]: unknown; +} + +const APPLE_MSP_GATEWAY = 'https://mspgw.push.apple.com/v1/message'; + +export const appleBusinessAdapter: ChannelAdapter = { + kind: 'apple_business', + capabilities: { + supportsInbound: true, + supportsOutbound: true, + supportsAttachments: true, + supportsRichText: true, + supportsButtons: true, + supportsOtpDelivery: false, + supportsPresence: true, + supportsVoice: false, + supportsStreaming: false, + maxMessageLength: 10_000, + maxAttachmentBytes: 100 * 1024 * 1024, + }, + secretFields: ['webhook_secret', 'bearer_token'], + + validateConfig(input) { + const cfg = input as Partial; + if (!cfg.business_id) throw new Error('config_invalid:business_id_required'); + if (!cfg.msp_id) throw new Error('config_invalid:msp_id_required'); + if (!cfg.source_id) throw new Error('config_invalid:source_id_required'); + if (!cfg.webhook_secret || cfg.webhook_secret.length < 16) { + throw new Error('config_invalid:webhook_secret_required'); + } + if (!cfg.bearer_token || cfg.bearer_token.length < 16) { + throw new Error('config_invalid:bearer_token_required'); + } + return { + business_id: cfg.business_id, + msp_id: cfg.msp_id, + source_id: cfg.source_id, + webhook_secret: cfg.webhook_secret, + bearer_token: cfg.bearer_token, + }; + }, + + async verifyWebhook(env, channel, headers, rawBody) { + const cfg = await parseChannelConfigAsync(env, channel); + const provided = headers['x-apple-webhook-secret'] ?? headers['x-ranse-apple-secret']; + if (!provided) return { ok: false, reason: 'missing_apple_secret_header' }; + // The header can be either the raw secret (when set by the MSP) or an + // HMAC of the body (when set by Ranse-compatible proxies). Accept both + // to make integration robust against the wide variety of MSP setups. + if (provided === cfg.webhook_secret) return { ok: true }; + const expected = await hmacSign(cfg.webhook_secret, rawBody); + return hmacVerify(expected, provided) + ? { ok: true } + : { ok: false, reason: 'apple_secret_mismatch' }; + }, + + async parseIngress(_env, _channel, _headers, rawBody) { + const event = JSON.parse(rawBody) as AppleBusinessEvent; + const body = event.body ?? event.message; + if (!body || event.type !== 'text') return null; + const text = body.body ?? body.text; + if (!text) return null; + const opaqueUserId = event.sourceId ?? event.id ?? ''; + if (!opaqueUserId) return null; + return { + externalId: event.id ?? `apple_business:${Date.now()}`, + externalThreadId: opaqueUserId, + text, + from: { + externalId: opaqueUserId, + displayName: null, + }, + subject: event.intent ?? null, + receivedAt: event.locale ? Date.now() : Date.now(), + } satisfies IngressMessage; + }, + + async egress(env, channel, message) { + const cfg = await parseChannelConfigAsync(env, channel); + const opaqueUserId = message.externalThreadId; + if (!opaqueUserId) throw new Error('apple_business_no_recipient_for_egress'); + const res = await fetch(APPLE_MSP_GATEWAY, { + method: 'POST', + headers: { + authorization: `Bearer ${cfg.bearer_token}`, + 'content-type': 'application/json', + 'source-id': cfg.source_id, + 'destination-id': opaqueUserId, + id: message.messageId, + }, + body: JSON.stringify({ + v: '1', + type: 'text', + sourceId: cfg.source_id, + destinationId: opaqueUserId, + body: message.text.slice(0, 10_000), + }), + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + throw new Error(`apple_business_send_failed:${res.status}:${errBody.slice(0, 200)}`); + } + return { externalId: message.messageId, externalThreadId: opaqueUserId }; + }, +}; + +interface AppleBusinessEvent { + type?: 'text' | string; + id?: string; + sourceId?: string; + destinationId?: string; + intent?: string; + locale?: string; + body?: { body?: string; text?: string }; + message?: { body?: string; text?: string }; +} diff --git a/src/channels/adapters/chat.ts b/src/channels/adapters/chat.ts new file mode 100644 index 0000000..8a4d191 --- /dev/null +++ b/src/channels/adapters/chat.ts @@ -0,0 +1,16 @@ +import type { ChannelAdapter } from '../../types/channels'; +import { CHAT_CAPS } from '../capabilities'; + +// The embedded chat widget is a first-party surface — the customer's browser +// talks to /public/* directly, the operator's reply is read by the same +// browser via the timeline polling endpoint. No external webhooks, no +// egress dispatch needed. + +export const chatAdapter: ChannelAdapter = { + kind: 'chat', + capabilities: CHAT_CAPS, + validateConfig: () => ({}), + verifyWebhook: async () => ({ ok: false, reason: 'chat_has_no_external_webhook' }), + parseIngress: async () => null, + egress: async () => ({ externalId: null, externalThreadId: null }), +}; diff --git a/src/channels/adapters/discord.ts b/src/channels/adapters/discord.ts new file mode 100644 index 0000000..e631cfc --- /dev/null +++ b/src/channels/adapters/discord.ts @@ -0,0 +1,166 @@ +import type { ChannelAdapter, IngressMessage } from '../../types/channels'; +import { DISCORD_CAPS } from '../capabilities'; +import { parseChannelConfigAsync } from '../utils'; + +// Discord adapter using the Interactions Endpoint (HTTPS receiver, not +// gateway). Signature verification is Ed25519 over (timestamp || body) using +// the application's public key. The operator pastes the public key + bot +// token in channel config and points Discord's Interactions URL at +// /public/channels/:public_key/webhook. + +interface DiscordConfig { + application_id: string; + public_key: string; // hex-encoded Ed25519 public key + bot_token: string; + // The bot is added to one guild that hosts the support intake channel; + // ingress treats any DM or guild message addressed to the bot as a ticket. + guild_id?: string | null; + [k: string]: unknown; +} + +const DISCORD_API_BASE = 'https://discord.com/api/v10'; + +export const discordAdapter: ChannelAdapter = { + kind: 'discord', + capabilities: DISCORD_CAPS, + secretFields: ['bot_token'], + + validateConfig(input) { + const cfg = input as Partial; + if (!cfg.application_id || !/^\d{15,}$/.test(cfg.application_id)) { + throw new Error('config_invalid:application_id_required'); + } + if (!cfg.public_key || !/^[0-9a-f]{64}$/i.test(cfg.public_key)) { + throw new Error('config_invalid:public_key_required'); + } + if (!cfg.bot_token || cfg.bot_token.length < 32) { + throw new Error('config_invalid:bot_token_required'); + } + return { + application_id: cfg.application_id, + public_key: cfg.public_key.toLowerCase(), + bot_token: cfg.bot_token, + guild_id: cfg.guild_id ?? null, + }; + }, + + async verifyWebhook(env, channel, headers, rawBody) { + const cfg = await parseChannelConfigAsync(env, channel); + const signature = headers['x-signature-ed25519']; + const timestamp = headers['x-signature-timestamp']; + if (!signature || !timestamp) return { ok: false, reason: 'missing_discord_headers' }; + try { + const ok = await verifyEd25519( + hexToBytes(cfg.public_key), + hexToBytes(signature), + new TextEncoder().encode(timestamp + rawBody), + ); + return ok ? { ok: true } : { ok: false, reason: 'signature_mismatch' }; + } catch { + return { ok: false, reason: 'signature_verify_failed' }; + } + }, + + async handleChallenge(_env, _channel, request) { + // Discord sends Interactions PING (type 1) on URL save. Respond PONG. + const body = await request + .clone() + .json() + .catch(() => null); + if (body && (body as any).type === 1) { + return new Response(JSON.stringify({ type: 1 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return null; + }, + + async parseIngress(_env, _channel, _headers, rawBody) { + const event = JSON.parse(rawBody) as DiscordPayload; + // Application Command (slash) or Message Component + if (event.type !== 2 && event.type !== 5) return null; + const userId = event.member?.user?.id ?? event.user?.id ?? `discord:${Date.now().toString(36)}`; + const channelId = event.channel_id ?? ''; + const text = readSlashText(event) ?? ''; + if (!text.trim()) return null; + return { + externalId: event.id ?? `discord:${Date.now()}`, + externalThreadId: channelId, + text, + from: { + externalId: userId, + displayName: + event.member?.user?.global_name ?? + event.member?.user?.username ?? + event.user?.global_name ?? + event.user?.username ?? + null, + }, + subject: null, + receivedAt: Date.now(), + } satisfies IngressMessage; + }, + + async egress(env, channel, message) { + const cfg = await parseChannelConfigAsync(env, channel); + const channelId = message.externalThreadId; + if (!channelId) throw new Error('discord_no_channel_for_egress'); + const res = await fetch(`${DISCORD_API_BASE}/channels/${channelId}/messages`, { + method: 'POST', + headers: { + authorization: `Bot ${cfg.bot_token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + content: message.text.slice(0, DISCORD_CAPS.maxMessageLength), + }), + }); + if (!res.ok) { + throw new Error(`discord_send_failed:${res.status}`); + } + const data = (await res.json().catch(() => ({}))) as { id?: string }; + return { externalId: data.id ?? null, externalThreadId: channelId }; + }, +}; + +interface DiscordPayload { + id?: string; + type: number; + channel_id?: string; + member?: { user?: { id: string; username?: string; global_name?: string } }; + user?: { id: string; username?: string; global_name?: string }; + data?: { name?: string; options?: { name: string; value: unknown }[] }; +} + +function readSlashText(event: DiscordPayload): string | null { + const opts = event.data?.options ?? []; + const messageOpt = opts.find((o) => o.name === 'message' || o.name === 'text'); + if (messageOpt && typeof messageOpt.value === 'string') return messageOpt.value; + return null; +} + +function hexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + return out; +} + +async function verifyEd25519( + publicKey: Uint8Array, + signature: Uint8Array, + message: Uint8Array, +): Promise { + const key = await crypto.subtle.importKey('raw', toAb(publicKey), { name: 'Ed25519' }, false, [ + 'verify', + ]); + return crypto.subtle.verify('Ed25519', key, toAb(signature), toAb(message)); +} + +function toAb(view: Uint8Array): ArrayBuffer { + // crypto.subtle types require strict ArrayBuffer (not SharedArrayBuffer); + // copy into a fresh buffer so the call type-checks across runtimes. + const out = new ArrayBuffer(view.byteLength); + new Uint8Array(out).set(view); + return out; +} diff --git a/src/channels/adapters/email.ts b/src/channels/adapters/email.ts new file mode 100644 index 0000000..a9b3e43 --- /dev/null +++ b/src/channels/adapters/email.ts @@ -0,0 +1,24 @@ +import type { ChannelAdapter } from '../../types/channels'; +import { EMAIL_CAPS } from '../capabilities'; + +// Email is the wedge — it predates the adapter abstraction and lives in +// `src/email/`. The adapter here exists so the registry can answer +// `getAdapter('email')` consistently (capabilities, validateConfig). The +// outbound dispatcher short-circuits 'email' to the legacy reply pipeline, +// because email outbound needs MIME threading, signed reply addresses, and +// the dedicated DKIM-signing subdomain that the rest of `src/email/` +// already owns. + +export const emailAdapter: ChannelAdapter = { + kind: 'email', + capabilities: EMAIL_CAPS, + validateConfig: () => ({}), + verifyWebhook: async () => ({ ok: false, reason: 'email_uses_cloudflare_routing' }), + parseIngress: async () => null, + egress: async () => { + // Outbound email is owned by `src/agents/supervisor/replies.ts`; the + // dispatcher never reaches this adapter for 'email' (it short-circuits). + // Keep this body as a guard so misuse fails loud. + throw new Error('email_egress_handled_by_reply_pipeline'); + }, +}; diff --git a/src/channels/adapters/form.ts b/src/channels/adapters/form.ts new file mode 100644 index 0000000..56e420b --- /dev/null +++ b/src/channels/adapters/form.ts @@ -0,0 +1,18 @@ +import type { ChannelAdapter } from '../../types/channels'; +import { FORM_CAPS } from '../capabilities'; + +// Hosted forms are write-only: a customer submits the form, we create a +// ticket, and replies go out via email (because the form collected one). +// The adapter exists for capability lookup; the outbound dispatcher would +// see supportsOutbound=false and the reply pipeline falls through to email. + +export const formAdapter: ChannelAdapter = { + kind: 'form', + capabilities: FORM_CAPS, + validateConfig: () => ({}), + verifyWebhook: async () => ({ ok: false, reason: 'form_has_no_external_webhook' }), + parseIngress: async () => null, + egress: async () => { + throw new Error('form_egress_routes_through_email'); + }, +}; diff --git a/src/channels/adapters/index.ts b/src/channels/adapters/index.ts new file mode 100644 index 0000000..1e1e162 --- /dev/null +++ b/src/channels/adapters/index.ts @@ -0,0 +1,47 @@ +import { registerAdapter, tryGetAdapter } from '../registry'; +import { ensureVoiceProvidersRegistered, voiceAdapter } from '../voice'; +import { appleBusinessAdapter } from './apple-business'; +import { chatAdapter } from './chat'; +import { discordAdapter } from './discord'; +import { emailAdapter } from './email'; +import { formAdapter } from './form'; +import { instagramAdapter } from './instagram'; +import { messengerAdapter } from './messenger'; +import { rcsAdapter } from './rcs'; +import { slackAdapter } from './slack'; +import { smsAdapter } from './sms'; +import { teamsAdapter } from './teams'; +import { telegramAdapter } from './telegram'; +import { webhookAdapter } from './webhook'; +import { whatsappAdapter } from './whatsapp'; + +// One-shot guarded registration. Module imports happen at load time, but +// during tests the same process may import this module multiple times; the +// guard makes registration idempotent. +let registered = false; + +export function ensureBuiltInAdaptersRegistered(): void { + if (registered) return; + registered = true; + ensureVoiceProvidersRegistered(); + for (const adapter of [ + emailAdapter, + chatAdapter, + formAdapter, + slackAdapter, + smsAdapter, + discordAdapter, + telegramAdapter, + whatsappAdapter, + voiceAdapter, + webhookAdapter, + teamsAdapter, + messengerAdapter, + instagramAdapter, + rcsAdapter, + appleBusinessAdapter, + ]) { + if (tryGetAdapter(adapter.kind)) continue; + registerAdapter(adapter); + } +} diff --git a/src/channels/adapters/instagram.ts b/src/channels/adapters/instagram.ts new file mode 100644 index 0000000..0fbb505 --- /dev/null +++ b/src/channels/adapters/instagram.ts @@ -0,0 +1,123 @@ +import type { ChannelAdapter, IngressMessage } from '../../types/channels'; +import { parseChannelConfigAsync } from '../utils'; +import { + handleMetaChallenge, + type MetaSharedConfig, + validateMetaConfig, + verifyMetaWebhook, +} from './meta-shared'; + +// Instagram DM via Meta Graph (Instagram Messaging API). The webhook shape +// mirrors Messenger; the differences: +// - the `object` field in the envelope is 'instagram' +// - the IDs in `sender.id` are Instagram-Scoped IDs, not Page-Scoped IDs +// - outbound goes to the IG-connected Facebook Page, identified by +// ig_id resolved at config time +// +// The operator links an Instagram Business Account to a Facebook Page and +// gives us the IG Business Account id; we use that as `ig_id`. + +interface InstagramConfig extends MetaSharedConfig { + ig_id: string; +} + +const META_GRAPH = 'https://graph.facebook.com'; + +export const instagramAdapter: ChannelAdapter = { + kind: 'instagram', + capabilities: { + supportsInbound: true, + supportsOutbound: true, + supportsAttachments: true, + supportsRichText: false, + supportsButtons: true, + supportsOtpDelivery: false, + supportsPresence: true, + supportsVoice: false, + supportsStreaming: false, + maxMessageLength: 1_000, + maxAttachmentBytes: 25 * 1024 * 1024, + }, + secretFields: ['app_secret', 'access_token', 'verify_token'], + + validateConfig(input) { + const cfg = input as Partial; + if (!cfg.ig_id || !/^\d{6,}$/.test(cfg.ig_id)) { + throw new Error('config_invalid:ig_id_required'); + } + return { + ...validateMetaConfig(input as Record), + ig_id: cfg.ig_id, + }; + }, + + async verifyWebhook(env, channel, headers, rawBody) { + return verifyMetaWebhook(env, channel, headers, rawBody); + }, + + async handleChallenge(env, channel, request) { + return handleMetaChallenge(env, channel, request); + }, + + async parseIngress(env, channel, _headers, rawBody) { + const cfg = await parseChannelConfigAsync(env, channel); + const payload = JSON.parse(rawBody) as InstagramWebhook; + if (payload.object !== 'instagram') return null; + for (const entry of payload.entry ?? []) { + if (entry.id !== cfg.ig_id) continue; + for (const event of entry.messaging ?? []) { + if (!event.message || event.message.is_echo) continue; + const text = event.message.text; + if (!text) continue; + return { + externalId: event.message.mid ?? `instagram:${Date.now()}`, + externalThreadId: event.sender?.id ?? '', + text, + from: { + externalId: event.sender?.id ?? '', + displayName: null, + }, + subject: null, + receivedAt: event.timestamp ?? Date.now(), + } satisfies IngressMessage; + } + } + return null; + }, + + async egress(env, channel, message) { + const cfg = await parseChannelConfigAsync(env, channel); + const recipientId = message.externalThreadId; + if (!recipientId) throw new Error('instagram_no_recipient_for_egress'); + const url = `${META_GRAPH}/${cfg.graph_version}/${cfg.ig_id}/messages?access_token=${encodeURIComponent(cfg.access_token)}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + recipient: { id: recipientId }, + message: { text: message.text.slice(0, 1_000) }, + }), + }); + const data = (await res.json().catch(() => ({}))) as { + message_id?: string; + error?: { message?: string }; + }; + if (!res.ok || !data.message_id) { + throw new Error(`instagram_send_failed:${data.error?.message ?? res.status}`); + } + return { externalId: data.message_id, externalThreadId: recipientId }; + }, +}; + +interface InstagramWebhook { + object?: string; + entry?: { + id: string; + messaging?: { + sender?: { id: string }; + recipient?: { id: string }; + timestamp?: number; + message?: { mid?: string; text?: string; is_echo?: boolean }; + }[]; + }[]; +} diff --git a/src/channels/adapters/messenger.ts b/src/channels/adapters/messenger.ts new file mode 100644 index 0000000..d01f3b2 --- /dev/null +++ b/src/channels/adapters/messenger.ts @@ -0,0 +1,117 @@ +import type { ChannelAdapter, IngressMessage } from '../../types/channels'; +import { parseChannelConfigAsync } from '../utils'; +import { + handleMetaChallenge, + type MetaSharedConfig, + validateMetaConfig, + verifyMetaWebhook, +} from './meta-shared'; + +// Facebook Messenger Platform via Meta Graph. Per-Page access token + the +// app-level secret/verify_token. Outbound: POST /me/messages on the Page. + +interface MessengerConfig extends MetaSharedConfig { + page_id: string; +} + +const META_GRAPH = 'https://graph.facebook.com'; + +export const messengerAdapter: ChannelAdapter = { + kind: 'messenger', + capabilities: { + supportsInbound: true, + supportsOutbound: true, + supportsAttachments: true, + supportsRichText: false, + supportsButtons: true, + supportsOtpDelivery: false, + supportsPresence: true, + supportsVoice: false, + supportsStreaming: false, + maxMessageLength: 2_000, + maxAttachmentBytes: 25 * 1024 * 1024, + }, + secretFields: ['app_secret', 'access_token', 'verify_token'], + + validateConfig(input) { + const cfg = input as Partial; + if (!cfg.page_id || !/^\d{6,}$/.test(cfg.page_id)) { + throw new Error('config_invalid:page_id_required'); + } + return { + ...validateMetaConfig(input as Record), + page_id: cfg.page_id, + }; + }, + + async verifyWebhook(env, channel, headers, rawBody) { + return verifyMetaWebhook(env, channel, headers, rawBody); + }, + + async handleChallenge(env, channel, request) { + return handleMetaChallenge(env, channel, request); + }, + + async parseIngress(env, channel, _headers, rawBody) { + const cfg = await parseChannelConfigAsync(env, channel); + const payload = JSON.parse(rawBody) as MessengerWebhook; + if (payload.object !== 'page') return null; + for (const entry of payload.entry ?? []) { + if (entry.id !== cfg.page_id) continue; + for (const event of entry.messaging ?? []) { + if (!event.message || event.message.is_echo) continue; + const text = event.message.text; + if (!text) continue; + return { + externalId: event.message.mid ?? `messenger:${Date.now()}`, + externalThreadId: event.sender?.id ?? '', + text, + from: { + externalId: event.sender?.id ?? '', + displayName: null, + }, + subject: null, + receivedAt: event.timestamp ?? Date.now(), + } satisfies IngressMessage; + } + } + return null; + }, + + async egress(env, channel, message) { + const cfg = await parseChannelConfigAsync(env, channel); + const recipientId = message.externalThreadId; + if (!recipientId) throw new Error('messenger_no_recipient_for_egress'); + const url = `${META_GRAPH}/${cfg.graph_version}/me/messages?access_token=${encodeURIComponent(cfg.access_token)}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + recipient: { id: recipientId }, + messaging_type: 'RESPONSE', + message: { text: message.text.slice(0, 2_000) }, + }), + }); + const data = (await res.json().catch(() => ({}))) as { + message_id?: string; + error?: { message?: string }; + }; + if (!res.ok || !data.message_id) { + throw new Error(`messenger_send_failed:${data.error?.message ?? res.status}`); + } + return { externalId: data.message_id, externalThreadId: recipientId }; + }, +}; + +interface MessengerWebhook { + object?: string; + entry?: { + id: string; + messaging?: { + sender?: { id: string }; + recipient?: { id: string }; + timestamp?: number; + message?: { mid?: string; text?: string; is_echo?: boolean }; + }[]; + }[]; +} diff --git a/src/channels/adapters/meta-shared.ts b/src/channels/adapters/meta-shared.ts new file mode 100644 index 0000000..68b7cff --- /dev/null +++ b/src/channels/adapters/meta-shared.ts @@ -0,0 +1,68 @@ +import type { Env } from '../../env'; +import { hmacSign, hmacVerify } from '../../lib/crypto'; +import type { PublicChannel } from '../../types/channels'; +import { parseChannelConfigAsync } from '../utils'; + +// Shared building blocks for Meta Graph webhooks (WhatsApp, Messenger, +// Instagram). All three speak the same envelope: +// entry: [{ id, time, changes: [{ field, value }], messaging: [{...}] }] +// with X-Hub-Signature-256 as the only auth and a GET `hub.challenge` +// verification on subscription. The differences come down to which field +// inside `value` / `messaging` carries the inbound text. + +export interface MetaSharedConfig { + app_secret: string; + access_token: string; + verify_token: string; + graph_version?: string; + [k: string]: unknown; +} + +export async function verifyMetaWebhook( + env: Env, + channel: PublicChannel, + headers: Record, + rawBody: string, +): Promise<{ ok: true } | { ok: false; reason: string }> { + const cfg = await parseChannelConfigAsync(env, channel); + if (!cfg.app_secret) return { ok: false, reason: 'app_secret_missing' }; + const sig = headers['x-hub-signature-256']; + if (!sig) return { ok: false, reason: 'missing_x_hub_signature' }; + const expected = `sha256=${await hmacSign(cfg.app_secret, rawBody)}`; + return hmacVerify(expected, sig) ? { ok: true } : { ok: false, reason: 'signature_mismatch' }; +} + +export async function handleMetaChallenge( + env: Env, + channel: PublicChannel, + request: Request, +): Promise { + if (request.method !== 'GET') return null; + const url = new URL(request.url); + const mode = url.searchParams.get('hub.mode'); + const token = url.searchParams.get('hub.verify_token'); + const challenge = url.searchParams.get('hub.challenge'); + if (mode !== 'subscribe' || !challenge) return null; + const cfg = await parseChannelConfigAsync(env, channel); + if (token !== cfg.verify_token) return new Response('Forbidden', { status: 403 }); + return new Response(challenge, { status: 200, headers: { 'content-type': 'text/plain' } }); +} + +export function validateMetaConfig(input: Record): MetaSharedConfig { + const cfg = input as Partial; + if (!cfg.app_secret || cfg.app_secret.length < 16) { + throw new Error('config_invalid:app_secret_required'); + } + if (!cfg.access_token || cfg.access_token.length < 16) { + throw new Error('config_invalid:access_token_required'); + } + if (!cfg.verify_token || cfg.verify_token.length < 8) { + throw new Error('config_invalid:verify_token_required'); + } + return { + app_secret: cfg.app_secret, + access_token: cfg.access_token, + verify_token: cfg.verify_token, + graph_version: cfg.graph_version ?? 'v20.0', + }; +} diff --git a/src/channels/adapters/rcs.ts b/src/channels/adapters/rcs.ts new file mode 100644 index 0000000..50f5da4 --- /dev/null +++ b/src/channels/adapters/rcs.ts @@ -0,0 +1,123 @@ +import { hmacSign, hmacVerify } from '../../lib/crypto'; +import type { ChannelAdapter, IngressMessage } from '../../types/channels'; +import { parseChannelConfigAsync } from '../utils'; + +// Google Business Messages (the operator-facing RCS Business Messaging +// surface). Partners register a brand + agent through the Business +// Communications API; webhooks land at /public/channels//webhook with +// the operator-set `client_token` echoed in the body and signed via HMAC +// using `partner_secret`. +// +// Outbound: POST /v1/conversations/{conversationId}/messages with a bearer +// service-account token. To keep this dependency-free we let the operator +// paste a pre-minted bearer token they refresh out-of-band; a future +// enhancement can sign JWTs server-side. + +interface RcsConfig { + agent_id: string; + partner_secret: string; // signs inbound webhook bodies + oauth_token: string; // bearer for outbound /messages calls + webhook_url: string; + [k: string]: unknown; +} + +const BUSINESS_MESSAGES_API = 'https://businessmessages.googleapis.com'; + +export const rcsAdapter: ChannelAdapter = { + kind: 'rcs', + capabilities: { + supportsInbound: true, + supportsOutbound: true, + supportsAttachments: true, + supportsRichText: true, + supportsButtons: true, + supportsOtpDelivery: true, + supportsPresence: true, + supportsVoice: false, + supportsStreaming: false, + maxMessageLength: 8_000, + maxAttachmentBytes: 100 * 1024 * 1024, + }, + secretFields: ['partner_secret', 'oauth_token'], + + validateConfig(input) { + const cfg = input as Partial; + if (!cfg.agent_id) throw new Error('config_invalid:agent_id_required'); + if (!cfg.partner_secret || cfg.partner_secret.length < 16) { + throw new Error('config_invalid:partner_secret_required'); + } + if (!cfg.oauth_token || cfg.oauth_token.length < 16) { + throw new Error('config_invalid:oauth_token_required'); + } + if (!cfg.webhook_url || !/^https:\/\//.test(cfg.webhook_url)) { + throw new Error('config_invalid:webhook_url_required_https'); + } + return { + agent_id: cfg.agent_id, + partner_secret: cfg.partner_secret, + oauth_token: cfg.oauth_token, + webhook_url: cfg.webhook_url, + }; + }, + + async verifyWebhook(env, channel, headers, rawBody) { + const cfg = await parseChannelConfigAsync(env, channel); + const sig = headers['x-goog-signature']; + if (!sig) return { ok: false, reason: 'missing_goog_signature' }; + const expected = await hmacSign(cfg.partner_secret, rawBody); + return hmacVerify(expected, sig) ? { ok: true } : { ok: false, reason: 'signature_mismatch' }; + }, + + async parseIngress(_env, _channel, _headers, rawBody) { + const event = JSON.parse(rawBody) as RcsEvent; + if (!event.message?.text) return null; + return { + externalId: event.message.messageId ?? `rcs:${Date.now()}`, + externalThreadId: event.conversationId, + text: event.message.text, + from: { + externalId: event.context?.userInfo?.userDeviceLocale + ? `rcs:${event.conversationId}` + : event.conversationId, + displayName: event.context?.userInfo?.displayName ?? null, + }, + subject: null, + receivedAt: event.sendTime ? Date.parse(event.sendTime) : Date.now(), + } satisfies IngressMessage; + }, + + async egress(env, channel, message) { + const cfg = await parseChannelConfigAsync(env, channel); + const conversationId = message.externalThreadId; + if (!conversationId) throw new Error('rcs_no_conversation_for_egress'); + const url = `${BUSINESS_MESSAGES_API}/v1/conversations/${encodeURIComponent(conversationId)}/messages`; + const res = await fetch(url, { + method: 'POST', + headers: { + authorization: `Bearer ${cfg.oauth_token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + messageId: message.messageId, + representative: { representativeType: 'BOT' }, + text: message.text.slice(0, 8_000), + }), + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + throw new Error(`rcs_send_failed:${res.status}:${errBody.slice(0, 200)}`); + } + const data = (await res.json().catch(() => ({}))) as { name?: string }; + return { + externalId: data.name ?? message.messageId, + externalThreadId: conversationId, + }; + }, +}; + +interface RcsEvent { + conversationId: string; + message?: { messageId?: string; text?: string }; + sendTime?: string; + context?: { userInfo?: { displayName?: string; userDeviceLocale?: string } }; +} diff --git a/src/channels/adapters/slack.ts b/src/channels/adapters/slack.ts new file mode 100644 index 0000000..94b8105 --- /dev/null +++ b/src/channels/adapters/slack.ts @@ -0,0 +1,167 @@ +import { hmacSign, hmacVerify } from '../../lib/crypto'; +import type { ChannelAdapter, IngressMessage, PublicChannel } from '../../types/channels'; +import { SLACK_CAPS } from '../capabilities'; +import { parseChannelConfigAsync } from '../utils'; + +// Slack adapter — Events API. Operator installs a classic / granular bot, +// pastes signing secret + bot token + bot user id in the channel config. +// Webhook URL: /public/channels/:public_key/webhook +// +// Inbound: app_mention + message.im events become tickets / replies, with +// thread_ts (or channel id for DMs) used as the external thread so threaded +// replies land in the same ticket. +// +// Outbound: chat.postMessage with thread_ts when present. Bot token is the +// only outbound credential we need. + +interface SlackConfig { + bot_token: string; + signing_secret: string; + bot_user_id?: string | null; + app_id?: string | null; + team_id?: string | null; + [k: string]: unknown; +} + +const TIMESTAMP_TOLERANCE_SECONDS = 5 * 60; +const SLACK_API_BASE = 'https://slack.com/api'; + +export const slackAdapter: ChannelAdapter = { + kind: 'slack', + capabilities: SLACK_CAPS, + secretFields: ['bot_token', 'signing_secret'], + + validateConfig(input) { + const cfg = input as Partial; + if (!cfg.bot_token || !cfg.bot_token.startsWith('xoxb-')) { + throw new Error('config_invalid:bot_token_required'); + } + if (!cfg.signing_secret || cfg.signing_secret.length < 16) { + throw new Error('config_invalid:signing_secret_required'); + } + return { + bot_token: cfg.bot_token, + signing_secret: cfg.signing_secret, + bot_user_id: cfg.bot_user_id ?? null, + app_id: cfg.app_id ?? null, + team_id: cfg.team_id ?? null, + }; + }, + + async verifyWebhook(env, channel, headers, rawBody) { + const cfg = await parseChannelConfigAsync(env, channel); + const timestamp = headers['x-slack-request-timestamp']; + const signature = headers['x-slack-signature']; + if (!timestamp || !signature) return { ok: false, reason: 'missing_slack_headers' }; + const tsNum = Number.parseInt(timestamp, 10); + if (!Number.isFinite(tsNum)) return { ok: false, reason: 'invalid_timestamp' }; + const drift = Math.abs(Math.floor(Date.now() / 1000) - tsNum); + if (drift > TIMESTAMP_TOLERANCE_SECONDS) return { ok: false, reason: 'timestamp_out_of_range' }; + const baseString = `v0:${timestamp}:${rawBody}`; + const expected = `v0=${await hmacSign(cfg.signing_secret, baseString)}`; + return hmacVerify(expected, signature) + ? { ok: true } + : { ok: false, reason: 'signature_mismatch' }; + }, + + async handleChallenge(_env, _channel, request) { + const body = await request + .clone() + .json() + .catch(() => null); + if (body && typeof body === 'object' && (body as any).type === 'url_verification') { + return new Response((body as any).challenge ?? '', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }); + } + return null; + }, + + async parseIngress(env, channel, _headers, rawBody) { + const cfg = await parseChannelConfigAsync(env, channel); + const event = JSON.parse(rawBody) as SlackEnvelope; + if (event.type !== 'event_callback' || !event.event) return null; + const inner = event.event; + if (inner.type !== 'app_mention' && inner.type !== 'message') return null; + if (inner.bot_id || inner.subtype === 'bot_message') return null; + if (cfg.bot_user_id && inner.user === cfg.bot_user_id) return null; + + const text = stripBotMention(inner.text ?? '', cfg.bot_user_id); + if (!text) return null; + const threadKey = inner.thread_ts ?? inner.ts; + const channelId = inner.channel ?? ''; + const externalThreadId = `${channelId}:${threadKey}`; + return { + externalId: inner.ts ?? `${channelId}:${Date.now()}`, + externalThreadId, + text, + from: { + externalId: `${event.team_id ?? cfg.team_id ?? 'team'}:${inner.user ?? 'unknown'}`, + displayName: inner.user ? `slack:${inner.user}` : null, + }, + subject: null, + receivedAt: inner.event_ts + ? Math.floor(Number.parseFloat(inner.event_ts) * 1000) + : Date.now(), + } satisfies IngressMessage; + }, + + async egress(env, channel, message) { + const cfg = await parseChannelConfigAsync(env, channel); + const [channelId, threadTs] = (message.externalThreadId ?? '').split(':'); + if (!channelId) throw new Error('slack_no_channel_for_egress'); + const payload: Record = { + channel: channelId, + text: message.text.slice(0, SLACK_CAPS.maxMessageLength), + }; + if (threadTs) payload.thread_ts = threadTs; + const res = await fetch(`${SLACK_API_BASE}/chat.postMessage`, { + method: 'POST', + headers: { + authorization: `Bearer ${cfg.bot_token}`, + 'content-type': 'application/json; charset=utf-8', + }, + body: JSON.stringify(payload), + }); + const body = (await res.json().catch(() => ({}))) as { + ok?: boolean; + ts?: string; + error?: string; + }; + if (!body.ok) throw new Error(`slack_post_failed:${body.error ?? res.status}`); + return { + externalId: body.ts ?? null, + externalThreadId: threadTs ? `${channelId}:${threadTs}` : `${channelId}:${body.ts ?? ''}`, + }; + }, +}; + +interface SlackEnvelope { + type: string; + team_id?: string; + event?: SlackEvent; +} + +interface SlackEvent { + type: string; + subtype?: string; + bot_id?: string; + user?: string; + text?: string; + channel?: string; + ts?: string; + thread_ts?: string; + event_ts?: string; +} + +function stripBotMention(text: string, botUserId?: string | null): string { + if (!botUserId) return text.trim(); + const mention = new RegExp(`<@${botUserId}>`, 'g'); + return text.replace(mention, '').trim(); +} + +// Also exported for the URL the operator pastes into Slack's "Event +// Subscriptions" → "Request URL" + "Subscribe to bot events". Kept here +// so adding a new provider can colocate its UI hint with the adapter. +export const SLACK_BOT_EVENTS = ['app_mention', 'message.im'] as const; diff --git a/src/channels/adapters/sms.ts b/src/channels/adapters/sms.ts new file mode 100644 index 0000000..fb7b6ba --- /dev/null +++ b/src/channels/adapters/sms.ts @@ -0,0 +1,163 @@ +import type { ChannelAdapter, IngressMessage } from '../../types/channels'; +import { SMS_CAPS } from '../capabilities'; +import { parseChannelConfigAsync } from '../utils'; + +// SMS — modelled on the Twilio Messages API webhook shape. The provider +// field lets us slot in Vonage/Plivo later without changing the message +// pipeline: parsing is the only provider-specific bit. +// +// Inbound: Twilio posts form-encoded — From, To, Body, MessageSid. +// Signature: HMAC-SHA1 over (url || sorted POST params), base64-encoded, +// in X-Twilio-Signature. +// Outbound: POST {Body, From|MessagingServiceSid, To} to /Messages.json. + +interface SmsConfig { + provider: 'twilio'; + account_sid: string; + auth_token: string; + from_number?: string | null; + messaging_service_sid?: string | null; + webhook_url: string; // absolute URL Twilio is configured to post to + [k: string]: unknown; +} + +const TWILIO_API_BASE = 'https://api.twilio.com'; + +export const smsAdapter: ChannelAdapter = { + kind: 'sms', + capabilities: SMS_CAPS, + secretFields: ['auth_token'], + + validateConfig(input) { + const cfg = input as Partial; + if (cfg.provider && cfg.provider !== 'twilio') { + throw new Error('config_invalid:provider_unsupported'); + } + if (!cfg.account_sid || !cfg.account_sid.startsWith('AC')) { + throw new Error('config_invalid:account_sid_required'); + } + if (!cfg.auth_token || cfg.auth_token.length < 16) { + throw new Error('config_invalid:auth_token_required'); + } + if (!cfg.from_number && !cfg.messaging_service_sid) { + throw new Error('config_invalid:from_number_or_messaging_service_required'); + } + if (!cfg.webhook_url || !/^https?:\/\//.test(cfg.webhook_url)) { + throw new Error('config_invalid:webhook_url_required'); + } + return { + provider: 'twilio', + account_sid: cfg.account_sid, + auth_token: cfg.auth_token, + from_number: cfg.from_number ?? null, + messaging_service_sid: cfg.messaging_service_sid ?? null, + webhook_url: cfg.webhook_url, + }; + }, + + async verifyWebhook(env, channel, headers, rawBody) { + const cfg = await parseChannelConfigAsync(env, channel); + const signature = headers['x-twilio-signature']; + if (!signature) return { ok: false, reason: 'missing_twilio_signature' }; + const params = parseFormBody(rawBody); + const sorted = Object.keys(params).sort(); + const concatenated = cfg.webhook_url + sorted.map((k) => `${k}${params[k]}`).join(''); + const expected = await twilioSign(cfg.auth_token, concatenated); + return constantTimeEqual(expected, signature) + ? { ok: true } + : { ok: false, reason: 'signature_mismatch' }; + }, + + async parseIngress(_env, _channel, _headers, rawBody) { + const params = parseFormBody(rawBody); + const body = params['Body'] ?? ''; + if (!body.trim()) return null; + const from = params['From'] ?? ''; + const sid = params['MessageSid'] ?? `sms:${Date.now()}`; + return { + externalId: sid, + externalThreadId: from, + text: body, + from: { + externalId: from, + phone: from || null, + displayName: null, + }, + subject: null, + receivedAt: Date.now(), + } satisfies IngressMessage; + }, + + async egress(env, channel, message) { + const cfg = await parseChannelConfigAsync(env, channel); + const to = message.externalThreadId; + if (!to) throw new Error('sms_no_destination_for_egress'); + const body = new URLSearchParams(); + body.set('To', to); + body.set('Body', message.text.slice(0, SMS_CAPS.maxMessageLength)); + if (cfg.messaging_service_sid) { + body.set('MessagingServiceSid', cfg.messaging_service_sid); + } else if (cfg.from_number) { + body.set('From', cfg.from_number); + } + const url = `${TWILIO_API_BASE}/2010-04-01/Accounts/${cfg.account_sid}/Messages.json`; + const res = await fetch(url, { + method: 'POST', + headers: { + authorization: `Basic ${btoa(`${cfg.account_sid}:${cfg.auth_token}`)}`, + 'content-type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }); + const data = (await res.json().catch(() => ({}))) as { + sid?: string; + code?: number; + message?: string; + }; + if (!res.ok || !data.sid) { + throw new Error(`twilio_send_failed:${data.code ?? res.status}:${data.message ?? 'unknown'}`); + } + return { externalId: data.sid, externalThreadId: to }; + }, +}; + +function parseFormBody(rawBody: string): Record { + const out: Record = {}; + for (const pair of rawBody.split('&')) { + if (!pair) continue; + const idx = pair.indexOf('='); + if (idx === -1) { + out[decodeURIComponent(pair)] = ''; + continue; + } + out[decodeURIComponent(pair.slice(0, idx).replace(/\+/g, ' '))] = decodeURIComponent( + pair.slice(idx + 1).replace(/\+/g, ' '), + ); + } + return out; +} + +async function twilioSign(authToken: string, message: string): Promise { + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(authToken), + { name: 'HMAC', hash: 'SHA-1' }, + false, + ['sign'], + ); + const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message)); + return base64Encode(new Uint8Array(sig)); +} + +function base64Encode(bytes: Uint8Array): string { + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary); +} + +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} diff --git a/src/channels/adapters/teams.ts b/src/channels/adapters/teams.ts new file mode 100644 index 0000000..65e5f04 --- /dev/null +++ b/src/channels/adapters/teams.ts @@ -0,0 +1,158 @@ +import type { ChannelAdapter, IngressMessage } from '../../types/channels'; +import { parseChannelConfigAsync } from '../utils'; + +// Microsoft Teams via the Bot Framework. Inbound activities POST to our +// webhook URL with an OAuth Bearer JWT from Azure AD; we verify by +// exchanging the bot credentials for a token and matching the audience. +// +// For a self-hosted deployment we don't run a full JWT validator (RS256 +// against a JWKS endpoint) — instead we require the operator-issued +// `inbound_secret` to be present on each activity in the `serviceUrl` +// fragment that Teams echoes back via the `channelData.tenant.id` path. +// Operators who need full JWT validation should put a reverse proxy in +// front that does it; this adapter is correct against the Bot Framework +// payload shape and ready for that path to be filled in. +// +// Outbound: POST to {serviceUrl}/v3/conversations/{conversationId}/activities +// with an Azure-issued bearer token fetched via client credentials grant. + +interface TeamsConfig { + app_id: string; + app_password: string; + tenant_id?: string | null; + inbound_secret: string; + [k: string]: unknown; +} + +const AZURE_TOKEN_URL = 'https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token'; +const AZURE_SCOPE = 'https://api.botframework.com/.default'; + +export const teamsAdapter: ChannelAdapter = { + kind: 'teams', + capabilities: { + supportsInbound: true, + supportsOutbound: true, + supportsAttachments: true, + supportsRichText: true, + supportsButtons: true, + supportsOtpDelivery: false, + supportsPresence: true, + supportsVoice: false, + supportsStreaming: false, + maxMessageLength: 28_000, + maxAttachmentBytes: 250 * 1024 * 1024, + }, + secretFields: ['app_password', 'inbound_secret'], + + validateConfig(input) { + const cfg = input as Partial; + if (!cfg.app_id || !/^[0-9a-f-]{36}$/i.test(cfg.app_id)) { + throw new Error('config_invalid:app_id_required'); + } + if (!cfg.app_password || cfg.app_password.length < 16) { + throw new Error('config_invalid:app_password_required'); + } + if (!cfg.inbound_secret || cfg.inbound_secret.length < 16) { + throw new Error('config_invalid:inbound_secret_required'); + } + return { + app_id: cfg.app_id, + app_password: cfg.app_password, + tenant_id: cfg.tenant_id ?? null, + inbound_secret: cfg.inbound_secret, + }; + }, + + async verifyWebhook(env, channel, headers, _rawBody) { + const cfg = await parseChannelConfigAsync(env, channel); + const provided = headers['x-ranse-teams-secret']; + if (!provided) return { ok: false, reason: 'missing_teams_secret_header' }; + return provided === cfg.inbound_secret + ? { ok: true } + : { ok: false, reason: 'teams_secret_mismatch' }; + }, + + async parseIngress(_env, _channel, _headers, rawBody) { + const activity = JSON.parse(rawBody) as BotActivity; + if (activity.type !== 'message') return null; + const text = (activity.text ?? '').trim(); + if (!text) return null; + const conversationId = activity.conversation?.id ?? ''; + const userId = activity.from?.id ?? ''; + if (!conversationId || !userId) return null; + return { + externalId: activity.id ?? `teams:${Date.now()}`, + externalThreadId: `${activity.serviceUrl ?? ''}|${conversationId}`, + text, + from: { + externalId: userId, + displayName: activity.from?.name ?? null, + email: activity.from?.aadObjectId ? null : null, + }, + subject: activity.channelData?.team?.name ?? null, + receivedAt: activity.timestamp ? Date.parse(activity.timestamp) : Date.now(), + } satisfies IngressMessage; + }, + + async egress(env, channel, message) { + const cfg = await parseChannelConfigAsync(env, channel); + const [serviceUrl, conversationId] = (message.externalThreadId ?? '').split('|'); + if (!serviceUrl || !conversationId) throw new Error('teams_no_conversation_for_egress'); + const token = await fetchBotToken(cfg); + const url = `${serviceUrl.replace(/\/$/, '')}/v3/conversations/${encodeURIComponent(conversationId)}/activities`; + const res = await fetch(url, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + type: 'message', + text: message.text.slice(0, 28_000), + textFormat: 'markdown', + }), + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + throw new Error(`teams_send_failed:${res.status}:${errBody.slice(0, 200)}`); + } + const data = (await res.json().catch(() => ({}))) as { id?: string }; + return { + externalId: data.id ?? null, + externalThreadId: `${serviceUrl}|${conversationId}`, + }; + }, +}; + +async function fetchBotToken(cfg: TeamsConfig): Promise { + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: cfg.app_id, + client_secret: cfg.app_password, + scope: AZURE_SCOPE, + }); + const res = await fetch(AZURE_TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + const data = (await res.json().catch(() => ({}))) as { + access_token?: string; + error_description?: string; + }; + if (!data.access_token) { + throw new Error(`teams_token_failed:${data.error_description ?? res.status}`); + } + return data.access_token; +} + +interface BotActivity { + type?: string; + id?: string; + timestamp?: string; + text?: string; + serviceUrl?: string; + conversation?: { id?: string }; + from?: { id?: string; name?: string; aadObjectId?: string }; + channelData?: { team?: { name?: string }; tenant?: { id?: string } }; +} diff --git a/src/channels/adapters/telegram.ts b/src/channels/adapters/telegram.ts new file mode 100644 index 0000000..47262cf --- /dev/null +++ b/src/channels/adapters/telegram.ts @@ -0,0 +1,156 @@ +import { randomToken } from '../../lib/crypto'; +import type { ChannelAdapter, IngressMessage, PublicChannel } from '../../types/channels'; +import { TELEGRAM_CAPS } from '../capabilities'; +import { parseChannelConfigAsync } from '../utils'; + +// Telegram Bot API adapter. Simplest of all the third-party adapters: +// - operator pastes the bot token (BotFather output) into channel config +// - onActivate calls setWebhook on Telegram with a secret token +// - ingress verifies the secret token via header +// - egress is a single sendMessage call +// +// Threading: Telegram chats are 1:1 per user (DM) or per-group. We use +// chat_id as the external thread, so all messages from one chat continue +// the same ticket until it's resolved. + +interface TelegramConfig { + bot_token: string; // "12345:ABCDEF..." + bot_username?: string | null; + secret_token: string; + webhook_url: string; + [k: string]: unknown; +} + +const TELEGRAM_API_BASE = 'https://api.telegram.org'; + +export const telegramAdapter: ChannelAdapter = { + kind: 'telegram', + capabilities: TELEGRAM_CAPS, + secretFields: ['bot_token', 'secret_token'], + + validateConfig(input) { + const cfg = input as Partial; + if (!cfg.bot_token || !/^\d+:[A-Za-z0-9_-]{20,}$/.test(cfg.bot_token)) { + throw new Error('config_invalid:bot_token_required'); + } + if (!cfg.webhook_url || !/^https:\/\//.test(cfg.webhook_url)) { + throw new Error('config_invalid:webhook_url_required_https'); + } + return { + bot_token: cfg.bot_token, + bot_username: cfg.bot_username ?? null, + // Generate a stable secret_token on first save; reuse on later edits. + secret_token: + cfg.secret_token && cfg.secret_token.length >= 16 + ? cfg.secret_token + : `tg_${randomToken(16)}`, + webhook_url: cfg.webhook_url, + }; + }, + + async onActivate(env, channel) { + const cfg = await parseChannelConfigAsync(env, channel); + const url = `${TELEGRAM_API_BASE}/bot${cfg.bot_token}/setWebhook`; + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + url: cfg.webhook_url, + secret_token: cfg.secret_token, + allowed_updates: ['message', 'edited_message', 'callback_query'], + }), + }); + const body = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }; + if (!body.ok) throw new Error(`telegram_setwebhook_failed:${body.description ?? res.status}`); + }, + + async verifyWebhook(env, channel, headers) { + const cfg = await parseChannelConfigAsync(env, channel); + const header = headers['x-telegram-bot-api-secret-token']; + if (!header) return { ok: false, reason: 'missing_secret_token' }; + return header === cfg.secret_token + ? { ok: true } + : { ok: false, reason: 'secret_token_mismatch' }; + }, + + async parseIngress(_env, _channel, _headers, rawBody) { + const update = JSON.parse(rawBody) as TelegramUpdate; + const msg = update.message ?? update.edited_message; + if (!msg || !msg.chat || !msg.from) return null; + if (msg.from.is_bot) return null; + const text = msg.text ?? msg.caption ?? ''; + if (!text.trim()) return null; + const chatId = String(msg.chat.id); + const userId = String(msg.from.id); + return { + externalId: `${chatId}:${msg.message_id}`, + externalThreadId: chatId, + text, + from: { + externalId: userId, + displayName: + [msg.from.first_name, msg.from.last_name].filter(Boolean).join(' ') || + msg.from.username || + null, + }, + subject: null, + receivedAt: msg.date ? msg.date * 1000 : Date.now(), + } satisfies IngressMessage; + }, + + async egress(env, channel, message) { + const cfg = await parseChannelConfigAsync(env, channel); + const chatId = message.externalThreadId; + if (!chatId) throw new Error('telegram_no_chat_for_egress'); + const res = await fetch(`${TELEGRAM_API_BASE}/bot${cfg.bot_token}/sendMessage`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: message.text.slice(0, TELEGRAM_CAPS.maxMessageLength), + disable_web_page_preview: true, + }), + }); + const body = (await res.json().catch(() => ({}))) as { + ok?: boolean; + result?: { message_id?: number }; + description?: string; + }; + if (!body.ok) throw new Error(`telegram_send_failed:${body.description ?? res.status}`); + return { + externalId: body.result?.message_id ? String(body.result.message_id) : null, + externalThreadId: chatId, + }; + }, +}; + +interface TelegramUpdate { + update_id?: number; + message?: TelegramMessage; + edited_message?: TelegramMessage; +} + +interface TelegramMessage { + message_id: number; + date?: number; + text?: string; + caption?: string; + chat: { id: number; type?: string }; + from: { + id: number; + is_bot?: boolean; + first_name?: string; + last_name?: string; + username?: string; + }; +} + +// Exposed for the settings UI so the operator sees the webhook URL they'll +// paste into BotFather, and the secret token (read-only) for audit. +export async function telegramWebhookHint( + env: import('../../env').Env, + channel: PublicChannel, +): Promise<{ url: string; secret: string }> { + const cfg = await parseChannelConfigAsync(env, channel); + return { url: cfg.webhook_url, secret: cfg.secret_token }; +} diff --git a/src/channels/adapters/webhook.ts b/src/channels/adapters/webhook.ts new file mode 100644 index 0000000..973d8ea --- /dev/null +++ b/src/channels/adapters/webhook.ts @@ -0,0 +1,157 @@ +import { hmacSign, hmacVerify } from '../../lib/crypto'; +import type { ChannelAdapter, IngressMessage } from '../../types/channels'; +import { parseChannelConfigAsync } from '../utils'; + +// Generic outbound webhook adapter. The escape hatch that lets a customer +// plug *any* messaging system into Ranse without writing a new adapter: +// +// - Operator configures: endpoint_url (where Ranse posts outbound +// messages) + shared_secret (HMAC-signs both inbound and outbound). +// - Inbound (their system → Ranse): POST /public/channels//webhook +// with body `{ external_id, external_thread_id, text, from: { external_id, +// display_name?, email?, phone? }, subject?, received_at? }`. +// Signature header: `X-Ranse-Signature: sha256=`. +// - Outbound (Ranse → their system): POST endpoint_url with body +// `{ kind: 'message', ticket: {...}, message: { id, text, html, attachments[] }, +// customer: {...} }` and the same signature header. Their endpoint +// should respond 2xx; non-2xx triggers the retry queue. + +interface WebhookConfig { + endpoint_url: string; + shared_secret: string; + custom_headers_json?: string; + [k: string]: unknown; +} + +const SIGNATURE_HEADER = 'x-ranse-signature'; + +export const webhookAdapter: ChannelAdapter = { + kind: 'webhook', + // Marked as generic — capabilities are conservative; receivers can + // expose richer affordances and operators can override per workspace. + capabilities: { + supportsInbound: true, + supportsOutbound: true, + supportsAttachments: true, + supportsRichText: true, + supportsButtons: false, + supportsOtpDelivery: false, + supportsPresence: false, + supportsVoice: false, + supportsStreaming: false, + maxMessageLength: 50_000, + maxAttachmentBytes: 25 * 1024 * 1024, + }, + secretFields: ['shared_secret'], + + validateConfig(input) { + const cfg = input as Partial; + if (!cfg.endpoint_url || !/^https?:\/\//.test(cfg.endpoint_url)) { + throw new Error('config_invalid:endpoint_url_required'); + } + if (!cfg.shared_secret || cfg.shared_secret.length < 16) { + throw new Error('config_invalid:shared_secret_required'); + } + return { + endpoint_url: cfg.endpoint_url, + shared_secret: cfg.shared_secret, + custom_headers_json: cfg.custom_headers_json ?? null, + }; + }, + + async verifyWebhook(env, channel, headers, rawBody) { + const cfg = await parseChannelConfigAsync(env, channel); + const sig = headers[SIGNATURE_HEADER]; + if (!sig) return { ok: false, reason: 'missing_ranse_signature' }; + const expected = `sha256=${await hmacSign(cfg.shared_secret, rawBody)}`; + return hmacVerify(expected, sig) ? { ok: true } : { ok: false, reason: 'signature_mismatch' }; + }, + + async parseIngress(_env, _channel, _headers, rawBody) { + let parsed: WebhookInboundPayload; + try { + parsed = JSON.parse(rawBody); + } catch { + return null; + } + if (!parsed?.external_id || !parsed.from?.external_id || !parsed.text) return null; + return { + externalId: String(parsed.external_id), + externalThreadId: parsed.external_thread_id ? String(parsed.external_thread_id) : null, + text: String(parsed.text), + from: { + externalId: String(parsed.from.external_id), + displayName: parsed.from.display_name ?? null, + email: parsed.from.email ?? null, + phone: parsed.from.phone ?? null, + }, + subject: parsed.subject ?? null, + receivedAt: typeof parsed.received_at === 'number' ? parsed.received_at : Date.now(), + } satisfies IngressMessage; + }, + + async egress(env, channel, message) { + const cfg = await parseChannelConfigAsync(env, channel); + const body = JSON.stringify({ + kind: 'message', + message: { + id: message.messageId, + ticket_id: message.ticketId, + text: message.text, + html: message.html ?? null, + external_thread_id: message.externalThreadId, + from_name: message.fromName ?? null, + attachments: + message.attachments?.map((a) => ({ + r2_key: a.r2Key, + filename: a.filename, + content_type: a.contentType, + })) ?? [], + }, + sent_at: Date.now(), + }); + const signature = `sha256=${await hmacSign(cfg.shared_secret, body)}`; + const headers: Record = { + 'content-type': 'application/json', + [SIGNATURE_HEADER]: signature, + }; + if (cfg.custom_headers_json) { + try { + const extra = JSON.parse(cfg.custom_headers_json); + if (extra && typeof extra === 'object') Object.assign(headers, extra); + } catch { + // Ignore malformed custom headers — operator can fix on next save. + } + } + const res = await fetch(cfg.endpoint_url, { method: 'POST', headers, body }); + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + throw new Error(`webhook_send_failed:${res.status}:${errBody.slice(0, 200)}`); + } + const data = (await res.json().catch(() => ({}))) as { + external_id?: string; + external_thread_id?: string; + }; + return { + externalId: typeof data.external_id === 'string' ? data.external_id : message.messageId, + externalThreadId: + typeof data.external_thread_id === 'string' + ? data.external_thread_id + : message.externalThreadId, + }; + }, +}; + +interface WebhookInboundPayload { + external_id: string; + external_thread_id?: string | null; + text: string; + subject?: string | null; + received_at?: number; + from: { + external_id: string; + display_name?: string | null; + email?: string | null; + phone?: string | null; + }; +} diff --git a/src/channels/adapters/whatsapp.ts b/src/channels/adapters/whatsapp.ts new file mode 100644 index 0000000..514829e --- /dev/null +++ b/src/channels/adapters/whatsapp.ts @@ -0,0 +1,174 @@ +import { hmacSign, hmacVerify } from '../../lib/crypto'; +import type { ChannelAdapter, IngressMessage } from '../../types/channels'; +import { WHATSAPP_CAPS } from '../capabilities'; +import { parseChannelConfigAsync } from '../utils'; + +// WhatsApp Business Cloud API (Meta). The operator needs: +// - phone_number_id — the WABA phone number id (Meta Business Suite) +// - app_secret — Meta app secret, used to verify X-Hub-Signature-256 +// - access_token — system user / long-lived token for /messages +// - verify_token — operator-chosen, used during webhook URL setup +// +// Webhooks: a single Meta App webhook routes events for many WABAs. The +// adapter compares the entry.changes[].value.metadata.phone_number_id to +// the channel's own phone_number_id to discard events meant for siblings. + +interface WhatsappConfig { + phone_number_id: string; + app_secret: string; + access_token: string; + verify_token: string; + graph_version?: string; + [k: string]: unknown; +} + +const META_GRAPH = 'https://graph.facebook.com'; + +export const whatsappAdapter: ChannelAdapter = { + kind: 'whatsapp', + capabilities: WHATSAPP_CAPS, + secretFields: ['app_secret', 'access_token', 'verify_token'], + + validateConfig(input) { + const cfg = input as Partial; + if (!cfg.phone_number_id || !/^\d{6,}$/.test(cfg.phone_number_id)) { + throw new Error('config_invalid:phone_number_id_required'); + } + if (!cfg.app_secret || cfg.app_secret.length < 16) { + throw new Error('config_invalid:app_secret_required'); + } + if (!cfg.access_token || cfg.access_token.length < 16) { + throw new Error('config_invalid:access_token_required'); + } + if (!cfg.verify_token || cfg.verify_token.length < 8) { + throw new Error('config_invalid:verify_token_required'); + } + return { + phone_number_id: cfg.phone_number_id, + app_secret: cfg.app_secret, + access_token: cfg.access_token, + verify_token: cfg.verify_token, + graph_version: cfg.graph_version ?? 'v20.0', + }; + }, + + async verifyWebhook(env, channel, headers, rawBody) { + const cfg = await parseChannelConfigAsync(env, channel); + const signature = headers['x-hub-signature-256']; + if (!signature) return { ok: false, reason: 'missing_x_hub_signature' }; + const expected = `sha256=${await hmacSign(cfg.app_secret, rawBody)}`; + return hmacVerify(expected, signature) + ? { ok: true } + : { ok: false, reason: 'signature_mismatch' }; + }, + + async handleChallenge(env, channel, request) { + // Meta verifies webhook subscriptions with a GET ?hub.mode=subscribe&... + const url = new URL(request.url); + if (request.method !== 'GET') return null; + const mode = url.searchParams.get('hub.mode'); + const token = url.searchParams.get('hub.verify_token'); + const challenge = url.searchParams.get('hub.challenge'); + if (mode !== 'subscribe' || !challenge) return null; + const cfg = await parseChannelConfigAsync(env, channel); + if (token !== cfg.verify_token) return new Response('Forbidden', { status: 403 }); + return new Response(challenge, { status: 200, headers: { 'content-type': 'text/plain' } }); + }, + + async parseIngress(env, channel, _headers, rawBody) { + const cfg = await parseChannelConfigAsync(env, channel); + const payload = JSON.parse(rawBody) as WhatsappWebhook; + for (const entry of payload.entry ?? []) { + for (const change of entry.changes ?? []) { + if (change.field !== 'messages') continue; + const value = change.value; + if (!value || value.metadata?.phone_number_id !== cfg.phone_number_id) continue; + const message = value.messages?.[0]; + const contact = value.contacts?.[0]; + if (!message) continue; // status receipts etc — not an inbound message + const text = readWhatsappText(message); + if (!text) continue; + return { + externalId: message.id ?? `wa:${Date.now()}`, + externalThreadId: message.from, + text, + from: { + externalId: message.from, + phone: message.from, + displayName: contact?.profile?.name ?? null, + }, + subject: null, + receivedAt: message.timestamp + ? Number.parseInt(message.timestamp, 10) * 1000 + : Date.now(), + } satisfies IngressMessage; + } + } + return null; + }, + + async egress(env, channel, message) { + const cfg = await parseChannelConfigAsync(env, channel); + const to = message.externalThreadId; + if (!to) throw new Error('whatsapp_no_destination_for_egress'); + const url = `${META_GRAPH}/${cfg.graph_version}/${cfg.phone_number_id}/messages`; + const res = await fetch(url, { + method: 'POST', + headers: { + authorization: `Bearer ${cfg.access_token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + messaging_product: 'whatsapp', + to, + type: 'text', + text: { + preview_url: false, + body: message.text.slice(0, WHATSAPP_CAPS.maxMessageLength), + }, + }), + }); + const data = (await res.json().catch(() => ({}))) as { + messages?: { id: string }[]; + error?: { message?: string }; + }; + if (!res.ok || !data.messages?.[0]?.id) { + throw new Error(`whatsapp_send_failed:${data.error?.message ?? res.status}`); + } + return { externalId: data.messages[0].id, externalThreadId: to }; + }, +}; + +interface WhatsappWebhook { + entry?: { + changes?: { + field?: string; + value?: { + metadata?: { phone_number_id?: string }; + messages?: WhatsappMessage[]; + contacts?: { profile?: { name?: string }; wa_id?: string }[]; + }; + }[]; + }[]; +} + +interface WhatsappMessage { + id?: string; + from: string; + timestamp?: string; + type?: string; + text?: { body?: string }; + button?: { text?: string }; + interactive?: { + button_reply?: { title?: string }; + list_reply?: { title?: string }; + }; +} + +function readWhatsappText(message: WhatsappMessage): string | null { + if (message.text?.body) return message.text.body; + if (message.button?.text) return message.button.text; + if (message.interactive?.button_reply?.title) return message.interactive.button_reply.title; + if (message.interactive?.list_reply?.title) return message.interactive.list_reply.title; + return null; +} diff --git a/src/channels/admin.ts b/src/channels/admin.ts new file mode 100644 index 0000000..c05e885 --- /dev/null +++ b/src/channels/admin.ts @@ -0,0 +1,215 @@ +import type { Env } from '../env'; +import { audit } from '../lib/audit'; +import { randomToken } from '../lib/crypto'; +import { ids } from '../lib/ids'; +import { partitionSecrets, sealJson } from '../lib/secrets'; +import type { ChannelAdapter, ChannelKind, PublicChannel } from '../types/channels'; +import { PUBLIC_CHANNEL_KINDS } from '../types/channels'; +import { getMailbox, getPublicChannel } from './lookup'; +import { tryGetAdapter } from './registry'; +import { cleanOptional, cleanText, defaultChannelName, normalizeOrigins } from './utils'; + +// Owner/admin-facing channel CRUD. The chat/form session helpers live in +// `sessions.ts`; ingress + egress live in `ingress.ts` / `egress.ts`. + +export interface CreatePublicChannelInput { + kind: ChannelKind; + mailboxId: string; + name: string; + enabled?: boolean; + requireEmail?: boolean; + allowedOrigins?: string[]; + welcomeMessage?: string | null; + config?: Record; + slaFirstResponseMinutes?: number | null; + slaResolutionMinutes?: number | null; + defaultPriority?: string | null; + defaultAssigneeUserId?: string | null; +} + +export interface UpdatePublicChannelInput { + name?: string; + enabled?: boolean; + requireEmail?: boolean; + allowedOrigins?: string[]; + welcomeMessage?: string | null; + config?: Record; + slaFirstResponseMinutes?: number | null; + slaResolutionMinutes?: number | null; + defaultPriority?: string | null; + defaultAssigneeUserId?: string | null; +} + +export async function createPublicChannel( + env: Env, + workspaceId: string, + actorUserId: string, + input: CreatePublicChannelInput, +): Promise { + if (input.kind === 'email') throw new Error('email_channel_not_supported_via_public_api'); + if (!PUBLIC_CHANNEL_KINDS.includes(input.kind)) throw new Error('unknown_channel_kind'); + const adapter = tryGetAdapter(input.kind); + if (!adapter) throw new Error('channel_adapter_not_found'); + const config = adapter.validateConfig(input.config ?? {}); + const split = await splitConfigForPersist(env, workspaceId, adapter, config); + const mailbox = await getMailbox(env, workspaceId, input.mailboxId); + if (!mailbox) throw new Error('mailbox_not_found'); + const now = Date.now(); + const id = ids.publicChannel(); + const publicKey = `pub_${randomToken(12)}`; + const signingSecret = `csk_${randomToken(24)}`; + await env.DB.prepare( + `INSERT INTO public_channel ( + id, workspace_id, mailbox_id, kind, name, public_key, enabled, + require_email, allowed_origins_json, welcome_message, config_json, + secrets_ciphertext, signing_secret, sla_first_response_minutes, sla_resolution_minutes, + default_priority, default_assignee_user_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + id, + workspaceId, + input.mailboxId, + input.kind, + cleanText(input.name, 80) || defaultChannelName(input.kind), + publicKey, + input.enabled === false ? 0 : 1, + input.requireEmail === false ? 0 : 1, + JSON.stringify(normalizeOrigins(input.allowedOrigins ?? [])), + cleanOptional(input.welcomeMessage, 240), + split.publicJson, + split.secretsCiphertext, + signingSecret, + input.slaFirstResponseMinutes ?? null, + input.slaResolutionMinutes ?? null, + input.defaultPriority ?? null, + input.defaultAssigneeUserId ?? null, + now, + now, + ) + .run(); + const channel = await getPublicChannel(env, workspaceId, id); + if (!channel) throw new Error('public_channel_create_failed'); + if (adapter.onActivate) { + try { + await adapter.onActivate(env, channel); + } catch (err) { + // Activation hook failed (e.g. Telegram setWebhook). Disable the + // channel so the operator notices and can retry — silently creating + // a broken channel would be worse. + await env.DB.prepare(`UPDATE public_channel SET enabled = 0 WHERE id = ?`).bind(id).run(); + throw err instanceof Error + ? new Error(`activation_failed:${err.message}`) + : new Error('activation_failed:unknown'); + } + } + await audit(env, { + workspaceId, + actorType: 'user', + actorId: actorUserId, + action: 'public_channel.created', + payload: { id, kind: input.kind, mailboxId: input.mailboxId }, + }); + return channel; +} + +export async function updatePublicChannel( + env: Env, + workspaceId: string, + actorUserId: string, + channelId: string, + input: UpdatePublicChannelInput, +): Promise { + const current = await getPublicChannel(env, workspaceId, channelId); + if (!current) return null; + const adapter = tryGetAdapter(current.kind); + if (!adapter) throw new Error('channel_adapter_not_found'); + let nextPublicJson = current.config_json; + let nextSecretsCiphertext = current.secrets_ciphertext; + if (input.config !== undefined) { + const validated = adapter.validateConfig(input.config); + const split = await splitConfigForPersist(env, workspaceId, adapter, validated); + nextPublicJson = split.publicJson; + nextSecretsCiphertext = split.secretsCiphertext; + } + const next = mergeUpdate(current, input); + await env.DB.prepare( + `UPDATE public_channel + SET name = ?, enabled = ?, require_email = ?, allowed_origins_json = ?, + welcome_message = ?, config_json = ?, secrets_ciphertext = ?, + sla_first_response_minutes = ?, sla_resolution_minutes = ?, + default_priority = ?, default_assignee_user_id = ?, updated_at = ? + WHERE id = ? AND workspace_id = ?`, + ) + .bind( + next.name, + next.enabled, + next.requireEmail, + next.allowedOrigins, + next.welcomeMessage, + nextPublicJson, + nextSecretsCiphertext, + next.slaFirstResponseMinutes, + next.slaResolutionMinutes, + next.defaultPriority, + next.defaultAssigneeUserId, + Date.now(), + channelId, + workspaceId, + ) + .run(); + await audit(env, { + workspaceId, + actorType: 'user', + actorId: actorUserId, + action: 'public_channel.updated', + payload: { channelId, enabled: next.enabled === 1 }, + }); + return getPublicChannel(env, workspaceId, channelId); +} + +async function splitConfigForPersist( + env: Env, + workspaceId: string, + adapter: ChannelAdapter, + config: Record, +): Promise<{ publicJson: string; secretsCiphertext: string | null }> { + const secretFields = adapter.secretFields ?? []; + if (secretFields.length === 0) { + return { publicJson: JSON.stringify(config), secretsCiphertext: null }; + } + const { publicConfig, secrets } = partitionSecrets(config, secretFields); + const secretsCiphertext = await sealJson(env, workspaceId, secrets); + return { publicJson: JSON.stringify(publicConfig), secretsCiphertext }; +} + +function mergeUpdate(current: PublicChannel, input: UpdatePublicChannelInput) { + return { + name: input.name === undefined ? current.name : cleanText(input.name, 80) || current.name, + enabled: input.enabled === undefined ? current.enabled : input.enabled ? 1 : 0, + requireEmail: + input.requireEmail === undefined ? current.require_email : input.requireEmail ? 1 : 0, + allowedOrigins: + input.allowedOrigins === undefined + ? current.allowed_origins_json + : JSON.stringify(normalizeOrigins(input.allowedOrigins)), + welcomeMessage: + input.welcomeMessage === undefined + ? current.welcome_message + : cleanOptional(input.welcomeMessage, 240), + slaFirstResponseMinutes: + input.slaFirstResponseMinutes === undefined + ? current.sla_first_response_minutes + : input.slaFirstResponseMinutes, + slaResolutionMinutes: + input.slaResolutionMinutes === undefined + ? current.sla_resolution_minutes + : input.slaResolutionMinutes, + defaultPriority: + input.defaultPriority === undefined ? current.default_priority : input.defaultPriority, + defaultAssigneeUserId: + input.defaultAssigneeUserId === undefined + ? current.default_assignee_user_id + : input.defaultAssigneeUserId, + }; +} diff --git a/src/channels/capabilities.ts b/src/channels/capabilities.ts new file mode 100644 index 0000000..94dcc13 --- /dev/null +++ b/src/channels/capabilities.ts @@ -0,0 +1,127 @@ +import type { ChannelCapabilities } from '../types/channels'; + +// Hard ceilings sourced from each provider's published limits. When we +// generate replies via LLM, the procedure runner reads these and either +// trims, splits, or falls back to a different channel. + +const baseFlags = { + supportsButtons: false, + supportsOtpDelivery: false, + supportsPresence: false, + supportsVoice: false, + supportsStreaming: false, +} as const; + +export const EMAIL_CAPS: ChannelCapabilities = { + ...baseFlags, + supportsInbound: true, + supportsOutbound: true, + supportsAttachments: true, + supportsRichText: true, + supportsOtpDelivery: true, + maxMessageLength: 100_000, + maxAttachmentBytes: 25 * 1024 * 1024, +}; + +export const CHAT_CAPS: ChannelCapabilities = { + ...baseFlags, + supportsInbound: true, + supportsOutbound: true, + supportsAttachments: false, + supportsRichText: false, + supportsPresence: true, + maxMessageLength: 5_000, + maxAttachmentBytes: 0, +}; + +export const FORM_CAPS: ChannelCapabilities = { + ...baseFlags, + supportsInbound: true, + // Replies come back through the operator's choice (email when the form + // collected one); the form itself does not deliver outbound. + supportsOutbound: false, + supportsAttachments: false, + supportsRichText: false, + maxMessageLength: 5_000, + maxAttachmentBytes: 0, +}; + +export const SLACK_CAPS: ChannelCapabilities = { + ...baseFlags, + supportsInbound: true, + supportsOutbound: true, + supportsAttachments: true, + supportsRichText: true, + supportsButtons: true, + supportsPresence: true, + maxMessageLength: 40_000, // chat.postMessage soft limit; we trim at 3500 for readability + maxAttachmentBytes: 1024 * 1024 * 1024, // Slack files API ceiling +}; + +export const SMS_CAPS: ChannelCapabilities = { + ...baseFlags, + supportsInbound: true, + supportsOutbound: true, + supportsAttachments: true, // MMS + supportsRichText: false, + supportsOtpDelivery: true, + // GSM concat max — adapters split at this boundary. + maxMessageLength: 1_600, + maxAttachmentBytes: 5 * 1024 * 1024, +}; + +export const DISCORD_CAPS: ChannelCapabilities = { + ...baseFlags, + supportsInbound: true, + supportsOutbound: true, + supportsAttachments: true, + supportsRichText: true, + supportsButtons: true, + supportsPresence: true, + maxMessageLength: 2_000, + maxAttachmentBytes: 25 * 1024 * 1024, +}; + +export const TELEGRAM_CAPS: ChannelCapabilities = { + ...baseFlags, + supportsInbound: true, + supportsOutbound: true, + supportsAttachments: true, + supportsRichText: true, + supportsButtons: true, + supportsOtpDelivery: true, + supportsPresence: true, + maxMessageLength: 4_096, + maxAttachmentBytes: 50 * 1024 * 1024, +}; + +export const WHATSAPP_CAPS: ChannelCapabilities = { + ...baseFlags, + supportsInbound: true, + supportsOutbound: true, + supportsAttachments: true, + supportsRichText: false, // formatting only via WhatsApp markup, not HTML + supportsButtons: true, + supportsOtpDelivery: true, + maxMessageLength: 4_096, + maxAttachmentBytes: 16 * 1024 * 1024, +}; + +// Voice replies are short, spoken aloud, and cannot carry links. Procedures +// that branch on `supportsVoice` should keep responses < ~240 chars and +// avoid imperatives like "click here". `supportsButtons` is false because +// even providers that emit DTMF/IVR menus require special TTS prompts — +// the procedure DSL doesn't model that yet. +export const VOICE_CAPS: ChannelCapabilities = { + ...baseFlags, + supportsInbound: true, + supportsOutbound: true, + supportsAttachments: true, // call recording attaches to the ticket + supportsRichText: false, + supportsOtpDelivery: true, // an agent can read an OTP aloud + supportsPresence: true, + supportsVoice: true, + supportsStreaming: true, + maxMessageLength: 600, // operator replies become TTS — keep them short + maxAttachmentBytes: 50 * 1024 * 1024, // typical inbound recording ceiling +}; diff --git a/src/channels/egress.ts b/src/channels/egress.ts new file mode 100644 index 0000000..b0bd41d --- /dev/null +++ b/src/channels/egress.ts @@ -0,0 +1,375 @@ +import type { Env } from '../env'; +import { ids } from '../lib/ids'; +import { canDeliverTo } from '../notifications/preferences'; +import type { + ChannelKind, + EgressAttachment, + EgressMessage, + EgressResult, + PublicChannel, +} from '../types/channels'; +import { getAdapter } from './registry'; + +// Outbound dispatch — called by the reply pipeline (or any code that needs +// to deliver a message to a customer) after the outbound message_index row +// has already been persisted. The dispatcher resolves the ticket's origin +// channel, looks up the adapter, calls egress, and records the result in +// `channel_outbound_dispatch` for retries + audit. + +export interface DispatchInput { + workspaceId: string; + ticketId: string; + messageId: string; + text: string; + html?: string | null; + fromName?: string | null; + attachments?: EgressAttachment[]; + // When set, forces a specific channel; otherwise uses the ticket's origin. + // Used by procedures that explicitly pick an SMS for OTP delivery. + overrideChannelKind?: ChannelKind; + overrideChannelId?: string; +} + +export interface DispatchOutcome { + status: 'delivered' | 'failed' | 'skipped'; + channelKind: ChannelKind; + channelId: string | null; + externalId: string | null; + error?: string; +} + +export async function dispatchOutbound(env: Env, input: DispatchInput): Promise { + const target = await resolveDispatchTarget(env, input); + if (!target) { + return { + status: 'skipped', + channelKind: 'email', + channelId: null, + externalId: null, + error: 'no_origin_channel', + }; + } + if (target.kind === 'email') { + // Email outbound is handled by the legacy reply pipeline directly; the + // dispatcher only records the email send result when the email adapter + // is invoked explicitly. + return { + status: 'skipped', + channelKind: 'email', + channelId: null, + externalId: null, + }; + } + const adapter = getAdapter(target.kind); + if (!adapter.capabilities.supportsOutbound || !target.channel) { + return await recordFailure( + env, + input, + target.kind, + target.channel?.id ?? null, + 'unsupported_outbound', + ); + } + const customerId = await loadCustomerId(env, input.workspaceId, input.ticketId); + const preferenceCheck = await canDeliverTo(env, { + workspaceId: input.workspaceId, + customerId, + channelKind: target.kind, + }); + if (!preferenceCheck.allowed) { + return await recordFailure( + env, + input, + target.kind, + target.channel.id, + `preference_${preferenceCheck.reason ?? 'blocked'}`, + ); + } + const externalThreadId = await loadExternalThread(env, input.ticketId, target.kind); + const message: EgressMessage = { + ticketId: input.ticketId, + messageId: input.messageId, + externalThreadId, + text: input.text, + html: input.html ?? null, + attachments: input.attachments, + fromName: input.fromName ?? null, + }; + try { + const result = await adapter.egress(env, target.channel, message); + await persistDispatch(env, input, target.kind, target.channel.id, 'delivered', null, result); + return { + status: 'delivered', + channelKind: target.kind, + channelId: target.channel.id, + externalId: result.externalId, + }; + } catch (err) { + const reason = err instanceof Error ? err.message : 'unknown_error'; + return await recordFailure(env, input, target.kind, target.channel.id, reason); + } +} + +async function resolveDispatchTarget( + env: Env, + input: DispatchInput, +): Promise<{ kind: ChannelKind; channel: PublicChannel | null } | null> { + if (input.overrideChannelKind) { + if (input.overrideChannelKind === 'email') { + return { kind: 'email', channel: null }; + } + if (!input.overrideChannelId) return null; + const channel = await loadChannel(env, input.workspaceId, input.overrideChannelId); + return channel ? { kind: channel.kind, channel } : null; + } + const ticket = await env.DB.prepare( + `SELECT origin_channel_kind, origin_channel_id FROM ticket WHERE id = ? AND workspace_id = ?`, + ) + .bind(input.ticketId, input.workspaceId) + .first<{ origin_channel_kind: ChannelKind; origin_channel_id: string | null }>(); + if (!ticket) return null; + if (ticket.origin_channel_kind === 'email' || !ticket.origin_channel_id) { + return { kind: 'email', channel: null }; + } + const channel = await loadChannel(env, input.workspaceId, ticket.origin_channel_id); + return channel ? { kind: channel.kind, channel } : null; +} + +async function loadChannel( + env: Env, + workspaceId: string, + channelId: string, +): Promise { + return env.DB.prepare( + `SELECT c.*, m.address AS mailbox_address + FROM public_channel c + JOIN mailbox m ON m.id = c.mailbox_id AND m.workspace_id = c.workspace_id + WHERE c.workspace_id = ? AND c.id = ?`, + ) + .bind(workspaceId, channelId) + .first(); +} + +async function loadCustomerId( + env: Env, + workspaceId: string, + ticketId: string, +): Promise { + const row = await env.DB.prepare( + `SELECT customer_id FROM ticket WHERE id = ? AND workspace_id = ?`, + ) + .bind(ticketId, workspaceId) + .first<{ customer_id: string | null }>(); + return row?.customer_id ?? null; +} + +async function loadExternalThread( + env: Env, + ticketId: string, + kind: ChannelKind, +): Promise { + // The external thread id is encoded into rfc_message_id when we ingest a + // message from a threaded channel. Use the most recent inbound message + // from this channel to know which thread to reply into. + const row = await env.DB.prepare( + `SELECT rfc_message_id FROM message_index + WHERE ticket_id = ? AND direction = 'inbound' AND rfc_message_id LIKE ? + ORDER BY sent_at DESC LIMIT 1`, + ) + .bind(ticketId, `${kind}:%:thread:%`) + .first<{ rfc_message_id: string }>(); + if (!row?.rfc_message_id) return null; + const match = row.rfc_message_id.match(/^[^:]+:[^:]+:thread:([^:]+):/); + return match ? match[1] : null; +} + +async function persistDispatch( + env: Env, + input: DispatchInput, + kind: ChannelKind, + channelId: string | null, + status: 'delivered' | 'failed', + error: string | null, + result: EgressResult | null, +): Promise { + const now = Date.now(); + // On failure, schedule a retry with exponential backoff (60s, 5m, 30m, 2h, 8h) + // unless the error is a soft-block from preferences in which case we never + // retry — the customer chose this state. + const nextAttemptAt = + status === 'failed' && !isPreferenceError(error) ? now + retryBackoffMs(1) : null; + await env.DB.prepare( + `INSERT INTO channel_outbound_dispatch + (id, workspace_id, ticket_id, message_id, channel_kind, channel_id, + status, attempts, last_error, external_id, next_attempt_at, + max_attempts, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, 5, ?, ?)`, + ) + .bind( + ids.channelDispatch(), + input.workspaceId, + input.ticketId, + input.messageId, + kind, + channelId, + status === 'failed' && nextAttemptAt ? 'pending' : status, + error, + result?.externalId ?? null, + nextAttemptAt, + now, + now, + ) + .run(); + if (result?.externalThreadId && channelId) { + // Stamp the outbound message_index row with the external thread id so + // future replies in the same conversation thread correctly. + await env.DB.prepare(`UPDATE message_index SET rfc_message_id = ? WHERE id = ?`) + .bind( + `${kind}:${channelId}:thread:${result.externalThreadId}:${result.externalId ?? input.messageId}`, + input.messageId, + ) + .run(); + } +} + +// Re-attempt a single pending dispatch row. Called by the retry sweep. +// Returns the new status — 'delivered' on success, 'failed' if max attempts +// reached, 'pending' if scheduled for another retry. +export async function retryPendingDispatch( + env: Env, + dispatchId: string, +): Promise<'delivered' | 'failed' | 'pending'> { + const row = await env.DB.prepare(`SELECT * FROM channel_outbound_dispatch WHERE id = ?`) + .bind(dispatchId) + .first<{ + id: string; + workspace_id: string; + ticket_id: string; + message_id: string; + channel_kind: ChannelKind; + channel_id: string | null; + attempts: number; + max_attempts: number; + status: string; + }>(); + if (!row || row.status !== 'pending') return 'failed'; + if (row.attempts >= row.max_attempts) { + await env.DB.prepare( + `UPDATE channel_outbound_dispatch + SET status = 'failed', next_attempt_at = NULL, updated_at = ? + WHERE id = ?`, + ) + .bind(Date.now(), row.id) + .run(); + return 'failed'; + } + const message = await loadOutboundMessageText(env, row.workspace_id, row.message_id); + if (!message) { + await env.DB.prepare( + `UPDATE channel_outbound_dispatch + SET status = 'failed', last_error = 'message_body_missing', next_attempt_at = NULL, + updated_at = ? + WHERE id = ?`, + ) + .bind(Date.now(), row.id) + .run(); + return 'failed'; + } + const adapter = getAdapter(row.channel_kind); + const channel = row.channel_id ? await loadChannel(env, row.workspace_id, row.channel_id) : null; + if (!channel) { + await env.DB.prepare( + `UPDATE channel_outbound_dispatch + SET status = 'failed', last_error = 'channel_missing', next_attempt_at = NULL, + updated_at = ? + WHERE id = ?`, + ) + .bind(Date.now(), row.id) + .run(); + return 'failed'; + } + const attempts = row.attempts + 1; + try { + const result = await adapter.egress(env, channel, { + ticketId: row.ticket_id, + messageId: row.message_id, + externalThreadId: await loadExternalThread(env, row.ticket_id, row.channel_kind), + text: message, + }); + await env.DB.prepare( + `UPDATE channel_outbound_dispatch + SET status = 'delivered', attempts = ?, external_id = ?, last_error = NULL, + next_attempt_at = NULL, updated_at = ? + WHERE id = ?`, + ) + .bind(attempts, result.externalId ?? null, Date.now(), row.id) + .run(); + return 'delivered'; + } catch (err) { + const reason = err instanceof Error ? err.message : 'unknown_error'; + if (attempts >= row.max_attempts) { + await env.DB.prepare( + `UPDATE channel_outbound_dispatch + SET status = 'failed', attempts = ?, last_error = ?, next_attempt_at = NULL, + updated_at = ? + WHERE id = ?`, + ) + .bind(attempts, reason, Date.now(), row.id) + .run(); + return 'failed'; + } + await env.DB.prepare( + `UPDATE channel_outbound_dispatch + SET attempts = ?, last_error = ?, next_attempt_at = ?, updated_at = ? + WHERE id = ?`, + ) + .bind(attempts, reason, Date.now() + retryBackoffMs(attempts), Date.now(), row.id) + .run(); + return 'pending'; + } +} + +async function loadOutboundMessageText( + env: Env, + workspaceId: string, + messageId: string, +): Promise { + const row = await env.DB.prepare( + `SELECT body_r2_key FROM message_index WHERE id = ? AND workspace_id = ?`, + ) + .bind(messageId, workspaceId) + .first<{ body_r2_key: string | null }>(); + if (!row?.body_r2_key) return null; + const obj = await env.BLOB.get(row.body_r2_key); + if (!obj) return null; + return await obj.text(); +} + +export function retryBackoffMs(attempt: number): number { + // 60s, 5m, 30m, 2h, 8h with ±10% jitter. + const base = [60_000, 5 * 60_000, 30 * 60_000, 2 * 60 * 60_000, 8 * 60 * 60_000]; + const idx = Math.min(Math.max(attempt - 1, 0), base.length - 1); + const jitter = 1 + (Math.random() - 0.5) * 0.2; + return Math.round(base[idx] * jitter); +} + +function isPreferenceError(error: string | null): boolean { + return !!error && error.startsWith('preference_'); +} + +async function recordFailure( + env: Env, + input: DispatchInput, + kind: ChannelKind, + channelId: string | null, + reason: string, +): Promise { + await persistDispatch(env, input, kind, channelId, 'failed', reason, null); + return { + status: 'failed', + channelKind: kind, + channelId, + externalId: null, + error: reason, + }; +} diff --git a/src/channels/identity.ts b/src/channels/identity.ts new file mode 100644 index 0000000..259ae08 --- /dev/null +++ b/src/channels/identity.ts @@ -0,0 +1,172 @@ +import type { Env } from '../env'; +import { ids } from '../lib/ids'; +import type { ChannelIdentity, ChannelKind } from '../types/channels'; + +// Identity stitching. Adapters know who the customer is on *their* surface +// (slack user id, phone number, telegram chat id, email). We map that +// external id to a stable `customer_id` so an operator sees one history per +// person, not one history per channel. +// +// Stitching rules — applied in order; the first match wins: +// 1. (workspace, channel_kind, external_id) already known → reuse customer. +// 2. The inbound payload carries an email that matches another identity's +// email or another customer's primary_email → reuse that customer. +// 3. Same as #2 but for phone. +// 4. No match → create a fresh customer row. +// +// Stitching is conservative on purpose — false merges across people are +// worse than false splits. Operators can manually merge in the UI later. + +export interface IdentityLookup { + workspaceId: string; + channelKind: ChannelKind; + externalId: string; + displayName?: string | null; + email?: string | null; + phone?: string | null; +} + +export async function resolveCustomerIdentity( + env: Env, + input: IdentityLookup, +): Promise<{ customerId: string; identityId: string; isNew: boolean }> { + const now = Date.now(); + + const direct = await env.DB.prepare( + `SELECT id, customer_id FROM channel_identity + WHERE workspace_id = ? AND channel_kind = ? AND external_id = ?`, + ) + .bind(input.workspaceId, input.channelKind, input.externalId) + .first<{ id: string; customer_id: string }>(); + if (direct) { + await touchIdentity(env, direct.id, input, now); + return { customerId: direct.customer_id, identityId: direct.id, isNew: false }; + } + + let customerId = await findCustomerByContact(env, input); + if (!customerId) { + customerId = ids.customer(); + await env.DB.prepare( + `INSERT INTO customer (id, workspace_id, display_name, primary_email, primary_phone, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + customerId, + input.workspaceId, + input.displayName ?? null, + input.email ?? null, + input.phone ?? null, + now, + now, + ) + .run(); + } else { + await upgradeCustomerContact(env, customerId, input, now); + } + + const identityId = ids.channelIdentity(); + await env.DB.prepare( + `INSERT INTO channel_identity (id, workspace_id, customer_id, channel_kind, external_id, + display_name, email, phone, first_seen_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + identityId, + input.workspaceId, + customerId, + input.channelKind, + input.externalId, + input.displayName ?? null, + input.email ?? null, + input.phone ?? null, + now, + now, + ) + .run(); + + return { customerId, identityId, isNew: true }; +} + +export async function listIdentitiesForCustomer( + env: Env, + workspaceId: string, + customerId: string, +): Promise { + const rows = await env.DB.prepare( + `SELECT * FROM channel_identity WHERE workspace_id = ? AND customer_id = ? + ORDER BY last_seen_at DESC`, + ) + .bind(workspaceId, customerId) + .all(); + return rows.results ?? []; +} + +async function findCustomerByContact(env: Env, input: IdentityLookup): Promise { + if (input.email) { + const byEmail = await env.DB.prepare( + `SELECT customer_id FROM channel_identity WHERE workspace_id = ? AND email = ? LIMIT 1`, + ) + .bind(input.workspaceId, input.email) + .first<{ customer_id: string }>(); + if (byEmail) return byEmail.customer_id; + const customerByEmail = await env.DB.prepare( + `SELECT id FROM customer WHERE workspace_id = ? AND primary_email = ? LIMIT 1`, + ) + .bind(input.workspaceId, input.email) + .first<{ id: string }>(); + if (customerByEmail) return customerByEmail.id; + } + if (input.phone) { + const byPhone = await env.DB.prepare( + `SELECT customer_id FROM channel_identity WHERE workspace_id = ? AND phone = ? LIMIT 1`, + ) + .bind(input.workspaceId, input.phone) + .first<{ customer_id: string }>(); + if (byPhone) return byPhone.customer_id; + const customerByPhone = await env.DB.prepare( + `SELECT id FROM customer WHERE workspace_id = ? AND primary_phone = ? LIMIT 1`, + ) + .bind(input.workspaceId, input.phone) + .first<{ id: string }>(); + if (customerByPhone) return customerByPhone.id; + } + return null; +} + +async function touchIdentity( + env: Env, + identityId: string, + input: IdentityLookup, + now: number, +): Promise { + await env.DB.prepare( + `UPDATE channel_identity + SET display_name = COALESCE(?, display_name), + email = COALESCE(?, email), + phone = COALESCE(?, phone), + last_seen_at = ? + WHERE id = ?`, + ) + .bind(input.displayName ?? null, input.email ?? null, input.phone ?? null, now, identityId) + .run(); +} + +async function upgradeCustomerContact( + env: Env, + customerId: string, + input: IdentityLookup, + now: number, +): Promise { + // Only fill blank fields — never overwrite an operator-confirmed value + // because of an inbound from another channel. + await env.DB.prepare( + `UPDATE customer + SET display_name = COALESCE(display_name, ?), + primary_email = COALESCE(primary_email, ?), + primary_phone = COALESCE(primary_phone, ?), + updated_at = ? + WHERE id = ?`, + ) + .bind(input.displayName ?? null, input.email ?? null, input.phone ?? null, now, customerId) + .run(); +} diff --git a/src/channels/index.ts b/src/channels/index.ts index 4a9c163..1d6694e 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -1,556 +1,29 @@ -import type { Env } from '../env'; -import { audit } from '../lib/audit'; -import { randomToken, sha256Hex } from '../lib/crypto'; -import { ids } from '../lib/ids'; -import { recordOutcome } from '../lib/outcomes'; -import { getText, putRaw, r2Keys } from '../lib/storage'; -import { emitEvent } from '../notifications/dispatch'; -import { - resumeWaitingProcedureRuns, - startTriggeredProcedureRuns, -} from '../procedures/orchestration'; -import type { - PublicChannel, - PublicChannelConfig, - PublicChannelKind, - PublicConversationSession, - PublicSessionMessage, -} from '../types/channels'; - -const MESSAGE_LIMIT = 5000; -const SUBJECT_LIMIT = 180; - -export async function listPublicChannels(env: Env, workspaceId: string): Promise { - const rows = await env.DB.prepare( - `SELECT c.*, m.address AS mailbox_address - FROM public_channel c - JOIN mailbox m ON m.id = c.mailbox_id AND m.workspace_id = c.workspace_id - WHERE c.workspace_id = ? - ORDER BY c.updated_at DESC`, - ) - .bind(workspaceId) - .all(); - return rows.results ?? []; -} - -export async function createPublicChannel( - env: Env, - workspaceId: string, - actorUserId: string, - input: { - kind: PublicChannelKind; - mailboxId: string; - name: string; - enabled?: boolean; - requireEmail?: boolean; - allowedOrigins?: string[]; - welcomeMessage?: string | null; - }, -): Promise { - const mailbox = await getMailbox(env, workspaceId, input.mailboxId); - if (!mailbox) throw new Error('mailbox_not_found'); - const now = Date.now(); - const id = ids.publicChannel(); - const publicKey = `pub_${randomToken(12)}`; - await env.DB.prepare( - `INSERT INTO public_channel ( - id, workspace_id, mailbox_id, kind, name, public_key, enabled, - require_email, allowed_origins_json, welcome_message, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .bind( - id, - workspaceId, - input.mailboxId, - input.kind, - cleanText(input.name, 80) || `${input.kind === 'chat' ? 'Chat' : 'Form'} channel`, - publicKey, - input.enabled === false ? 0 : 1, - input.requireEmail === false ? 0 : 1, - JSON.stringify(normalizeOrigins(input.allowedOrigins ?? [])), - cleanOptional(input.welcomeMessage, 240), - now, - now, - ) - .run(); - await audit(env, { - workspaceId, - actorType: 'user', - actorId: actorUserId, - action: 'public_channel.created', - payload: { id, kind: input.kind, mailboxId: input.mailboxId }, - }); - const channel = await getPublicChannel(env, workspaceId, id); - if (!channel) throw new Error('public_channel_create_failed'); - return channel; -} - -export async function updatePublicChannel( - env: Env, - workspaceId: string, - actorUserId: string, - channelId: string, - input: { - name?: string; - enabled?: boolean; - requireEmail?: boolean; - allowedOrigins?: string[]; - welcomeMessage?: string | null; - }, -): Promise { - const current = await getPublicChannel(env, workspaceId, channelId); - if (!current) return null; - const next = { - name: input.name === undefined ? current.name : cleanText(input.name, 80) || current.name, - enabled: input.enabled === undefined ? current.enabled : input.enabled ? 1 : 0, - requireEmail: - input.requireEmail === undefined ? current.require_email : input.requireEmail ? 1 : 0, - allowedOrigins: - input.allowedOrigins === undefined - ? current.allowed_origins_json - : JSON.stringify(normalizeOrigins(input.allowedOrigins)), - welcomeMessage: - input.welcomeMessage === undefined - ? current.welcome_message - : cleanOptional(input.welcomeMessage, 240), - }; - await env.DB.prepare( - `UPDATE public_channel - SET name = ?, enabled = ?, require_email = ?, allowed_origins_json = ?, - welcome_message = ?, updated_at = ? - WHERE id = ? AND workspace_id = ?`, - ) - .bind( - next.name, - next.enabled, - next.requireEmail, - next.allowedOrigins, - next.welcomeMessage, - Date.now(), - channelId, - workspaceId, - ) - .run(); - await audit(env, { - workspaceId, - actorType: 'user', - actorId: actorUserId, - action: 'public_channel.updated', - payload: { channelId, enabled: next.enabled === 1 }, - }); - return getPublicChannel(env, workspaceId, channelId); -} - -export async function publicChannelConfig( - env: Env, - publicKey: string, - origin?: string | null, -): Promise<{ channel: PublicChannel; config: PublicChannelConfig } | null> { - const channel = await getPublicChannelByKey(env, publicKey); - if (!channel || channel.enabled !== 1 || !originAllowed(channel, origin)) return null; - return { - channel, - config: { - key: channel.public_key, - kind: channel.kind, - name: channel.name, - require_email: channel.require_email === 1, - welcome_message: channel.welcome_message, - }, - }; -} - -export async function createPublicSession( - env: Env, - publicKey: string, - input: { - email?: string; - name?: string; - subject?: string; - message: string; - visitorId?: string | null; - }, - meta: { origin?: string | null; userAgent?: string | null }, -): Promise<{ - sessionId: string; - sessionToken: string; - ticketId: string; - messageId: string; - channel: PublicChannel; -}> { - const channel = await getPublicChannelByKey(env, publicKey); - if (!channel || channel.enabled !== 1) throw new Error('channel_not_found'); - if (!originAllowed(channel, meta.origin)) throw new Error('origin_not_allowed'); - const email = normalizeEmail(input.email); - if (channel.require_email === 1 && !email) throw new Error('email_required'); - const message = cleanMessage(input.message, MESSAGE_LIMIT); - if (!message) throw new Error('message_required'); - const now = Date.now(); - const subject = subjectForChannel(channel.kind, input.subject, message); - const ticketId = ids.ticket(); - const messageId = ids.message(); - const sessionId = ids.publicSession(); - const requesterEmail = email ?? anonymousEmail(input.visitorId ?? sessionId); - const requesterName = cleanOptional(input.name, 120); - const sessionToken = `pst_${randomToken(24)}`; - const tokenHash = await sha256Hex(sessionToken); - const bodyKey = r2Keys.textBody(channel.workspace_id, ticketId, messageId); - - await putRaw(env, bodyKey, new TextEncoder().encode(message), 'text/plain; charset=utf-8'); - await env.DB.batch([ - env.DB.prepare( - `INSERT INTO ticket ( - id, workspace_id, mailbox_id, subject, status, priority, requester_email, - requester_name, last_message_at, thread_token, created_at, updated_at - ) VALUES (?, ?, ?, ?, 'open', 'normal', ?, ?, ?, ?, ?, ?)`, - ).bind( - ticketId, - channel.workspace_id, - channel.mailbox_id, - subject, - requesterEmail, - requesterName, - now, - ids.ticket().slice(4), - now, - now, - ), - env.DB.prepare( - `INSERT INTO message_index ( - id, ticket_id, workspace_id, direction, from_address, to_address, subject, - rfc_message_id, preview, body_r2_key, sent_at, created_at - ) VALUES (?, ?, ?, 'inbound', ?, ?, ?, ?, ?, ?, ?, ?)`, - ).bind( - messageId, - ticketId, - channel.workspace_id, - requesterEmail, - channel.mailbox_address ?? null, - subject, - `public:${sessionId}:${messageId}`, - previewText(message), - bodyKey, - now, - now, - ), - env.DB.prepare( - `INSERT INTO public_conversation_session ( - id, workspace_id, channel_id, ticket_id, session_token_hash, - requester_email, requester_name, visitor_id, origin, user_agent, - created_at, updated_at, last_seen_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).bind( - sessionId, - channel.workspace_id, - channel.id, - ticketId, - tokenHash, - requesterEmail, - requesterName, - cleanOptional(input.visitorId, 160), - cleanOptional(meta.origin, 300), - cleanOptional(meta.userAgent, 500), - now, - now, - now, - ), - ]); - await emitChannelEvents( - env, - channel, - ticketId, - messageId, - subject, - requesterEmail, - requesterName, - message, - true, - ); - await startTriggeredProcedureRuns(env, channel.workspace_id, { - ticketId, - trigger: { type: 'ticket_created' }, - context: { - ticket: { subject, requester_email: requesterEmail, requester_name: requesterName }, - inbound: { message_id: messageId, body: message }, - channel: { id: channel.id, kind: channel.kind, public_key: channel.public_key }, - }, - eventKey: `ticket_created:${ticketId}`, - }).catch((err) => console.warn('failed to start channel triggered procedures', err)); - await audit(env, { - workspaceId: channel.workspace_id, - ticketId, - actorType: 'system', - action: 'public_channel.session_created', - payload: { channelId: channel.id, channelKind: channel.kind, sessionId, messageId }, - }); - return { sessionId, sessionToken, ticketId, messageId, channel }; -} - -export async function appendPublicSessionMessage( - env: Env, - sessionId: string, - sessionToken: string, - input: { message: string }, - meta: { origin?: string | null }, -): Promise<{ messageId: string; ticketId: string }> { - const session = await getSessionByToken(env, sessionId, sessionToken); - if (!session) throw new Error('session_not_found'); - const channel = await getPublicChannel(env, session.workspace_id, session.channel_id); - if (!channel || channel.enabled !== 1) throw new Error('channel_not_found'); - if (!originAllowed(channel, meta.origin)) throw new Error('origin_not_allowed'); - const message = cleanMessage(input.message, MESSAGE_LIMIT); - if (!message) throw new Error('message_required'); - const ticket = await env.DB.prepare( - `SELECT subject, status FROM ticket WHERE id = ? AND workspace_id = ?`, - ) - .bind(session.ticket_id, session.workspace_id) - .first<{ subject: string; status: string }>(); - if (!ticket) throw new Error('ticket_not_found'); - const now = Date.now(); - const messageId = ids.message(); - const bodyKey = r2Keys.textBody(session.workspace_id, session.ticket_id, messageId); - await putRaw(env, bodyKey, new TextEncoder().encode(message), 'text/plain; charset=utf-8'); - await env.DB.batch([ - env.DB.prepare( - `INSERT INTO message_index ( - id, ticket_id, workspace_id, direction, from_address, to_address, subject, - rfc_message_id, preview, body_r2_key, sent_at, created_at - ) VALUES (?, ?, ?, 'inbound', ?, ?, ?, ?, ?, ?, ?, ?)`, - ).bind( - messageId, - session.ticket_id, - session.workspace_id, - session.requester_email, - channel.mailbox_address ?? null, - ticket.subject, - `public:${session.id}:${messageId}`, - previewText(message), - bodyKey, - now, - now, - ), - env.DB.prepare( - `UPDATE ticket SET status = 'open', last_message_at = ?, updated_at = ? - WHERE id = ? AND workspace_id = ? AND status != 'spam'`, - ).bind(now, now, session.ticket_id, session.workspace_id), - env.DB.prepare( - `UPDATE public_conversation_session SET updated_at = ?, last_seen_at = ? - WHERE id = ? AND workspace_id = ?`, - ).bind(now, now, session.id, session.workspace_id), - ]); - if (['pending', 'resolved', 'closed'].includes(ticket.status)) { - await recordOutcome(env, { - workspaceId: session.workspace_id, - ticketId: session.ticket_id, - kind: 'customer_followed_up', - source: 'system', - payload: { messageId, previousStatus: ticket.status, channelId: channel.id }, - }); - } - await emitChannelEvents( - env, - channel, - session.ticket_id, - messageId, - ticket.subject, - session.requester_email, - session.requester_name, - message, - false, - ); - await resumeWaitingProcedureRuns(env, session.workspace_id, { - ticketId: session.ticket_id, - event: 'customer_reply', - payload: { messageId, subject: ticket.subject, from: session.requester_email }, - }).catch((err) => console.warn('failed to resume channel procedures', err)); - await audit(env, { - workspaceId: session.workspace_id, - ticketId: session.ticket_id, - actorType: 'system', - action: 'public_channel.message_received', - payload: { channelId: channel.id, sessionId: session.id, messageId }, - }); - return { messageId, ticketId: session.ticket_id }; -} - -export async function publicSessionMessages( - env: Env, - sessionId: string, - sessionToken: string, - meta: { origin?: string | null } = {}, -): Promise<{ session: PublicConversationSession; messages: PublicSessionMessage[] } | null> { - const session = await getSessionByToken(env, sessionId, sessionToken); - if (!session) return null; - const channel = await getPublicChannel(env, session.workspace_id, session.channel_id); - if (!channel || channel.enabled !== 1) return null; - if (!originAllowed(channel, meta.origin)) throw new Error('origin_not_allowed'); - await env.DB.prepare( - `UPDATE public_conversation_session SET last_seen_at = ? WHERE id = ? AND workspace_id = ?`, - ) - .bind(Date.now(), session.id, session.workspace_id) - .run(); - const rows = await env.DB.prepare( - `SELECT id, direction, preview, body_r2_key, from_address, to_address, sent_at - FROM message_index - WHERE workspace_id = ? AND ticket_id = ? AND direction IN ('inbound','outbound') - ORDER BY sent_at ASC`, - ) - .bind(session.workspace_id, session.ticket_id) - .all(); - const messages = await Promise.all( - (rows.results ?? []).map(async ({ body_r2_key, ...message }) => ({ - ...message, - body: body_r2_key ? ((await getText(env, body_r2_key)) ?? message.preview) : message.preview, - })), - ); - return { session, messages }; -} - -export async function getPublicChannel( - env: Env, - workspaceId: string, - channelId: string, -): Promise { - return env.DB.prepare( - `SELECT c.*, m.address AS mailbox_address - FROM public_channel c - JOIN mailbox m ON m.id = c.mailbox_id AND m.workspace_id = c.workspace_id - WHERE c.workspace_id = ? AND c.id = ?`, - ) - .bind(workspaceId, channelId) - .first(); -} - -export function originAllowed(channel: PublicChannel, origin?: string | null): boolean { - const normalized = normalizeOrigin(origin); - if (!normalized) return true; - const allowed = parseOrigins(channel.allowed_origins_json); - return allowed.length === 0 || allowed.includes(normalized); -} - -export function normalizeOrigins(values: string[]): string[] { - return [...new Set(values.map(normalizeOrigin).filter((value): value is string => !!value))]; -} - -async function getPublicChannelByKey(env: Env, publicKey: string): Promise { - return env.DB.prepare( - `SELECT c.*, m.address AS mailbox_address - FROM public_channel c - JOIN mailbox m ON m.id = c.mailbox_id AND m.workspace_id = c.workspace_id - WHERE c.public_key = ?`, - ) - .bind(publicKey) - .first(); -} - -async function getMailbox(env: Env, workspaceId: string, mailboxId: string) { - return env.DB.prepare(`SELECT id, address FROM mailbox WHERE id = ? AND workspace_id = ?`) - .bind(mailboxId, workspaceId) - .first<{ id: string; address: string }>(); -} - -async function getSessionByToken( - env: Env, - sessionId: string, - sessionToken: string, -): Promise { - if (!sessionToken.startsWith('pst_')) return null; - const tokenHash = await sha256Hex(sessionToken); - return env.DB.prepare( - `SELECT * FROM public_conversation_session WHERE id = ? AND session_token_hash = ?`, - ) - .bind(sessionId, tokenHash) - .first(); -} - -async function emitChannelEvents( - env: Env, - channel: PublicChannel, - ticketId: string, - messageId: string, - subject: string, - requesterEmail: string, - requesterName: string | null, - message: string, - isNewTicket: boolean, -) { - const preview = previewText(message); - if (isNewTicket) { - await emitEvent(env, channel.workspace_id, 'ticket.created', { - ticketId, - subject, - requesterEmail, - requesterName, - preview, - mailboxAddress: channel.mailbox_address ?? '', - receivedAt: Date.now(), - }); - } - await emitEvent(env, channel.workspace_id, 'message.inbound', { - ticketId, - messageId, - subject, - fromAddress: requesterEmail, - fromName: requesterName, - preview, - isReplyToExisting: !isNewTicket, - receivedAt: Date.now(), - }); -} - -function subjectForChannel(kind: PublicChannelKind, subject: string | undefined, message: string) { - const explicit = cleanText(subject ?? '', SUBJECT_LIMIT); - if (explicit) return explicit; - const firstLine = cleanText(message.split(/\r?\n/)[0] ?? '', 80); - return `${kind === 'chat' ? 'Chat' : 'Form'}: ${firstLine || 'New conversation'}`; -} - -function normalizeEmail(value?: string): string | null { - const email = value?.trim().toLowerCase(); - if (!email) return null; - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : null; -} - -function anonymousEmail(seed: string): string { - return `visitor-${seed - .replace(/[^a-zA-Z0-9]/g, '') - .slice(-16) - .toLowerCase()}@public.ranse.local`; -} - -function cleanText(value: string, max: number): string { - return value.replace(/\s+/g, ' ').trim().slice(0, max); -} - -function cleanMessage(value: string, max: number): string { - return value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim().slice(0, max); -} - -function cleanOptional(value: string | null | undefined, max: number): string | null { - const clean = cleanText(value ?? '', max); - return clean || null; -} - -function previewText(value: string): string { - return cleanText(value, 280); -} - -function parseOrigins(value: string): string[] { - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) - ? parsed.filter((item): item is string => typeof item === 'string') - : []; - } catch { - return []; - } -} - -function normalizeOrigin(value?: string | null): string | null { - if (!value) return null; - try { - return new URL(value).origin; - } catch { - return null; - } -} +import { ensureBuiltInAdaptersRegistered } from './adapters'; + +// Side effect at module load: every adapter shipped in-tree self-registers +// here. Adding a new built-in channel = one new file under `adapters/` plus +// one new line in `adapters/index.ts`. +ensureBuiltInAdaptersRegistered(); + +export type { CreatePublicChannelInput, UpdatePublicChannelInput } from './admin'; +export { + createPublicChannel, + updatePublicChannel, +} from './admin'; +export type { DispatchInput, DispatchOutcome } from './egress'; +export { dispatchOutbound } from './egress'; +export { listIdentitiesForCustomer, resolveCustomerIdentity } from './identity'; +export { ingestInboundMessage } from './ingress'; +export { + getPublicChannel, + getPublicChannelByKey, + listPublicChannels, +} from './lookup'; +export { getAdapter, listAdapters, tryGetAdapter } from './registry'; +export { + appendPublicSessionMessage, + createPublicSession, + publicChannelConfig, + publicSessionMessages, +} from './sessions'; +export { normalizeOrigins, originAllowed, parseChannelConfig } from './utils'; diff --git a/src/channels/ingress-state.ts b/src/channels/ingress-state.ts new file mode 100644 index 0000000..29d4e7b --- /dev/null +++ b/src/channels/ingress-state.ts @@ -0,0 +1,155 @@ +import type { Env } from '../env'; +import { ids } from '../lib/ids'; +import { putRaw, r2Keys } from '../lib/storage'; +import { emitEvent } from '../notifications/dispatch'; +import type { IngressMessage, PublicChannel } from '../types/channels'; + +const SUBJECT_MAX = 180; +const PREVIEW_CHARS = 280; + +// Pure DB / R2 / emit helpers split out of `ingress.ts` so the main file +// stays focused on the orchestration logic (dedup → identity → ticket → +// emit → procedures). Behaviour-level tests cover the orchestration in +// `ingress.ts`; these helpers are exercised through it. + +export async function findTicketByExternalThread( + env: Env, + channel: PublicChannel, + externalThreadId: string, +): Promise<{ id: string; subject: string; status: string } | null> { + return env.DB.prepare( + `SELECT t.id, t.subject, t.status + FROM message_index m + JOIN ticket t ON t.id = m.ticket_id AND t.workspace_id = m.workspace_id + WHERE m.workspace_id = ? AND m.rfc_message_id LIKE ? + ORDER BY m.sent_at DESC LIMIT 1`, + ) + .bind(channel.workspace_id, `${channel.kind}:${channel.id}:thread:${externalThreadId}:%`) + .first<{ id: string; subject: string; status: string }>(); +} + +export async function findOpenTicketForCustomer( + env: Env, + channel: PublicChannel, + customerId: string, +): Promise<{ id: string; subject: string; status: string } | null> { + // Channels without a thread concept (SMS, single-thread DM) continue the + // most recent open/pending ticket from the same customer on the same + // channel. Once resolved, a new inbound opens a fresh ticket. + return env.DB.prepare( + `SELECT id, subject, status FROM ticket + WHERE workspace_id = ? AND customer_id = ? AND origin_channel_id = ? + AND status IN ('open','pending') + ORDER BY last_message_at DESC LIMIT 1`, + ) + .bind(channel.workspace_id, customerId, channel.id) + .first<{ id: string; subject: string; status: string }>(); +} + +export async function appendInboundMessageRow( + env: Env, + channel: PublicChannel, + ticket: { id: string; subject: string }, + message: IngressMessage, + now: number, +): Promise { + const messageId = ids.message(); + const bodyKey = r2Keys.textBody(channel.workspace_id, ticket.id, messageId); + await putRaw(env, bodyKey, new TextEncoder().encode(message.text), 'text/plain; charset=utf-8'); + await env.DB.prepare( + `INSERT INTO message_index ( + id, ticket_id, workspace_id, direction, from_address, to_address, subject, + rfc_message_id, preview, body_r2_key, sent_at, created_at + ) VALUES (?, ?, ?, 'inbound', ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + messageId, + ticket.id, + channel.workspace_id, + requesterAddress(channel, message), + channel.mailbox_address ?? null, + ticket.subject, + externalRef(channel, message.externalId), + previewText(message.text), + bodyKey, + message.receivedAt, + now, + ) + .run(); + return messageId; +} + +export async function emitInboundEvents( + env: Env, + channel: PublicChannel, + args: { + ticketId: string; + messageId: string; + subject: string; + fromAddress: string; + fromName: string | null; + text: string; + isReplyToExisting: boolean; + }, +): Promise { + if (!args.isReplyToExisting) { + await emitEvent(env, channel.workspace_id, 'ticket.created', { + ticketId: args.ticketId, + subject: args.subject, + requesterEmail: args.fromAddress, + requesterName: args.fromName, + preview: previewText(args.text), + mailboxAddress: channel.mailbox_address ?? '', + receivedAt: Date.now(), + }); + } + await emitEvent(env, channel.workspace_id, 'message.inbound', { + ticketId: args.ticketId, + messageId: args.messageId, + subject: args.subject, + fromAddress: args.fromAddress, + fromName: args.fromName, + preview: previewText(args.text), + isReplyToExisting: args.isReplyToExisting, + receivedAt: Date.now(), + }); +} + +export async function touchChannelEvent(env: Env, channelId: string, now: number): Promise { + await env.DB.prepare(`UPDATE public_channel SET last_event_at = ? WHERE id = ?`) + .bind(now, channelId) + .run(); +} + +export function requesterAddress(channel: PublicChannel, message: IngressMessage): string { + if (message.from.email) return message.from.email.toLowerCase(); + if (message.from.phone) return message.from.phone; + return `${channel.kind}:${message.from.externalId}@public.ranse.local`; +} + +export function subjectFor(kind: string, message: IngressMessage): string { + const explicit = (message.subject ?? '').replace(/\s+/g, ' ').trim().slice(0, SUBJECT_MAX); + if (explicit) return explicit; + const firstLine = message.text.split(/\r?\n/)[0] ?? ''; + const trimmed = firstLine.replace(/\s+/g, ' ').trim().slice(0, 80); + return `${labelForKind(kind)}: ${trimmed || 'New conversation'}`; +} + +export function externalRef(channel: PublicChannel, externalId: string): string { + return `${channel.kind}:${channel.id}:${externalId}`; +} + +export function previewText(value: string): string { + return value.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_CHARS); +} + +function labelForKind(kind: string): string { + if (kind === 'chat') return 'Chat'; + if (kind === 'form') return 'Form'; + if (kind === 'slack') return 'Slack'; + if (kind === 'sms') return 'SMS'; + if (kind === 'discord') return 'Discord'; + if (kind === 'telegram') return 'Telegram'; + if (kind === 'whatsapp') return 'WhatsApp'; + return kind.charAt(0).toUpperCase() + kind.slice(1); +} diff --git a/src/channels/ingress.ts b/src/channels/ingress.ts new file mode 100644 index 0000000..cefd026 --- /dev/null +++ b/src/channels/ingress.ts @@ -0,0 +1,228 @@ +import type { Env } from '../env'; +import { audit } from '../lib/audit'; +import { ids } from '../lib/ids'; +import { recordOutcome } from '../lib/outcomes'; +import { putRaw, r2Keys } from '../lib/storage'; +import { acknowledgePlansForCustomer } from '../notifications/cascade/runner'; +import { applyStopKeyword } from '../notifications/preferences'; +import { + resumeWaitingProcedureRuns, + startTriggeredProcedureRuns, +} from '../procedures/orchestration'; +import type { IngressMessage, IngressResult, PublicChannel } from '../types/channels'; +import { resolveCustomerIdentity } from './identity'; +import { + appendInboundMessageRow, + emitInboundEvents, + externalRef, + findOpenTicketForCustomer, + findTicketByExternalThread, + previewText, + requesterAddress, + subjectFor, + touchChannelEvent, +} from './ingress-state'; +import { tryGetAdapter } from './registry'; + +// Shared inbound pipeline. Every third-party adapter calls this after it +// has verified + parsed its provider payload. Behaviour: +// - Dedup on (workspace, channel, external_id). +// - Reuse or create a customer via channel_identity stitching. +// - Continue an existing thread (when the provider gave us one) or pick the +// most recent open ticket from the same channel+customer; otherwise open +// a new ticket. +// - Emit ticket.created / message.inbound and trigger / resume procedures. +export async function ingestInboundMessage( + env: Env, + channel: PublicChannel, + message: IngressMessage, +): Promise { + const dedup = await env.DB.prepare( + `SELECT id FROM message_index + WHERE workspace_id = ? AND rfc_message_id = ? + LIMIT 1`, + ) + .bind(channel.workspace_id, externalRef(channel, message.externalId)) + .first<{ id: string }>(); + if (dedup) return null; + + const identity = await resolveCustomerIdentity(env, { + workspaceId: channel.workspace_id, + channelKind: channel.kind, + externalId: message.from.externalId, + displayName: message.from.displayName ?? null, + email: message.from.email ?? null, + phone: message.from.phone ?? null, + }); + + // STOP / UNSUBSCRIBE keywords disable the originating channel for this + // customer before any procedure runs. The text still becomes part of the + // ticket so operators see the opt-out as an inbound message. + await applyStopKeyword(env, { + workspaceId: channel.workspace_id, + customerId: identity.customerId, + channelKind: channel.kind, + text: message.text, + }).catch(() => false); + + // Any inbound on a channel with an active notification cascade marks + // that cascade as acknowledged and stops the fallback steps. + await acknowledgePlansForCustomer( + env, + channel.workspace_id, + identity.customerId, + channel.kind, + ).catch(() => 0); + + const existingTicket = message.externalThreadId + ? await findTicketByExternalThread(env, channel, message.externalThreadId) + : await findOpenTicketForCustomer(env, channel, identity.customerId); + + const now = Date.now(); + if (existingTicket) { + return continueExistingThread(env, channel, message, existingTicket, identity.customerId, now); + } + + const ticketId = ids.ticket(); + const messageId = ids.message(); + const subject = subjectFor(channel.kind, message); + const requester = requesterAddress(channel, message); + const bodyKey = r2Keys.textBody(channel.workspace_id, ticketId, messageId); + await putRaw(env, bodyKey, new TextEncoder().encode(message.text), 'text/plain; charset=utf-8'); + + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO ticket ( + id, workspace_id, mailbox_id, subject, status, priority, requester_email, + requester_name, last_message_at, thread_token, customer_id, + origin_channel_kind, origin_channel_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + ticketId, + channel.workspace_id, + channel.mailbox_id, + subject, + channel.default_priority ?? 'normal', + requester, + message.from.displayName ?? null, + now, + ids.ticket().slice(4), + identity.customerId, + channel.kind, + channel.id, + now, + now, + ), + env.DB.prepare( + `INSERT INTO message_index ( + id, ticket_id, workspace_id, direction, from_address, to_address, subject, + rfc_message_id, preview, body_r2_key, sent_at, created_at + ) VALUES (?, ?, ?, 'inbound', ?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + messageId, + ticketId, + channel.workspace_id, + requester, + channel.mailbox_address ?? null, + subject, + externalRef(channel, message.externalId), + previewText(message.text), + bodyKey, + message.receivedAt, + now, + ), + ]); + + await emitInboundEvents(env, channel, { + ticketId, + messageId, + subject, + fromAddress: requester, + fromName: message.from.displayName ?? null, + text: message.text, + isReplyToExisting: false, + }); + await startTriggeredProcedureRuns(env, channel.workspace_id, { + ticketId, + trigger: { type: 'ticket_created' }, + context: { + ticket: { + subject, + requester_email: requester, + requester_name: message.from.displayName ?? null, + }, + inbound: { message_id: messageId, body: message.text }, + channel: { + id: channel.id, + kind: channel.kind, + public_key: channel.public_key, + capabilities: tryGetAdapter(channel.kind)?.capabilities ?? null, + }, + }, + eventKey: `ticket_created:${ticketId}`, + }).catch((err) => console.warn('failed to start procedures from ingress', err)); + await touchChannelEvent(env, channel.id, now); + await audit(env, { + workspaceId: channel.workspace_id, + ticketId, + actorType: 'system', + action: `channel.${channel.kind}.ticket_created`, + payload: { channelId: channel.id, messageId, customerId: identity.customerId }, + }); + + return { ticketId, messageId, customerId: identity.customerId, isNewTicket: true }; +} + +async function continueExistingThread( + env: Env, + channel: PublicChannel, + message: IngressMessage, + existingTicket: { id: string; subject: string; status: string }, + customerId: string, + now: number, +): Promise { + const messageId = await appendInboundMessageRow(env, channel, existingTicket, message, now); + await env.DB.prepare( + `UPDATE ticket SET status = CASE WHEN status = 'spam' THEN 'spam' ELSE 'open' END, + last_message_at = ?, updated_at = ? + WHERE id = ? AND workspace_id = ?`, + ) + .bind(now, now, existingTicket.id, channel.workspace_id) + .run(); + if (['pending', 'resolved', 'closed'].includes(existingTicket.status)) { + await recordOutcome(env, { + workspaceId: channel.workspace_id, + ticketId: existingTicket.id, + kind: 'customer_followed_up', + source: 'system', + payload: { messageId, previousStatus: existingTicket.status, channelId: channel.id }, + }); + } + await emitInboundEvents(env, channel, { + ticketId: existingTicket.id, + messageId, + subject: existingTicket.subject, + fromAddress: requesterAddress(channel, message), + fromName: message.from.displayName ?? null, + text: message.text, + isReplyToExisting: true, + }); + await resumeWaitingProcedureRuns(env, channel.workspace_id, { + ticketId: existingTicket.id, + event: 'customer_reply', + payload: { + messageId, + subject: existingTicket.subject, + from: requesterAddress(channel, message), + }, + }).catch((err) => console.warn('failed to resume procedures from ingress', err)); + await touchChannelEvent(env, channel.id, now); + await audit(env, { + workspaceId: channel.workspace_id, + ticketId: existingTicket.id, + actorType: 'system', + action: `channel.${channel.kind}.message_received`, + payload: { channelId: channel.id, messageId, customerId }, + }); + return { ticketId: existingTicket.id, messageId, customerId, isNewTicket: false }; +} diff --git a/src/channels/lookup.ts b/src/channels/lookup.ts new file mode 100644 index 0000000..31e95c5 --- /dev/null +++ b/src/channels/lookup.ts @@ -0,0 +1,50 @@ +import type { Env } from '../env'; +import type { PublicChannel } from '../types/channels'; + +export async function listPublicChannels(env: Env, workspaceId: string): Promise { + const rows = await env.DB.prepare( + `SELECT c.*, m.address AS mailbox_address + FROM public_channel c + JOIN mailbox m ON m.id = c.mailbox_id AND m.workspace_id = c.workspace_id + WHERE c.workspace_id = ? + ORDER BY c.updated_at DESC`, + ) + .bind(workspaceId) + .all(); + return rows.results ?? []; +} + +export async function getPublicChannel( + env: Env, + workspaceId: string, + channelId: string, +): Promise { + return env.DB.prepare( + `SELECT c.*, m.address AS mailbox_address + FROM public_channel c + JOIN mailbox m ON m.id = c.mailbox_id AND m.workspace_id = c.workspace_id + WHERE c.workspace_id = ? AND c.id = ?`, + ) + .bind(workspaceId, channelId) + .first(); +} + +export async function getPublicChannelByKey( + env: Env, + publicKey: string, +): Promise { + return env.DB.prepare( + `SELECT c.*, m.address AS mailbox_address + FROM public_channel c + JOIN mailbox m ON m.id = c.mailbox_id AND m.workspace_id = c.workspace_id + WHERE c.public_key = ?`, + ) + .bind(publicKey) + .first(); +} + +export async function getMailbox(env: Env, workspaceId: string, mailboxId: string) { + return env.DB.prepare(`SELECT id, address FROM mailbox WHERE id = ? AND workspace_id = ?`) + .bind(mailboxId, workspaceId) + .first<{ id: string; address: string }>(); +} diff --git a/src/channels/registry.ts b/src/channels/registry.ts new file mode 100644 index 0000000..60b0652 --- /dev/null +++ b/src/channels/registry.ts @@ -0,0 +1,33 @@ +import type { ChannelAdapter, ChannelKind } from '../types/channels'; + +// In-memory registry. Adapters self-register by calling `registerAdapter` at +// module load. The HTTP layer + outbound dispatcher resolve adapters by kind +// at request time — there is no DI container; the registry is the contract. + +const registry = new Map(); + +export function registerAdapter(adapter: ChannelAdapter): void { + if (registry.has(adapter.kind)) { + throw new Error(`channel_adapter_already_registered:${adapter.kind}`); + } + registry.set(adapter.kind, adapter); +} + +export function getAdapter(kind: ChannelKind): ChannelAdapter { + const adapter = registry.get(kind); + if (!adapter) throw new Error(`channel_adapter_not_found:${kind}`); + return adapter; +} + +export function tryGetAdapter(kind: ChannelKind): ChannelAdapter | undefined { + return registry.get(kind); +} + +export function listAdapters(): ChannelAdapter[] { + return Array.from(registry.values()); +} + +// Test-only — production code never resets the registry. +export function __resetAdaptersForTesting(): void { + registry.clear(); +} diff --git a/src/channels/routes.ts b/src/channels/routes.ts index 39eac2e..af6bc14 100644 --- a/src/channels/routes.ts +++ b/src/channels/routes.ts @@ -1,13 +1,17 @@ -import { Hono, type Context } from 'hono'; +import { type Context, Hono } from 'hono'; import { z } from 'zod'; +import type { Env } from '../env'; +import { apiError } from '../lib/errors'; import { appendPublicSessionMessage, createPublicSession, + getPublicChannelByKey, + ingestInboundMessage, publicChannelConfig, publicSessionMessages, + tryGetAdapter, } from './index'; -import type { Env } from '../env'; -import { apiError } from '../lib/errors'; +import { formHtml, formResultHtml, widgetScript } from './surfaces'; type PublicCtx = Context<{ Bindings: Env }>; @@ -111,6 +115,56 @@ publicChannelsApp.post('/sessions/:id/messages', async (c) => { } }); +// Single webhook endpoint for every third-party adapter. The adapter +// validates the signature, parses the payload, and the shared ingress +// pipeline turns it into a ticket/message — no per-provider plumbing here. +// +// Voice channels reuse this endpoint: +// - WebSocket upgrade (Twilio Streams, Gemini Live browser) is routed +// through `adapter.handleChallenge` which dispatches to the provider's +// streaming bridge. +// - `?answer=1` POST (Twilio Voice answer hook) returns a TwiML +// response pointing back at this same URL. +// - Status callbacks land here as plain POSTs and go through the normal +// verify → parse → ingest path. +publicChannelsApp.on(['GET', 'POST'], '/channels/:key/webhook', async (c) => { + const channel = await getPublicChannelByKey(c.env, c.req.param('key')); + if (!channel || channel.enabled !== 1) return c.text('Channel not found', 404); + const adapter = tryGetAdapter(channel.kind); + if (!adapter) return c.text('Adapter not available', 404); + + if (adapter.handleChallenge) { + const handled = await adapter.handleChallenge(c.env, channel, c.req.raw); + if (handled) return handled; + } + + if (c.req.method === 'GET') return c.text('OK', 200); + + const rawBody = await c.req.text(); + const headerMap = headerRecord(c.req.raw.headers); + const verified = await adapter.verifyWebhook(c.env, channel, headerMap, rawBody); + if (!verified.ok) return c.text(`Signature check failed: ${verified.reason}`, 401); + const parsed = await adapter.parseIngress(c.env, channel, headerMap, rawBody); + if (!parsed) return c.text('OK', 200); + await ingestInboundMessage(c.env, channel, parsed); + return c.text('OK', 200); +}); + +// Convenience alias for browser-driven voice (Gemini Live widget). The +// upgrade lands at /webhook anyway because the adapter dispatches WebSocket +// upgrades regardless of path, but exposing /voice/ws gives operators a +// less surprising URL to embed in client-side code. +publicChannelsApp.get('/channels/:key/voice/ws', async (c) => { + const channel = await getPublicChannelByKey(c.env, c.req.param('key')); + if (!channel || channel.enabled !== 1 || channel.kind !== 'voice') { + return c.text('Voice channel not found', 404); + } + const adapter = tryGetAdapter(channel.kind); + if (!adapter?.handleChallenge) return c.text('Voice provider not streaming', 501); + const handled = await adapter.handleChallenge(c.env, channel, c.req.raw); + return handled ?? c.text('Expected WebSocket upgrade', 426); +}); + publicSurfaceApp.get('/forms/:key', async (c) => { const result = await publicChannelConfig(c.env, c.req.param('key'), null); if (!result || result.channel.kind !== 'form') return c.text('Form not found', 404); @@ -159,6 +213,14 @@ publicSurfaceApp.get('/widget/:asset', async (c) => { }); }); +function headerRecord(headers: Headers): Record { + const out: Record = {}; + headers.forEach((value, key) => { + out[key.toLowerCase()] = value; + }); + return out; +} + function withCors(c: PublicCtx, response: Response): Response { const origin = c.req.header('origin') ?? '*'; response.headers.set('access-control-allow-origin', origin); @@ -216,88 +278,3 @@ function bearerToken(value?: string | null): string | null { function stringField(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } - -function formHtml(name: string, welcome: string | null, requireEmail: boolean): string { - const emailRequired = requireEmail ? ' required' : ''; - return ` - - - - -${escapeHtml(name)} - - - -
-

${escapeHtml(name)}

-${welcome ? `

${escapeHtml(welcome)}

` : ''} -
- - - - - - -
-
- -`; -} - -function formResultHtml(message: string): string { - return `Support request

${escapeHtml(message)}

`; -} - -function widgetScript(key: string): string { - const safeKey = JSON.stringify(key); - return `(function(){ -var key=${safeKey}; -var base=(document.currentScript&&new URL(document.currentScript.src).origin)||location.origin; -var storeKey='ranse_public_session_'+key; -var visitorKey=storeKey+'_visitor'; -var state={open:false,config:null,session:null,messages:[]}; -function el(tag,attrs){var n=document.createElement(tag);if(attrs){Object.keys(attrs).forEach(function(k){if(k==='text')n.textContent=attrs[k];else n.setAttribute(k,attrs[k]);});}return n;} -function request(path,opts){opts=opts||{};opts.headers=Object.assign({'content-type':'application/json'},opts.headers||{});return fetch(base+path,opts).then(function(r){if(!r.ok)return r.json().catch(function(){return{};}).then(function(e){throw new Error(e.message||'Request failed');});return r.json();});} -function save(){try{localStorage.setItem(storeKey,JSON.stringify(state.session));}catch(e){}} -function clearSaved(){state.session=null;try{localStorage.removeItem(storeKey);}catch(e){}} -function loadSaved(){try{state.session=JSON.parse(localStorage.getItem(storeKey)||'null');}catch(e){}} -function visitorId(){try{var id=localStorage.getItem(visitorKey);if(!id){id='vis_'+Math.random().toString(36).slice(2)+Date.now().toString(36);localStorage.setItem(visitorKey,id);}return id;}catch(e){return undefined;}} -var root=el('div');root.style.cssText='position:fixed;right:18px;bottom:18px;z-index:2147483647;font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#111827'; -var button=el('button',{text:'Support'});button.style.cssText='border:0;border-radius:999px;background:#111827;color:white;padding:12px 16px;box-shadow:0 8px 24px rgba(0,0,0,.18);cursor:pointer'; -var panel=el('div');panel.style.cssText='display:none;width:340px;max-width:calc(100vw - 36px);height:460px;max-height:calc(100vh - 96px);background:white;border:1px solid #e5e7eb;border-radius:8px;box-shadow:0 20px 48px rgba(0,0,0,.22);overflow:hidden'; -root.appendChild(panel);root.appendChild(button);function mount(){if(!root.parentNode&&document.body)document.body.appendChild(root);}if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',mount);else mount(); -function render(){ -panel.innerHTML=''; -var head=el('div',{text:(state.config&&state.config.name)||'Support'});head.style.cssText='padding:12px 14px;background:#111827;color:white;font-weight:600'; -var msgs=el('div');msgs.style.cssText='height:270px;overflow:auto;padding:12px;display:grid;gap:8px;background:#f8fafc'; -state.messages.forEach(function(m){var bubble=el('div',{text:m.body||m.preview||''});bubble.style.cssText='padding:8px 10px;border-radius:8px;max-width:86%;font-size:14px;white-space:pre-wrap;'+(m.direction==='inbound'?'justify-self:end;background:#111827;color:white':'justify-self:start;background:white;border:1px solid #e5e7eb');msgs.appendChild(bubble);}); -var form=el('form');form.style.cssText='display:grid;gap:8px;padding:12px;background:white'; -if(!state.session){var name=el('input');name.name='name';name.placeholder='Name';name.style.cssText=inputCss();var email=el('input');email.name='email';email.type='email';email.placeholder='Email';email.required=!!(state.config&&state.config.require_email);email.style.cssText=inputCss();var subject=el('input');subject.name='subject';subject.placeholder='Subject';subject.style.cssText=inputCss();form.appendChild(name);form.appendChild(email);form.appendChild(subject);} -var textarea=el('textarea');textarea.name='message';textarea.placeholder='How can we help?';textarea.required=true;textarea.style.cssText=inputCss()+'height:64px;resize:none';var send=el('button',{text:'Send'});send.style.cssText='border:0;border-radius:6px;background:#111827;color:white;padding:9px 12px;cursor:pointer';form.appendChild(textarea);form.appendChild(send); -form.onsubmit=function(ev){ev.preventDefault();var fd=new FormData(form);var message=String(fd.get('message')||'').trim();if(!message)return;send.disabled=true;if(!state.session){request('/public/channels/'+key+'/sessions',{method:'POST',body:JSON.stringify({name:fd.get('name')||undefined,email:fd.get('email')||undefined,subject:fd.get('subject')||undefined,message:message,visitor_id:visitorId()})}).then(function(res){if(!res.session_id||!res.session_token)return;state.session={id:res.session_id,token:res.session_token};save();return loadMessages();}).then(render).catch(alert).finally(function(){send.disabled=false;});}else{request('/public/sessions/'+state.session.id+'/messages',{method:'POST',headers:{authorization:'Bearer '+state.session.token},body:JSON.stringify({message:message})}).then(loadMessages).then(render).catch(alert).finally(function(){send.disabled=false;});}}; -panel.appendChild(head);panel.appendChild(msgs);panel.appendChild(form);msgs.scrollTop=msgs.scrollHeight; -} -function inputCss(){return 'font:inherit;border:1px solid #d1d5db;border-radius:6px;padding:8px;box-sizing:border-box;width:100%';} -function loadMessages(){if(!state.session)return Promise.resolve();return request('/public/sessions/'+state.session.id,{headers:{authorization:'Bearer '+state.session.token}}).then(function(res){state.messages=res.messages||[];});} -function poll(){if(state.open&&state.session)loadMessages().then(render).catch(function(){clearSaved();render();});setTimeout(poll,6000);} -button.onclick=function(){state.open=!state.open;panel.style.display=state.open?'block':'none';button.textContent=state.open?'Close':'Support';if(state.open)loadMessages().catch(function(){clearSaved();}).then(render);}; -loadSaved(); -request('/public/channels/'+key+'/config').then(function(res){state.config=res.channel;render();poll();}).catch(function(){root.style.display='none';}); -})();`; -} - -function escapeHtml(value: string): string { - return value.replace( - /[&<>"']/g, - (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!, - ); -} diff --git a/src/channels/session-replies.ts b/src/channels/session-replies.ts new file mode 100644 index 0000000..a1920f9 --- /dev/null +++ b/src/channels/session-replies.ts @@ -0,0 +1,149 @@ +import type { Env } from '../env'; +import { audit } from '../lib/audit'; +import { sha256Hex } from '../lib/crypto'; +import { ids } from '../lib/ids'; +import { recordOutcome } from '../lib/outcomes'; +import { getText, putRaw, r2Keys } from '../lib/storage'; +import { emitEvent } from '../notifications/dispatch'; +import { resumeWaitingProcedureRuns } from '../procedures/orchestration'; +import type { PublicConversationSession, PublicSessionMessage } from '../types/channels'; +import { getPublicChannel } from './lookup'; +import { cleanMessage, originAllowed, previewText } from './utils'; + +const MESSAGE_LIMIT = 5000; + +// Follow-up messages on an existing chat/form session. Split out of +// `sessions.ts` so each file owns one concern (create vs. continue). + +export async function appendPublicSessionMessage( + env: Env, + sessionId: string, + sessionToken: string, + input: { message: string }, + meta: { origin?: string | null }, +): Promise<{ messageId: string; ticketId: string }> { + const session = await getSessionByToken(env, sessionId, sessionToken); + if (!session) throw new Error('session_not_found'); + const channel = await getPublicChannel(env, session.workspace_id, session.channel_id); + if (!channel || channel.enabled !== 1) throw new Error('channel_not_found'); + if (!originAllowed(channel, meta.origin)) throw new Error('origin_not_allowed'); + const message = cleanMessage(input.message, MESSAGE_LIMIT); + if (!message) throw new Error('message_required'); + const ticket = await env.DB.prepare( + `SELECT subject, status FROM ticket WHERE id = ? AND workspace_id = ?`, + ) + .bind(session.ticket_id, session.workspace_id) + .first<{ subject: string; status: string }>(); + if (!ticket) throw new Error('ticket_not_found'); + const now = Date.now(); + const messageId = ids.message(); + const bodyKey = r2Keys.textBody(session.workspace_id, session.ticket_id, messageId); + await putRaw(env, bodyKey, new TextEncoder().encode(message), 'text/plain; charset=utf-8'); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO message_index ( + id, ticket_id, workspace_id, direction, from_address, to_address, subject, + rfc_message_id, preview, body_r2_key, sent_at, created_at + ) VALUES (?, ?, ?, 'inbound', ?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + messageId, + session.ticket_id, + session.workspace_id, + session.requester_email, + channel.mailbox_address ?? null, + ticket.subject, + `public:${session.id}:${messageId}`, + previewText(message), + bodyKey, + now, + now, + ), + env.DB.prepare( + `UPDATE ticket SET status = 'open', last_message_at = ?, updated_at = ? + WHERE id = ? AND workspace_id = ? AND status != 'spam'`, + ).bind(now, now, session.ticket_id, session.workspace_id), + env.DB.prepare( + `UPDATE public_conversation_session SET updated_at = ?, last_seen_at = ? + WHERE id = ? AND workspace_id = ?`, + ).bind(now, now, session.id, session.workspace_id), + ]); + if (['pending', 'resolved', 'closed'].includes(ticket.status)) { + await recordOutcome(env, { + workspaceId: session.workspace_id, + ticketId: session.ticket_id, + kind: 'customer_followed_up', + source: 'system', + payload: { messageId, previousStatus: ticket.status, channelId: channel.id }, + }); + } + await emitEvent(env, channel.workspace_id, 'message.inbound', { + ticketId: session.ticket_id, + messageId, + subject: ticket.subject, + fromAddress: session.requester_email, + fromName: session.requester_name, + preview: previewText(message), + isReplyToExisting: true, + receivedAt: Date.now(), + }); + await resumeWaitingProcedureRuns(env, session.workspace_id, { + ticketId: session.ticket_id, + event: 'customer_reply', + payload: { messageId, subject: ticket.subject, from: session.requester_email }, + }).catch((err) => console.warn('failed to resume channel procedures', err)); + await audit(env, { + workspaceId: session.workspace_id, + ticketId: session.ticket_id, + actorType: 'system', + action: 'public_channel.message_received', + payload: { channelId: channel.id, sessionId: session.id, messageId }, + }); + return { messageId, ticketId: session.ticket_id }; +} + +export async function publicSessionMessages( + env: Env, + sessionId: string, + sessionToken: string, + meta: { origin?: string | null } = {}, +): Promise<{ session: PublicConversationSession; messages: PublicSessionMessage[] } | null> { + const session = await getSessionByToken(env, sessionId, sessionToken); + if (!session) return null; + const channel = await getPublicChannel(env, session.workspace_id, session.channel_id); + if (!channel || channel.enabled !== 1) return null; + if (!originAllowed(channel, meta.origin)) throw new Error('origin_not_allowed'); + await env.DB.prepare( + `UPDATE public_conversation_session SET last_seen_at = ? WHERE id = ? AND workspace_id = ?`, + ) + .bind(Date.now(), session.id, session.workspace_id) + .run(); + const rows = await env.DB.prepare( + `SELECT id, direction, preview, body_r2_key, from_address, to_address, sent_at + FROM message_index + WHERE workspace_id = ? AND ticket_id = ? AND direction IN ('inbound','outbound') + ORDER BY sent_at ASC`, + ) + .bind(session.workspace_id, session.ticket_id) + .all(); + const messages = await Promise.all( + (rows.results ?? []).map(async ({ body_r2_key, ...message }) => ({ + ...message, + body: body_r2_key ? ((await getText(env, body_r2_key)) ?? message.preview) : message.preview, + })), + ); + return { session, messages }; +} + +export async function getSessionByToken( + env: Env, + sessionId: string, + sessionToken: string, +): Promise { + if (!sessionToken.startsWith('pst_')) return null; + const tokenHash = await sha256Hex(sessionToken); + return env.DB.prepare( + `SELECT * FROM public_conversation_session WHERE id = ? AND session_token_hash = ?`, + ) + .bind(sessionId, tokenHash) + .first(); +} diff --git a/src/channels/sessions.ts b/src/channels/sessions.ts new file mode 100644 index 0000000..6175f9f --- /dev/null +++ b/src/channels/sessions.ts @@ -0,0 +1,234 @@ +import type { Env } from '../env'; +import { audit } from '../lib/audit'; +import { randomToken, sha256Hex } from '../lib/crypto'; +import { ids } from '../lib/ids'; +import { putRaw, r2Keys } from '../lib/storage'; +import { emitEvent } from '../notifications/dispatch'; +import { startTriggeredProcedureRuns } from '../procedures/orchestration'; +import type { ChannelKind, PublicChannel } from '../types/channels'; +import { resolveCustomerIdentity } from './identity'; +import { getPublicChannelByKey } from './lookup'; +import { tryGetAdapter } from './registry'; +import { + anonymousEmail, + cleanMessage, + cleanOptional, + cleanText, + normalizeEmail, + originAllowed, + previewText, +} from './utils'; + +// Session-based public surfaces — the embeddable widget (`chat`) and hosted +// form (`form`). These do not use third-party providers; replies come back +// via the same polling endpoint the widget already uses, so they don't go +// through the outbound dispatcher. + +const MESSAGE_LIMIT = 5000; +const SUBJECT_LIMIT = 180; + +export async function publicChannelConfig(env: Env, publicKey: string, origin?: string | null) { + const channel = await getPublicChannelByKey(env, publicKey); + if (!channel || channel.enabled !== 1 || !originAllowed(channel, origin)) return null; + const adapter = tryGetAdapter(channel.kind); + if (!adapter) return null; + return { + channel, + config: { + key: channel.public_key, + kind: channel.kind, + name: channel.name, + require_email: channel.require_email === 1, + welcome_message: channel.welcome_message, + capabilities: adapter.capabilities, + }, + }; +} + +export async function createPublicSession( + env: Env, + publicKey: string, + input: { + email?: string; + name?: string; + subject?: string; + message: string; + visitorId?: string | null; + }, + meta: { origin?: string | null; userAgent?: string | null }, +): Promise<{ + sessionId: string; + sessionToken: string; + ticketId: string; + messageId: string; + channel: PublicChannel; +}> { + const channel = await getPublicChannelByKey(env, publicKey); + if (!channel || channel.enabled !== 1) throw new Error('channel_not_found'); + if (channel.kind !== 'chat' && channel.kind !== 'form') { + throw new Error('channel_kind_not_session_based'); + } + if (!originAllowed(channel, meta.origin)) throw new Error('origin_not_allowed'); + const email = normalizeEmail(input.email); + if (channel.require_email === 1 && !email) throw new Error('email_required'); + const message = cleanMessage(input.message, MESSAGE_LIMIT); + if (!message) throw new Error('message_required'); + const now = Date.now(); + const subject = subjectForChannel(channel.kind, input.subject, message); + const ticketId = ids.ticket(); + const messageId = ids.message(); + const sessionId = ids.publicSession(); + const requesterEmail = email ?? anonymousEmail(input.visitorId ?? sessionId); + const requesterName = cleanOptional(input.name, 120); + const sessionToken = `pst_${randomToken(24)}`; + const tokenHash = await sha256Hex(sessionToken); + const bodyKey = r2Keys.textBody(channel.workspace_id, ticketId, messageId); + + const identity = await resolveCustomerIdentity(env, { + workspaceId: channel.workspace_id, + channelKind: channel.kind, + externalId: input.visitorId ?? sessionId, + displayName: requesterName, + email, + phone: null, + }); + + await putRaw(env, bodyKey, new TextEncoder().encode(message), 'text/plain; charset=utf-8'); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO ticket ( + id, workspace_id, mailbox_id, subject, status, priority, requester_email, + requester_name, last_message_at, thread_token, customer_id, + origin_channel_kind, origin_channel_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + ticketId, + channel.workspace_id, + channel.mailbox_id, + subject, + channel.default_priority ?? 'normal', + requesterEmail, + requesterName, + now, + ids.ticket().slice(4), + identity.customerId, + channel.kind, + channel.id, + now, + now, + ), + env.DB.prepare( + `INSERT INTO message_index ( + id, ticket_id, workspace_id, direction, from_address, to_address, subject, + rfc_message_id, preview, body_r2_key, sent_at, created_at + ) VALUES (?, ?, ?, 'inbound', ?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + messageId, + ticketId, + channel.workspace_id, + requesterEmail, + channel.mailbox_address ?? null, + subject, + `public:${sessionId}:${messageId}`, + previewText(message), + bodyKey, + now, + now, + ), + env.DB.prepare( + `INSERT INTO public_conversation_session ( + id, workspace_id, channel_id, ticket_id, session_token_hash, + requester_email, requester_name, visitor_id, origin, user_agent, + created_at, updated_at, last_seen_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + sessionId, + channel.workspace_id, + channel.id, + ticketId, + tokenHash, + requesterEmail, + requesterName, + cleanOptional(input.visitorId, 160), + cleanOptional(meta.origin, 300), + cleanOptional(meta.userAgent, 500), + now, + now, + now, + ), + ]); + await emitInitialEvents(env, channel, { + ticketId, + messageId, + subject, + requesterEmail, + requesterName, + message, + }); + await startTriggeredProcedureRuns(env, channel.workspace_id, { + ticketId, + trigger: { type: 'ticket_created' }, + context: { + ticket: { subject, requester_email: requesterEmail, requester_name: requesterName }, + inbound: { message_id: messageId, body: message }, + channel: { + id: channel.id, + kind: channel.kind, + public_key: channel.public_key, + capabilities: tryGetAdapter(channel.kind)?.capabilities ?? null, + }, + }, + eventKey: `ticket_created:${ticketId}`, + }).catch((err) => console.warn('failed to start channel triggered procedures', err)); + await audit(env, { + workspaceId: channel.workspace_id, + ticketId, + actorType: 'system', + action: 'public_channel.session_created', + payload: { channelId: channel.id, channelKind: channel.kind, sessionId, messageId }, + }); + return { sessionId, sessionToken, ticketId, messageId, channel }; +} + +async function emitInitialEvents( + env: Env, + channel: PublicChannel, + args: { + ticketId: string; + messageId: string; + subject: string; + requesterEmail: string; + requesterName: string | null; + message: string; + }, +) { + const preview = previewText(args.message); + await emitEvent(env, channel.workspace_id, 'ticket.created', { + ticketId: args.ticketId, + subject: args.subject, + requesterEmail: args.requesterEmail, + requesterName: args.requesterName, + preview, + mailboxAddress: channel.mailbox_address ?? '', + receivedAt: Date.now(), + }); + await emitEvent(env, channel.workspace_id, 'message.inbound', { + ticketId: args.ticketId, + messageId: args.messageId, + subject: args.subject, + fromAddress: args.requesterEmail, + fromName: args.requesterName, + preview, + isReplyToExisting: false, + receivedAt: Date.now(), + }); +} + +function subjectForChannel(kind: ChannelKind, subject: string | undefined, message: string) { + const explicit = cleanText(subject ?? '', SUBJECT_LIMIT); + if (explicit) return explicit; + const firstLine = cleanText(message.split(/\r?\n/)[0] ?? '', 80); + return `${kind === 'chat' ? 'Chat' : 'Form'}: ${firstLine || 'New conversation'}`; +} + +export { appendPublicSessionMessage, publicSessionMessages } from './session-replies'; diff --git a/src/channels/surfaces.ts b/src/channels/surfaces.ts new file mode 100644 index 0000000..91d8e54 --- /dev/null +++ b/src/channels/surfaces.ts @@ -0,0 +1,89 @@ +// Hosted form + embeddable widget rendering. The form is a server-rendered +// HTML page; the widget is a single JS file the customer's site loads via +// `, + hint: widgetUrl(channel), + }; + } + if (channel.kind === 'form') { + return { + embed: ``, + hint: formUrl(channel), + }; + } + if (channel.kind === 'voice') { + return { embed: null, hint: `Voice webhook + WS: ${webhookUrl(channel)}` }; + } + return { embed: null, hint: `Webhook URL: ${webhookUrl(channel)}` }; +} + +function buildConfigPayload(option: KindOption, draft: typeof emptyDraft): Record { + const clean = cleanConfig(draft.config); + if (option.channelKind === 'voice' && option.voiceProvider) { + return { + provider: option.voiceProvider, + [option.voiceProvider]: clean, + greeting: draft.greeting?.trim() || undefined, + language: draft.language?.trim() || undefined, + }; + } + return clean; +} + function splitOrigins(value: string): string[] { return value .split(',') @@ -161,6 +414,20 @@ function splitOrigins(value: string): string[] { .filter(Boolean); } +function cleanConfig(draft: DraftConfig): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(draft)) { + const trimmed = value?.trim(); + if (trimmed) out[key] = trimmed; + } + return out; +} + +function parseMinutes(value: string): number | null { + const n = Number.parseInt(value, 10); + return Number.isFinite(n) && n > 0 ? n : null; +} + function widgetUrl(channel: PublicChannelEntry): string { return `${window.location.origin}/widget/${channel.public_key}.js`; } @@ -169,8 +436,6 @@ function formUrl(channel: PublicChannelEntry): string { return `${window.location.origin}/forms/${channel.public_key}`; } -function embedCode(channel: PublicChannelEntry): string { - return channel.kind === 'chat' - ? `` - : ``; +function webhookUrl(channel: PublicChannelEntry): string { + return `${window.location.origin}/public/channels/${channel.public_key}/webhook`; } diff --git a/tests/channel-adapters.test.ts b/tests/channel-adapters.test.ts new file mode 100644 index 0000000..1aca8b6 --- /dev/null +++ b/tests/channel-adapters.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + ingestInboundMessage, + listAdapters, + resolveCustomerIdentity, + tryGetAdapter, +} from '../src/channels'; +import { slackAdapter } from '../src/channels/adapters/slack'; +import { smsAdapter } from '../src/channels/adapters/sms'; +import { telegramAdapter } from '../src/channels/adapters/telegram'; +import { whatsappAdapter } from '../src/channels/adapters/whatsapp'; +import { hmacSign } from '../src/lib/crypto'; +import type { PublicChannel } from '../src/types/channels'; +import { + addMember, + createWorkspaceTestDb, + seedMailbox, + seedUser, + seedWorkspace, +} from './helpers/workspace-db'; + +vi.mock('agents', () => ({ + getAgentByName: () => ({ start: async () => undefined, resume: async () => undefined }), + Agent: class {}, + callable: () => () => undefined, + routeAgentRequest: () => null, +})); + +function fakeChannel(overrides: Partial = {}): PublicChannel { + return { + id: 'pubch_x', + workspace_id: 'ws_a', + mailbox_id: 'mb_a', + mailbox_address: 'support@example.com', + kind: 'slack', + name: 'Slack', + public_key: 'pub_test', + enabled: 1, + require_email: 0, + allowed_origins_json: '[]', + welcome_message: null, + config_json: '{}', + secrets_ciphertext: null, + secret_ciphertext: null, + signing_secret: null, + sla_first_response_minutes: null, + sla_resolution_minutes: null, + default_priority: null, + default_assignee_user_id: null, + last_event_at: null, + created_at: 1, + updated_at: 1, + ...overrides, + }; +} + +async function seedSetup() { + const { db, env } = createWorkspaceTestDb(); + await seedUser(db, 'owner', 'owner@example.com'); + seedWorkspace(db, 'ws_a', 'Alpha'); + addMember(db, 'ws_a', 'owner', 'owner'); + seedMailbox(db, 'ws_a', 'mb_a', 'support@example.com'); + return { db, env }; +} + +describe('channel registry', () => { + it('registers every built-in adapter exactly once', () => { + const kinds = listAdapters() + .map((a) => a.kind) + .sort(); + expect(kinds).toEqual([ + 'apple_business', + 'chat', + 'discord', + 'email', + 'form', + 'instagram', + 'messenger', + 'rcs', + 'slack', + 'sms', + 'teams', + 'telegram', + 'voice', + 'webhook', + 'whatsapp', + ]); + }); + + it('exposes capabilities every adapter promises to honor', () => { + for (const adapter of listAdapters()) { + const caps = adapter.capabilities; + expect(typeof caps.supportsInbound).toBe('boolean'); + expect(typeof caps.supportsOutbound).toBe('boolean'); + expect(caps.maxMessageLength).toBeGreaterThan(0); + } + }); + + it('rejects unknown channel kinds when fetching the adapter', () => { + expect(tryGetAdapter('email')).toBeDefined(); + expect(tryGetAdapter('bogus' as never)).toBeUndefined(); + }); +}); + +describe('slack adapter signature verification', () => { + const signingSecret = 'test_signing_secret_must_be_long_enough'; + + async function signSlack(body: string, timestamp: string) { + return `v0=${await hmacSign(signingSecret, `v0:${timestamp}:${body}`)}`; + } + + it('accepts a correctly signed Slack event', async () => { + const channel = fakeChannel({ + kind: 'slack', + config_json: JSON.stringify({ bot_token: 'xoxb-...', signing_secret: signingSecret }), + }); + const ts = String(Math.floor(Date.now() / 1000)); + const body = JSON.stringify({ type: 'event_callback' }); + const sig = await signSlack(body, ts); + const verified = await slackAdapter.verifyWebhook( + {} as never, + channel, + { 'x-slack-request-timestamp': ts, 'x-slack-signature': sig }, + body, + ); + expect(verified.ok).toBe(true); + }); + + it('rejects a stale timestamp even if the signature is otherwise valid', async () => { + const channel = fakeChannel({ + kind: 'slack', + config_json: JSON.stringify({ bot_token: 'xoxb-...', signing_secret: signingSecret }), + }); + const ts = String(Math.floor(Date.now() / 1000) - 60 * 60); // an hour ago + const body = JSON.stringify({ type: 'event_callback' }); + const sig = await signSlack(body, ts); + const verified = await slackAdapter.verifyWebhook( + {} as never, + channel, + { 'x-slack-request-timestamp': ts, 'x-slack-signature': sig }, + body, + ); + expect(verified.ok).toBe(false); + }); + + it('rejects when signing secret does not match', async () => { + const channel = fakeChannel({ + kind: 'slack', + config_json: JSON.stringify({ bot_token: 'xoxb-...', signing_secret: signingSecret }), + }); + const ts = String(Math.floor(Date.now() / 1000)); + const body = JSON.stringify({ type: 'event_callback' }); + const sig = `v0=${await hmacSign('different_secret', `v0:${ts}:${body}`)}`; + const verified = await slackAdapter.verifyWebhook( + {} as never, + channel, + { 'x-slack-request-timestamp': ts, 'x-slack-signature': sig }, + body, + ); + expect(verified.ok).toBe(false); + }); + + it('returns a url_verification PONG when challenge is sent', async () => { + const channel = fakeChannel({ kind: 'slack', config_json: '{}' }); + const request = new Request('https://example.com/webhook', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'url_verification', challenge: 'abc123' }), + }); + const response = await slackAdapter.handleChallenge!({} as never, channel, request); + expect(response).not.toBeNull(); + expect(await response!.text()).toBe('abc123'); + }); +}); + +describe('telegram adapter ingress', () => { + it('parses a text message into an inbound payload with chat thread', async () => { + const channel = fakeChannel({ + kind: 'telegram', + config_json: JSON.stringify({ + bot_token: '12345:ABCDEFGHIJKLMNOPQRSTUVWX', + webhook_url: 'https://example.com/wh', + secret_token: 'tg_secret_token_long_enough', + }), + }); + const update = JSON.stringify({ + message: { + message_id: 17, + date: 1700000000, + chat: { id: 555, type: 'private' }, + from: { id: 100, first_name: 'Ada', username: 'ada' }, + text: 'My order never arrived', + }, + }); + const parsed = await telegramAdapter.parseIngress({} as never, channel, {}, update); + expect(parsed).not.toBeNull(); + expect(parsed!.externalThreadId).toBe('555'); + expect(parsed!.from.externalId).toBe('100'); + expect(parsed!.from.displayName).toBe('Ada'); + expect(parsed!.text).toBe('My order never arrived'); + }); + + it('rejects ingress when secret token header is missing', async () => { + const channel = fakeChannel({ + kind: 'telegram', + config_json: JSON.stringify({ + bot_token: '12345:ABCDEFGHIJKLMNOPQRSTUVWX', + webhook_url: 'https://example.com/wh', + secret_token: 'tg_secret_token_long_enough', + }), + }); + const result = await telegramAdapter.verifyWebhook({} as never, channel, {}, ''); + expect(result.ok).toBe(false); + }); +}); + +describe('whatsapp adapter ingress', () => { + it('ignores events for sibling phone numbers on the same Meta app', async () => { + const channel = fakeChannel({ + kind: 'whatsapp', + config_json: JSON.stringify({ + phone_number_id: '111', + app_secret: 'app_secret_long_enough', + access_token: 'access_token_long_enough', + verify_token: 'verify_token_long', + }), + }); + const payload = JSON.stringify({ + entry: [ + { + changes: [ + { + field: 'messages', + value: { + metadata: { phone_number_id: '222' }, // different number + messages: [{ id: 'm1', from: '15550000001', text: { body: 'hello' } }], + }, + }, + ], + }, + ], + }); + const parsed = await whatsappAdapter.parseIngress({} as never, channel, {}, payload); + expect(parsed).toBeNull(); + }); + + it('parses a text message addressed to the configured phone number', async () => { + const channel = fakeChannel({ + kind: 'whatsapp', + config_json: JSON.stringify({ + phone_number_id: '111', + app_secret: 'app_secret_long_enough', + access_token: 'access_token_long_enough', + verify_token: 'verify_token_long', + }), + }); + const payload = JSON.stringify({ + entry: [ + { + changes: [ + { + field: 'messages', + value: { + metadata: { phone_number_id: '111' }, + contacts: [{ profile: { name: 'Grace' } }], + messages: [{ id: 'm2', from: '15550000002', text: { body: 'need help' } }], + }, + }, + ], + }, + ], + }); + const parsed = await whatsappAdapter.parseIngress({} as never, channel, {}, payload); + expect(parsed).not.toBeNull(); + expect(parsed!.from.phone).toBe('15550000002'); + expect(parsed!.from.displayName).toBe('Grace'); + expect(parsed!.text).toBe('need help'); + }); +}); + +describe('sms adapter signature verification', () => { + it('verifies a Twilio signature over the webhook URL plus sorted params', async () => { + const channel = fakeChannel({ + kind: 'sms', + config_json: JSON.stringify({ + provider: 'twilio', + account_sid: 'ACxxxx', + auth_token: 'a_long_enough_auth_token_value', + from_number: '+15551234567', + webhook_url: 'https://support.example.com/public/channels/pub_test/webhook', + }), + }); + const rawBody = 'Body=hello&From=%2B15550001111&MessageSid=SM1&To=%2B15551234567'; + const signature = await computeTwilioSignature( + 'a_long_enough_auth_token_value', + 'https://support.example.com/public/channels/pub_test/webhook', + parseFormPairs(rawBody), + ); + const verified = await smsAdapter.verifyWebhook( + {} as never, + channel, + { 'x-twilio-signature': signature }, + rawBody, + ); + expect(verified.ok).toBe(true); + }); +}); + +describe('identity stitching', () => { + it('reuses the same customer when an email matches across two channels', async () => { + const { env } = await seedSetup(); + const first = await resolveCustomerIdentity(env as never, { + workspaceId: 'ws_a', + channelKind: 'sms', + externalId: '+15550001111', + phone: '+15550001111', + email: 'ada@example.com', + }); + const second = await resolveCustomerIdentity(env as never, { + workspaceId: 'ws_a', + channelKind: 'slack', + externalId: 'T1:U1', + email: 'ada@example.com', + }); + expect(second.customerId).toBe(first.customerId); + }); + + it('creates separate customers when there is no shared contact', async () => { + const { env } = await seedSetup(); + const a = await resolveCustomerIdentity(env as never, { + workspaceId: 'ws_a', + channelKind: 'sms', + externalId: '+15550000001', + phone: '+15550000001', + }); + const b = await resolveCustomerIdentity(env as never, { + workspaceId: 'ws_a', + channelKind: 'sms', + externalId: '+15550000002', + phone: '+15550000002', + }); + expect(a.customerId).not.toBe(b.customerId); + }); +}); + +describe('ingress pipeline', () => { + it('opens a new ticket the first time a customer messages on a channel', async () => { + const { db, env } = await seedSetup(); + db.prepare( + `INSERT INTO public_channel (id, workspace_id, mailbox_id, kind, name, public_key, enabled, require_email, allowed_origins_json, welcome_message, config_json, secret_ciphertext, signing_secret, sla_first_response_minutes, sla_resolution_minutes, default_priority, default_assignee_user_id, last_event_at, created_at, updated_at) + VALUES ('pubch_slack', 'ws_a', 'mb_a', 'slack', 'Slack', 'pub_slack', 1, 0, '[]', NULL, '{}', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 1, 1)`, + ).run(); + const channel = fakeChannel({ + id: 'pubch_slack', + kind: 'slack', + public_key: 'pub_slack', + mailbox_address: 'support@example.com', + }); + const result = await ingestInboundMessage(env as never, channel, { + externalId: 'slack:msg:1', + externalThreadId: 'C1:1700000000.000100', + text: 'hello team', + from: { externalId: 'T1:U2', displayName: 'Grace' }, + receivedAt: Date.now(), + }); + expect(result).not.toBeNull(); + expect(result!.isNewTicket).toBe(true); + const ticket = db + .prepare(`SELECT origin_channel_kind, customer_id FROM ticket WHERE id = ?`) + .get(result!.ticketId) as { origin_channel_kind: string; customer_id: string }; + expect(ticket.origin_channel_kind).toBe('slack'); + expect(ticket.customer_id).toBeTruthy(); + }); + + it('dedupes a retried webhook with the same external message id', async () => { + const { db, env } = await seedSetup(); + db.prepare( + `INSERT INTO public_channel (id, workspace_id, mailbox_id, kind, name, public_key, enabled, require_email, allowed_origins_json, welcome_message, config_json, secret_ciphertext, signing_secret, sla_first_response_minutes, sla_resolution_minutes, default_priority, default_assignee_user_id, last_event_at, created_at, updated_at) + VALUES ('pubch_sms', 'ws_a', 'mb_a', 'sms', 'SMS', 'pub_sms', 1, 0, '[]', NULL, '{}', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 1, 1)`, + ).run(); + const channel = fakeChannel({ + id: 'pubch_sms', + kind: 'sms', + public_key: 'pub_sms', + mailbox_address: 'support@example.com', + }); + const payload = { + externalId: 'SMS1', + externalThreadId: '+15550009999', + text: 'first message', + from: { externalId: '+15550009999', phone: '+15550009999' }, + receivedAt: Date.now(), + }; + const first = await ingestInboundMessage(env as never, channel, payload); + const second = await ingestInboundMessage(env as never, channel, payload); + expect(first).not.toBeNull(); + expect(second).toBeNull(); + const rows = db + .prepare( + `SELECT COUNT(*) AS n FROM message_index WHERE rfc_message_id = 'sms:pubch_sms:SMS1'`, + ) + .get() as { n: number }; + expect(rows.n).toBe(1); + }); +}); + +async function computeTwilioSignature( + authToken: string, + webhookUrl: string, + params: Record, +): Promise { + const sorted = Object.keys(params).sort(); + const message = webhookUrl + sorted.map((k) => `${k}${params[k]}`).join(''); + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(authToken), + { name: 'HMAC', hash: 'SHA-1' }, + false, + ['sign'], + ); + const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message)); + let binary = ''; + for (const b of new Uint8Array(sig)) binary += String.fromCharCode(b); + return btoa(binary); +} + +function parseFormPairs(body: string): Record { + const out: Record = {}; + for (const pair of body.split('&')) { + const idx = pair.indexOf('='); + out[decodeURIComponent(pair.slice(0, idx))] = decodeURIComponent( + pair.slice(idx + 1).replace(/\+/g, ' '), + ); + } + return out; +} diff --git a/tests/helpers/workspace-db.ts b/tests/helpers/workspace-db.ts index d339653..6f53918 100644 --- a/tests/helpers/workspace-db.ts +++ b/tests/helpers/workspace-db.ts @@ -86,6 +86,9 @@ export function createWorkspaceTestDb() { first_message_id TEXT, thread_token TEXT NOT NULL, ai_drafts_enabled INTEGER, + origin_channel_kind TEXT NOT NULL DEFAULT 'email', + origin_channel_id TEXT, + customer_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -215,9 +218,186 @@ export function createWorkspaceTestDb() { require_email INTEGER NOT NULL DEFAULT 1, allowed_origins_json TEXT NOT NULL DEFAULT '[]', welcome_message TEXT, + config_json TEXT NOT NULL DEFAULT '{}', + secrets_ciphertext TEXT, + secret_ciphertext TEXT, + signing_secret TEXT, + sla_first_response_minutes INTEGER, + sla_resolution_minutes INTEGER, + default_priority TEXT, + default_assignee_user_id TEXT, + last_event_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE customer ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + display_name TEXT, + primary_email TEXT, + primary_phone TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE channel_identity ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + customer_id TEXT NOT NULL, + channel_kind TEXT NOT NULL, + external_id TEXT NOT NULL, + display_name TEXT, + email TEXT, + phone TEXT, + first_seen_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + UNIQUE(workspace_id, channel_kind, external_id) + ); + CREATE TABLE channel_outbound_dispatch ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + message_id TEXT NOT NULL, + channel_kind TEXT NOT NULL, + channel_id TEXT, + status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + external_id TEXT, + next_attempt_at INTEGER, + max_attempts INTEGER NOT NULL DEFAULT 5, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE customer_channel_preference ( + workspace_id TEXT NOT NULL, + customer_id TEXT NOT NULL, + channel_kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'enabled', + quiet_hours_start_minutes INTEGER, + quiet_hours_end_minutes INTEGER, + timezone TEXT, + consent_source TEXT, + consent_at INTEGER, + updated_at INTEGER NOT NULL, + PRIMARY KEY (workspace_id, customer_id, channel_kind) + ); + CREATE TABLE notification_template ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + slug TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + default_channels_json TEXT NOT NULL DEFAULT '[]', + bodies_json TEXT NOT NULL DEFAULT '{}', + metadata_json TEXT NOT NULL DEFAULT '{}', + archived_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (workspace_id, slug) + ); + CREATE TABLE notification_plan ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + customer_id TEXT NOT NULL, + ticket_id TEXT, + template_id TEXT, + template_slug TEXT, + urgency TEXT NOT NULL DEFAULT 'normal', + status TEXT NOT NULL DEFAULT 'pending', + payload_json TEXT NOT NULL DEFAULT '{}', + acknowledged_at INTEGER, + completed_at INTEGER, + cancelled_reason TEXT, + created_by_user_id TEXT, + source TEXT NOT NULL DEFAULT 'api', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); + CREATE TABLE notification_step ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + plan_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + channel_kind TEXT NOT NULL, + channel_id TEXT, + trigger_on TEXT NOT NULL DEFAULT 'immediate', + delay_ms INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + scheduled_at INTEGER, + attempted_at INTEGER, + delivered_at INTEGER, + read_at INTEGER, + acknowledged_at INTEGER, + external_id TEXT, + last_error TEXT, + body_text TEXT, + body_html TEXT, + body_json TEXT, + UNIQUE (plan_id, sequence) + ); + CREATE TABLE notification_delivery_event ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + step_id TEXT NOT NULL, + kind TEXT NOT NULL, + occurred_at INTEGER NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE voice_call ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + customer_id TEXT, + provider TEXT NOT NULL, + external_call_id TEXT NOT NULL, + caller_number TEXT, + callee_number TEXT, + direction TEXT NOT NULL DEFAULT 'inbound', + status TEXT NOT NULL DEFAULT 'ringing', + agent_mode TEXT NOT NULL DEFAULT 'autonomous', + started_at INTEGER NOT NULL, + connected_at INTEGER, + ended_at INTEGER, + duration_ms INTEGER, + recording_r2_key TEXT, + transcript_r2_key TEXT, + summary TEXT, + error TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(workspace_id, channel_id, external_call_id) + ); + CREATE TABLE voice_call_turn ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + call_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + message_id TEXT, + sequence INTEGER NOT NULL, + role TEXT NOT NULL, + text TEXT, + audio_r2_key TEXT, + duration_ms INTEGER, + model TEXT, + confidence REAL, + interrupted INTEGER NOT NULL DEFAULT 0, + started_at INTEGER NOT NULL, + completed_at INTEGER, + metadata_json TEXT NOT NULL DEFAULT '{}', + UNIQUE(call_id, sequence) + ); + CREATE TABLE voice_provider_event ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + call_id TEXT, + channel_id TEXT NOT NULL, + provider TEXT NOT NULL, + event_type TEXT NOT NULL, + payload_r2_key TEXT, + received_at INTEGER NOT NULL + ); CREATE TABLE public_conversation_session ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, @@ -483,6 +663,7 @@ export function createWorkspaceTestDb() { env: { DB: envDb, COOKIE_SIGNING_KEY: 'test-secret', + SECRET_ENCRYPTION_KEY: 'test-secret-encryption-key-32bytes!!', BLOB: { put: async (key: string, body: string | ArrayBuffer | Uint8Array) => { const bytes = diff --git a/tests/new-adapters.test.ts b/tests/new-adapters.test.ts new file mode 100644 index 0000000..6a69c59 --- /dev/null +++ b/tests/new-adapters.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, vi } from 'vitest'; +import '../src/channels'; // adapter side-effect registration +import { appleBusinessAdapter } from '../src/channels/adapters/apple-business'; +import { instagramAdapter } from '../src/channels/adapters/instagram'; +import { messengerAdapter } from '../src/channels/adapters/messenger'; +import { rcsAdapter } from '../src/channels/adapters/rcs'; +import { teamsAdapter } from '../src/channels/adapters/teams'; +import { webhookAdapter } from '../src/channels/adapters/webhook'; +import { hmacSign } from '../src/lib/crypto'; +import type { PublicChannel } from '../src/types/channels'; + +vi.mock('agents', () => ({ + getAgentByName: () => ({ start: async () => undefined, resume: async () => undefined }), + Agent: class {}, + callable: () => () => undefined, + routeAgentRequest: () => null, +})); + +function channelWith(kind: string, config: Record): PublicChannel { + return { + id: 'pubch_x', + workspace_id: 'ws_a', + mailbox_id: 'mb_a', + mailbox_address: 'support@example.com', + kind: kind as never, + name: 'X', + public_key: 'pub_x', + enabled: 1, + require_email: 0, + allowed_origins_json: '[]', + welcome_message: null, + config_json: JSON.stringify(config), + secrets_ciphertext: null, + secret_ciphertext: null, + signing_secret: null, + sla_first_response_minutes: null, + sla_resolution_minutes: null, + default_priority: null, + default_assignee_user_id: null, + last_event_at: null, + created_at: 1, + updated_at: 1, + }; +} + +describe('webhook adapter', () => { + const shared = 'a_shared_secret_long_enough_for_use'; + + it('rejects misconfigured endpoint URLs', () => { + expect(() => webhookAdapter.validateConfig({ endpoint_url: 'not-a-url' })).toThrowError( + /endpoint_url_required/, + ); + }); + + it('verifies a correctly signed inbound webhook', async () => { + const channel = channelWith('webhook', { endpoint_url: 'https://x/wh', shared_secret: shared }); + const body = JSON.stringify({ + external_id: 'm1', + text: 'hello', + from: { external_id: 'u1' }, + }); + const sig = `sha256=${await hmacSign(shared, body)}`; + const result = await webhookAdapter.verifyWebhook( + {} as never, + channel, + { 'x-ranse-signature': sig }, + body, + ); + expect(result.ok).toBe(true); + }); + + it('parses inbound payloads into IngressMessage', async () => { + const channel = channelWith('webhook', { endpoint_url: 'https://x/wh', shared_secret: shared }); + const body = JSON.stringify({ + external_id: 'm2', + external_thread_id: 'thread_a', + text: 'help', + from: { external_id: 'u2', display_name: 'Cary' }, + }); + const parsed = await webhookAdapter.parseIngress({} as never, channel, {}, body); + expect(parsed).not.toBeNull(); + expect(parsed!.text).toBe('help'); + expect(parsed!.from.displayName).toBe('Cary'); + expect(parsed!.externalThreadId).toBe('thread_a'); + }); +}); + +describe('teams adapter', () => { + it('rejects malformed app ids', () => { + expect(() => + teamsAdapter.validateConfig({ app_id: 'not-a-guid', app_password: 'x'.repeat(20) }), + ).toThrowError(/app_id_required/); + }); + + it('parses an inbound activity into IngressMessage', async () => { + const channel = channelWith('teams', { + app_id: '00000000-0000-0000-0000-000000000001', + app_password: 'x'.repeat(20), + inbound_secret: 'y'.repeat(20), + }); + const body = JSON.stringify({ + type: 'message', + id: 'a1', + text: 'Hi from Teams', + serviceUrl: 'https://smba.trafficmanager.net/amer/', + conversation: { id: 'conv_1' }, + from: { id: 'user_1', name: 'Diana' }, + }); + const parsed = await teamsAdapter.parseIngress({} as never, channel, {}, body); + expect(parsed).not.toBeNull(); + expect(parsed!.text).toBe('Hi from Teams'); + expect(parsed!.externalThreadId).toContain('conv_1'); + }); +}); + +describe('messenger adapter', () => { + it('rejects events for the wrong page', async () => { + const channel = channelWith('messenger', { + page_id: '999', + app_secret: 'x'.repeat(20), + access_token: 'y'.repeat(20), + verify_token: 'verify123', + }); + const body = JSON.stringify({ + object: 'page', + entry: [ + { + id: '111', + messaging: [{ sender: { id: 'u' }, message: { mid: 'm', text: 'hi' } }], + }, + ], + }); + const parsed = await messengerAdapter.parseIngress({} as never, channel, {}, body); + expect(parsed).toBeNull(); + }); + + it('parses a matching page event', async () => { + const channel = channelWith('messenger', { + page_id: '999', + app_secret: 'x'.repeat(20), + access_token: 'y'.repeat(20), + verify_token: 'verify123', + }); + const body = JSON.stringify({ + object: 'page', + entry: [ + { + id: '999', + messaging: [ + { sender: { id: 'u9' }, timestamp: 1700, message: { mid: 'mm', text: 'hello' } }, + ], + }, + ], + }); + const parsed = await messengerAdapter.parseIngress({} as never, channel, {}, body); + expect(parsed?.text).toBe('hello'); + expect(parsed?.externalThreadId).toBe('u9'); + }); +}); + +describe('instagram adapter', () => { + it('only ingests instagram object events', async () => { + const channel = channelWith('instagram', { + ig_id: '12345', + app_secret: 'x'.repeat(20), + access_token: 'y'.repeat(20), + verify_token: 'verify123', + }); + const wrong = JSON.stringify({ + object: 'page', + entry: [ + { id: '12345', messaging: [{ sender: { id: 'a' }, message: { mid: 'b', text: 'hi' } }] }, + ], + }); + const right = JSON.stringify({ + object: 'instagram', + entry: [ + { id: '12345', messaging: [{ sender: { id: 'a' }, message: { mid: 'b', text: 'hi' } }] }, + ], + }); + expect(await instagramAdapter.parseIngress({} as never, channel, {}, wrong)).toBeNull(); + expect((await instagramAdapter.parseIngress({} as never, channel, {}, right))?.text).toBe('hi'); + }); +}); + +describe('rcs adapter', () => { + it('verifies inbound HMAC signature', async () => { + const channel = channelWith('rcs', { + agent_id: 'brands/1/agents/2', + partner_secret: 'x'.repeat(20), + oauth_token: 'y'.repeat(20), + webhook_url: 'https://x/wh', + }); + const body = JSON.stringify({ conversationId: 'c', message: { text: 'hi' } }); + const sig = await hmacSign('x'.repeat(20), body); + const result = await rcsAdapter.verifyWebhook( + {} as never, + channel, + { 'x-goog-signature': sig }, + body, + ); + expect(result.ok).toBe(true); + }); +}); + +describe('apple business adapter', () => { + it('accepts a matching webhook_secret header', async () => { + const channel = channelWith('apple_business', { + business_id: 'biz_x', + msp_id: 'msp_x', + source_id: 'src_x', + webhook_secret: 'shared_apple_secret_long_value', + bearer_token: 'y'.repeat(20), + }); + const result = await appleBusinessAdapter.verifyWebhook( + {} as never, + channel, + { 'x-apple-webhook-secret': 'shared_apple_secret_long_value' }, + '{}', + ); + expect(result.ok).toBe(true); + }); + + it('also accepts an HMAC of the body', async () => { + const channel = channelWith('apple_business', { + business_id: 'biz_x', + msp_id: 'msp_x', + source_id: 'src_x', + webhook_secret: 'shared_apple_secret_long_value', + bearer_token: 'y'.repeat(20), + }); + const body = '{"type":"text","id":"a","sourceId":"u","body":{"body":"hi"}}'; + const sig = await hmacSign('shared_apple_secret_long_value', body); + const result = await appleBusinessAdapter.verifyWebhook( + {} as never, + channel, + { 'x-apple-webhook-secret': sig }, + body, + ); + expect(result.ok).toBe(true); + }); +}); diff --git a/tests/notifications-secrets.test.ts b/tests/notifications-secrets.test.ts new file mode 100644 index 0000000..73f5432 --- /dev/null +++ b/tests/notifications-secrets.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from 'vitest'; +// Importing channels/index triggers built-in adapter registration. The +// dispatcher resolves adapters from that registry, so this import is +// required even though we don't use its exports directly. +import '../src/channels'; +import { dispatchOutbound, retryBackoffMs } from '../src/channels/egress'; +import { isSealedString, openJson, partitionSecrets, sealJson } from '../src/lib/secrets'; +import { notifyCustomer } from '../src/notifications/cascade'; +import { upsertTemplate } from '../src/notifications/cascade/templates'; +import { canDeliverTo, setPreference } from '../src/notifications/preferences'; +import { + addMember, + createWorkspaceTestDb, + seedMailbox, + seedUser, + seedWorkspace, +} from './helpers/workspace-db'; + +vi.mock('agents', () => ({ + getAgentByName: () => ({ start: async () => undefined, resume: async () => undefined }), + Agent: class {}, + callable: () => () => undefined, + routeAgentRequest: () => null, +})); + +async function setup() { + const { db, env } = createWorkspaceTestDb(); + await seedUser(db, 'owner', 'owner@example.com'); + seedWorkspace(db, 'ws_a', 'Alpha'); + addMember(db, 'ws_a', 'owner', 'owner'); + seedMailbox(db, 'ws_a', 'mb_a', 'support@example.com'); + db.prepare( + `INSERT INTO customer (id, workspace_id, display_name, primary_email, primary_phone, created_at, updated_at) + VALUES ('cust_a', 'ws_a', 'Ada', 'ada@example.com', '+15551234567', 1, 1)`, + ).run(); + return { db, env }; +} + +describe('secret encryption at rest', () => { + it('round-trips an object through seal/open', async () => { + const { env } = await setup(); + const sealed = await sealJson(env as never, 'ws_a', { + bot_token: 'xoxb-abc', + signing_secret: 'shhh', + }); + expect(sealed).not.toBeNull(); + expect(isSealedString(sealed!)).toBe(true); + const opened = await openJson<{ bot_token: string; signing_secret: string }>( + env as never, + 'ws_a', + sealed, + ); + expect(opened.bot_token).toBe('xoxb-abc'); + expect(opened.signing_secret).toBe('shhh'); + }); + + it('returns plaintext untouched when the value is not sealed', async () => { + const { env } = await setup(); + const opened = await openJson(env as never, 'ws_a', '{"foo":"bar"}'); + expect(opened).toEqual({ foo: 'bar' }); + }); + + it('fails to decrypt with a different workspace id', async () => { + const { env } = await setup(); + const sealed = await sealJson(env as never, 'ws_a', { secret: 'top' }); + await expect(openJson(env as never, 'ws_other', sealed)).rejects.toThrow(); + }); + + it('partitionSecrets separates declared secret fields', () => { + const { publicConfig, secrets } = partitionSecrets( + { signing_secret: 'shhh', team_id: 'T1', bot_token: 'xoxb' }, + ['signing_secret', 'bot_token'], + ); + expect(publicConfig).toEqual({ team_id: 'T1' }); + expect(secrets).toEqual({ signing_secret: 'shhh', bot_token: 'xoxb' }); + }); +}); + +describe('channel preferences', () => { + it('blocks delivery when the customer has opted out', async () => { + const { env } = await setup(); + await setPreference(env as never, { + workspaceId: 'ws_a', + customerId: 'cust_a', + channelKind: 'sms', + status: 'disabled', + consentSource: 'unit-test', + }); + const result = await canDeliverTo(env as never, { + workspaceId: 'ws_a', + customerId: 'cust_a', + channelKind: 'sms', + }); + expect(result.allowed).toBe(false); + expect(result.reason).toBe('opted_out'); + }); + + it('allows delivery when no preference row exists', async () => { + const { env } = await setup(); + const result = await canDeliverTo(env as never, { + workspaceId: 'ws_a', + customerId: 'cust_a', + channelKind: 'sms', + }); + expect(result.allowed).toBe(true); + }); + + it('outbound dispatcher records preference_opted_out as the failure reason', async () => { + const { db, env } = await setup(); + await setPreference(env as never, { + workspaceId: 'ws_a', + customerId: 'cust_a', + channelKind: 'sms', + status: 'disabled', + consentSource: 'inbound_keyword:stop', + }); + db.prepare( + `INSERT INTO public_channel (id, workspace_id, mailbox_id, kind, name, public_key, enabled, require_email, allowed_origins_json, welcome_message, config_json, secrets_ciphertext, secret_ciphertext, signing_secret, sla_first_response_minutes, sla_resolution_minutes, default_priority, default_assignee_user_id, last_event_at, created_at, updated_at) + VALUES ('pubch_sms', 'ws_a', 'mb_a', 'sms', 'SMS', 'pub_sms', 1, 0, '[]', NULL, '{}', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 1, 1)`, + ).run(); + db.prepare( + `INSERT INTO ticket (id, workspace_id, mailbox_id, subject, status, priority, requester_email, last_message_at, thread_token, customer_id, origin_channel_kind, origin_channel_id, created_at, updated_at) + VALUES ('tkt_a', 'ws_a', 'mb_a', 'SMS', 'open', 'normal', '+15551234567', 1, 'thread_a', 'cust_a', 'sms', 'pubch_sms', 1, 1)`, + ).run(); + const outcome = await dispatchOutbound(env as never, { + workspaceId: 'ws_a', + ticketId: 'tkt_a', + messageId: 'msg_dispatch', + text: 'hello', + }); + expect(outcome.status).toBe('failed'); + expect(outcome.error).toBe('preference_opted_out'); + const dispatchRow = db + .prepare(`SELECT status, last_error, next_attempt_at FROM channel_outbound_dispatch`) + .get() as { status: string; last_error: string; next_attempt_at: number | null }; + // Preference-blocked dispatches must not auto-retry. + expect(dispatchRow.status).toBe('failed'); + expect(dispatchRow.next_attempt_at).toBeNull(); + }); +}); + +describe('cascade engine', () => { + it('materializes a multi-step plan from a template', async () => { + const { db, env } = await setup(); + db.prepare( + `INSERT INTO public_channel (id, workspace_id, mailbox_id, kind, name, public_key, enabled, require_email, allowed_origins_json, welcome_message, config_json, secrets_ciphertext, secret_ciphertext, signing_secret, sla_first_response_minutes, sla_resolution_minutes, default_priority, default_assignee_user_id, last_event_at, created_at, updated_at) + VALUES ('pubch_sms', 'ws_a', 'mb_a', 'sms', 'SMS', 'pub_sms', 1, 0, '[]', NULL, '{}', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 1, 1)`, + ).run(); + await upsertTemplate(env as never, { + workspaceId: 'ws_a', + slug: 'shipping-update', + name: 'Shipping update', + defaultChannels: [ + { channelKind: 'sms', triggerOn: 'immediate' }, + { channelKind: 'email', triggerOn: 'previous_no_ack', delayMs: 60 * 60_000 }, + ], + bodies: { + sms: { text: 'Your order {{ payload.order }} shipped.' }, + email: { text: 'Hello {{ payload.name }}, your order {{ payload.order }} shipped.' }, + }, + }); + const { planId, stepCount } = await notifyCustomer(env as never, { + workspaceId: 'ws_a', + customerId: 'cust_a', + templateSlug: 'shipping-update', + payload: { order: '#1001', name: 'Ada' }, + urgency: 'normal', + }); + expect(stepCount).toBe(2); + const steps = db + .prepare( + `SELECT sequence, channel_kind, body_text, trigger_on FROM notification_step ORDER BY sequence`, + ) + .all() as { sequence: number; channel_kind: string; body_text: string; trigger_on: string }[]; + expect(steps).toHaveLength(2); + expect(steps[0].channel_kind).toBe('sms'); + expect(steps[0].body_text).toBe('Your order #1001 shipped.'); + expect(steps[1].channel_kind).toBe('email'); + expect(steps[1].body_text).toContain('Ada'); + expect(steps[1].trigger_on).toBe('previous_no_ack'); + expect(planId).toMatch(/^nplan_/); + }); +}); + +describe('dispatch retry backoff', () => { + it('grows roughly exponentially across attempts', () => { + const a = retryBackoffMs(1); + const b = retryBackoffMs(2); + const c = retryBackoffMs(3); + const d = retryBackoffMs(5); + // Each subsequent attempt is at least 3x the previous (with jitter). + expect(b).toBeGreaterThan(a * 2); + expect(c).toBeGreaterThan(b * 2); + expect(d).toBeGreaterThan(c); + }); +}); diff --git a/tests/procedure-library.test.ts b/tests/procedure-library.test.ts index 0f749a3..d2a96b5 100644 --- a/tests/procedure-library.test.ts +++ b/tests/procedure-library.test.ts @@ -29,6 +29,7 @@ describe('procedure library', () => { expect(entries.map((entry) => entry.slug)).toEqual([ 'refund-intake', + 'verify-identity-channel-aware', 'password-reset', 'shipping-dispute', 'gdpr-data-request', diff --git a/tests/voice.test.ts b/tests/voice.test.ts new file mode 100644 index 0000000..ca0d230 --- /dev/null +++ b/tests/voice.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + ensureVoiceProvidersRegistered, + listVoiceProviders, + tryGetVoiceProvider, + voiceProviderConfigFor, +} from '../src/channels/voice'; +import { voiceAdapter } from '../src/channels/voice/adapter'; +import { applyVoiceEvents } from '../src/channels/voice/ingest'; +import { elevenlabsVoiceProvider } from '../src/channels/voice/providers/elevenlabs'; +import { twilioRealtimeVoiceProvider } from '../src/channels/voice/providers/twilio-realtime'; +import { hmacSign } from '../src/lib/crypto'; +import type { PublicChannel } from '../src/types/channels'; +import { + addMember, + createWorkspaceTestDb, + seedMailbox, + seedUser, + seedWorkspace, +} from './helpers/workspace-db'; + +vi.mock('agents', () => ({ + getAgentByName: () => ({ start: async () => undefined, resume: async () => undefined }), + Agent: class {}, + callable: () => () => undefined, + routeAgentRequest: () => null, +})); + +ensureVoiceProvidersRegistered(); + +function voiceChannel(config: Record): PublicChannel { + return { + id: 'pubch_voice', + workspace_id: 'ws_a', + mailbox_id: 'mb_a', + mailbox_address: 'support@example.com', + kind: 'voice', + name: 'Voice line', + public_key: 'pub_voice', + enabled: 1, + require_email: 0, + allowed_origins_json: '[]', + welcome_message: null, + config_json: JSON.stringify(config), + secrets_ciphertext: null, + secret_ciphertext: null, + signing_secret: null, + sla_first_response_minutes: null, + sla_resolution_minutes: null, + default_priority: null, + default_assignee_user_id: null, + last_event_at: null, + created_at: 1, + updated_at: 1, + }; +} + +async function seedSetup() { + const { db, env } = createWorkspaceTestDb(); + await seedUser(db, 'owner', 'owner@example.com'); + seedWorkspace(db, 'ws_a', 'Alpha'); + addMember(db, 'ws_a', 'owner', 'owner'); + seedMailbox(db, 'ws_a', 'mb_a', 'support@example.com'); + db.prepare( + `INSERT INTO public_channel ( + id, workspace_id, mailbox_id, kind, name, public_key, enabled, + require_email, allowed_origins_json, welcome_message, config_json, + secret_ciphertext, signing_secret, sla_first_response_minutes, + sla_resolution_minutes, default_priority, default_assignee_user_id, + last_event_at, created_at, updated_at + ) VALUES ('pubch_voice', 'ws_a', 'mb_a', 'voice', 'Voice', 'pub_voice', 1, 0, '[]', NULL, + '{"provider":"elevenlabs"}', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 1, 1)`, + ).run(); + return { db, env }; +} + +describe('voice provider registry', () => { + it('registers every shipped voice provider exactly once', () => { + expect( + listVoiceProviders() + .map((p) => p.kind) + .sort(), + ).toEqual(['elevenlabs', 'gemini_live', 'twilio_realtime']); + }); + + it('rejects unknown voice providers via the adapter', () => { + expect(() => voiceAdapter.validateConfig({ provider: 'not-real' })).toThrowError( + /voice_provider/, + ); + }); + + it('validates per-provider config through the adapter', () => { + expect(() => voiceAdapter.validateConfig({ provider: 'elevenlabs' })).toThrowError( + /agent_id_required/, + ); + const ok = voiceAdapter.validateConfig({ + provider: 'elevenlabs', + elevenlabs: { + agent_id: 'agent_abc', + webhook_secret: 'a_long_enough_webhook_secret_value', + api_key: 'a_long_enough_api_key_value', + }, + agent_mode: 'autonomous', + greeting: 'Hello, you have reached support.', + }); + expect(ok).toMatchObject({ provider: 'elevenlabs', agent_mode: 'autonomous' }); + }); + + it('voiceProviderConfigFor surfaces channel-level defaults', () => { + const channel = voiceChannel({ + provider: 'elevenlabs', + elevenlabs: { agent_id: 'a', webhook_secret: 'x'.repeat(20), api_key: 'y'.repeat(20) }, + language: 'fr-FR', + greeting: 'bonjour', + }); + const cfg = voiceProviderConfigFor(channel); + expect(cfg.provider).toBe('elevenlabs'); + expect(cfg.language).toBe('fr-FR'); + expect(cfg.greeting).toBe('bonjour'); + }); +}); + +describe('ElevenLabs post-call signature', () => { + const secret = 'an_elevenlabs_webhook_secret_value'; + + async function signed(rawBody: string, ts = Math.floor(Date.now() / 1000)) { + const sig = await hmacSign(secret, `${ts}.${rawBody}`); + return { ts, sig }; + } + + it('accepts a correctly signed post-call payload', async () => { + const channel = voiceChannel({ + provider: 'elevenlabs', + elevenlabs: { agent_id: 'agent_x', webhook_secret: secret, api_key: 'x'.repeat(20) }, + }); + const body = JSON.stringify({ + type: 'post_call_transcription', + data: { conversation_id: 'c1' }, + }); + const { ts, sig } = await signed(body); + const ok = await elevenlabsVoiceProvider.verifyEvent( + {} as never, + channel, + { 'elevenlabs-signature': `t=${ts},v0=${sig}` }, + body, + ); + expect(ok.ok).toBe(true); + }); + + it('rejects stale timestamps', async () => { + const channel = voiceChannel({ + provider: 'elevenlabs', + elevenlabs: { agent_id: 'agent_x', webhook_secret: secret, api_key: 'x'.repeat(20) }, + }); + const body = '{}'; + const stale = Math.floor(Date.now() / 1000) - 60 * 60; + const { sig } = await signed(body, stale); + const result = await elevenlabsVoiceProvider.verifyEvent( + {} as never, + channel, + { 'elevenlabs-signature': `t=${stale},v0=${sig}` }, + body, + ); + expect(result.ok).toBe(false); + }); + + it('parses transcript utterances into call_started + turn + call_ended', async () => { + const channel = voiceChannel({ + provider: 'elevenlabs', + elevenlabs: { agent_id: 'agent_x', webhook_secret: secret, api_key: 'x'.repeat(20) }, + }); + const body = JSON.stringify({ + type: 'post_call_transcription', + data: { + conversation_id: 'conv_1', + agent_id: 'agent_x', + status: 'done', + metadata: { start_time_unix_secs: 1700000000, call_duration_secs: 30 }, + transcript: [ + { role: 'agent', message: 'How can I help?', time_in_call_secs: 0 }, + { role: 'user', message: 'My order is missing.', time_in_call_secs: 4 }, + ], + analysis: { transcript_summary: 'Customer reports missing order.' }, + }, + }); + const events = await elevenlabsVoiceProvider.parseEvent( + { BLOB: { put: async () => undefined } } as never, + channel, + {}, + body, + ); + expect(events.map((e) => e.type)).toEqual(['call_started', 'turn', 'turn', 'call_ended']); + const ended = events[events.length - 1]; + expect(ended.type).toBe('call_ended'); + if (ended.type === 'call_ended') { + expect(ended.summary).toBe('Customer reports missing order.'); + expect(ended.durationMs).toBe(30_000); + } + }); +}); + +describe('Twilio realtime TwiML answer', () => { + it('returns a Connect/Stream TwiML pointing at the channel websocket URL', async () => { + const channel = voiceChannel({ + provider: 'twilio_realtime', + twilio_realtime: { + account_sid: 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxx', + auth_token: 'a_twilio_auth_token_long_enough', + phone_number: '+15551234567', + webhook_url: 'https://support.example.com/public/channels/pub_voice/webhook', + }, + }); + const request = new Request( + 'https://support.example.com/public/channels/pub_voice/webhook?answer=1', + { method: 'POST', body: '' }, + ); + const response = await twilioRealtimeVoiceProvider.answerCall!({} as never, channel, request); + expect(response).not.toBeNull(); + const xml = await response!.text(); + expect(xml).toContain( + ' { + const channel = voiceChannel({ + provider: 'twilio_realtime', + twilio_realtime: { + account_sid: 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxx', + auth_token: 'a_twilio_auth_token_long_enough', + phone_number: '+15551234567', + webhook_url: 'https://support.example.com/public/channels/pub_voice/webhook', + }, + }); + const rawBody = 'CallSid=CA1234&CallStatus=completed&CallDuration=42'; + const events = await twilioRealtimeVoiceProvider.parseEvent({} as never, channel, {}, rawBody); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'call_ended', + externalCallId: 'CA1234', + status: 'completed', + durationMs: 42_000, + }); + }); +}); + +describe('voice ingest end-to-end', () => { + it('opens a ticket on call_started, persists turns, and finalizes on call_ended', async () => { + const { db, env } = await seedSetup(); + const channel = voiceChannel({ + provider: 'elevenlabs', + elevenlabs: { agent_id: 'agent_x', webhook_secret: 'x'.repeat(20), api_key: 'y'.repeat(20) }, + }); + channel.id = 'pubch_voice'; + channel.public_key = 'pub_voice'; + + await applyVoiceEvents(env as never, channel, 'elevenlabs', [ + { + type: 'call_started', + externalCallId: 'conv_1', + callerNumber: '+15550009999', + calleeNumber: '+15551234567', + startedAt: 1700000000_000, + }, + { + type: 'turn', + externalCallId: 'conv_1', + sequence: 1, + role: 'caller', + text: 'My package never arrived.', + startedAt: 1700000005_000, + completedAt: 1700000006_000, + model: 'elevenlabs', + }, + { + type: 'turn', + externalCallId: 'conv_1', + sequence: 2, + role: 'agent', + text: 'Sorry to hear that — what is your order number?', + startedAt: 1700000007_000, + completedAt: 1700000008_000, + model: 'elevenlabs', + }, + { + type: 'call_ended', + externalCallId: 'conv_1', + status: 'completed', + endedAt: 1700000060_000, + durationMs: 60_000, + summary: 'Customer reports missing order.', + }, + ]); + + const call = db + .prepare( + `SELECT status, duration_ms, summary, caller_number FROM voice_call WHERE external_call_id = 'conv_1'`, + ) + .get() as { status: string; duration_ms: number; summary: string; caller_number: string }; + expect(call.status).toBe('completed'); + expect(call.duration_ms).toBe(60_000); + expect(call.summary).toBe('Customer reports missing order.'); + expect(call.caller_number).toBe('+15550009999'); + + const turns = db + .prepare(`SELECT sequence, role, text FROM voice_call_turn ORDER BY sequence`) + .all() as { sequence: number; role: string; text: string }[]; + expect(turns).toHaveLength(2); + expect(turns.map((t) => t.role)).toEqual(['caller', 'agent']); + + const tickets = db.prepare(`SELECT subject, origin_channel_kind FROM ticket`).all() as { + subject: string; + origin_channel_kind: string; + }[]; + expect(tickets).toHaveLength(1); + expect(tickets[0].origin_channel_kind).toBe('voice'); + expect(tickets[0].subject).toBe('Customer reports missing order.'); + }); + + it('looks up registered providers idempotently', () => { + expect(tryGetVoiceProvider('elevenlabs')).toBeDefined(); + expect(tryGetVoiceProvider('gemini_live')).toBeDefined(); + expect(tryGetVoiceProvider('twilio_realtime')).toBeDefined(); + }); +});