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": [