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..7b79b7f 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,167 @@ 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 `, + 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(',') + .map((item) => item.trim()) + .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`; +} + +function formUrl(channel: PublicChannelEntry): string { + return `${window.location.origin}/forms/${channel.public_key}`; +} + +function webhookUrl(channel: PublicChannelEntry): string { + return `${window.location.origin}/public/channels/${channel.public_key}/webhook`; +} 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/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/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 }, + SECRET_ENCRYPTION_KEY: 'test-secret-encryption-key-32bytes!!', + 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/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(); + }); +}); 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": [