@crewhaus/call-session 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +41 -0
- package/src/adapters/adapters.test.ts +106 -0
- package/src/adapters/livekit-sip.ts +81 -0
- package/src/adapters/twilio.ts +104 -0
- package/src/index.test.ts +106 -0
- package/src/index.ts +213 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/call-session",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Call lifecycle state machine — idle | dialing | connected | on-hold | transferred | terminated (Section 24 VOICE)",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/errors": "0.0.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Max Meier",
|
|
20
|
+
"email": "max@studiomax.io",
|
|
21
|
+
"url": "https://studiomax.io"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
26
|
+
"directory": "packages/call-session"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/call-session#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "restricted"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 30 — telephony adapter contract tests.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { CallSessionError } from "../index";
|
|
6
|
+
import { createLiveKitSipAdapter } from "./livekit-sip";
|
|
7
|
+
import { createTwilioTelephonyAdapter } from "./twilio";
|
|
8
|
+
|
|
9
|
+
describe("twilio telephony adapter", () => {
|
|
10
|
+
test("dial POSTs to Calls.json with from/to params", async () => {
|
|
11
|
+
let observedUrl = "";
|
|
12
|
+
let observedAuth = "";
|
|
13
|
+
let observedBody = "";
|
|
14
|
+
const fetchImpl = (async (url: string, init?: RequestInit) => {
|
|
15
|
+
observedUrl = url;
|
|
16
|
+
observedAuth = (init?.headers as Record<string, string>)?.Authorization ?? "";
|
|
17
|
+
observedBody = init?.body as string;
|
|
18
|
+
return new Response(JSON.stringify({ sid: "CA123" }), { status: 200 });
|
|
19
|
+
}) as unknown as typeof fetch;
|
|
20
|
+
const adapter = createTwilioTelephonyAdapter({
|
|
21
|
+
accountSid: "AC123",
|
|
22
|
+
authToken: "tok",
|
|
23
|
+
fromNumber: "+15551234567",
|
|
24
|
+
fetchImpl,
|
|
25
|
+
});
|
|
26
|
+
await adapter.dial("+15559876543");
|
|
27
|
+
expect(observedUrl).toContain("/Accounts/AC123/Calls.json");
|
|
28
|
+
expect(observedAuth.startsWith("Basic ")).toBe(true);
|
|
29
|
+
expect(observedBody).toContain("From=%2B15551234567");
|
|
30
|
+
expect(observedBody).toContain("To=%2B15559876543");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("hold/resume update the active call", async () => {
|
|
34
|
+
let updates = 0;
|
|
35
|
+
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
|
36
|
+
if (init?.method === "POST" && (init.body as string).includes("Status=")) updates++;
|
|
37
|
+
return new Response(JSON.stringify({ sid: "CA123" }), { status: 200 });
|
|
38
|
+
}) as unknown as typeof fetch;
|
|
39
|
+
const adapter = createTwilioTelephonyAdapter({
|
|
40
|
+
accountSid: "A",
|
|
41
|
+
authToken: "t",
|
|
42
|
+
fromNumber: "+1",
|
|
43
|
+
fetchImpl,
|
|
44
|
+
});
|
|
45
|
+
await adapter.dial("+2");
|
|
46
|
+
await adapter.hold();
|
|
47
|
+
await adapter.resume();
|
|
48
|
+
expect(updates).toBe(2);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("missing config throws", () => {
|
|
52
|
+
expect(() =>
|
|
53
|
+
createTwilioTelephonyAdapter({
|
|
54
|
+
accountSid: "",
|
|
55
|
+
authToken: "t",
|
|
56
|
+
fromNumber: "+1",
|
|
57
|
+
}),
|
|
58
|
+
).toThrow(CallSessionError);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("livekit-sip telephony adapter", () => {
|
|
63
|
+
test("dial POSTs to CreateSIPParticipant", async () => {
|
|
64
|
+
let observedUrl = "";
|
|
65
|
+
let observedBody = "";
|
|
66
|
+
const fetchImpl = (async (url: string, init?: RequestInit) => {
|
|
67
|
+
observedUrl = url;
|
|
68
|
+
observedBody = init?.body as string;
|
|
69
|
+
return new Response(JSON.stringify({ participant_id: "p1" }), { status: 200 });
|
|
70
|
+
}) as unknown as typeof fetch;
|
|
71
|
+
const adapter = createLiveKitSipAdapter({
|
|
72
|
+
url: "http://lk",
|
|
73
|
+
apiKey: "k",
|
|
74
|
+
apiSecret: "s",
|
|
75
|
+
fromNumber: "+1",
|
|
76
|
+
fetchImpl,
|
|
77
|
+
});
|
|
78
|
+
await adapter.dial("sip:test@example.com");
|
|
79
|
+
expect(observedUrl).toContain("/twirp/livekit.SIP/CreateSIPParticipant");
|
|
80
|
+
const parsed = JSON.parse(observedBody) as { sip_call_to: string };
|
|
81
|
+
expect(parsed.sip_call_to).toBe("sip:test@example.com");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("transfer requires active call", async () => {
|
|
85
|
+
const fetchImpl = (async () => new Response("{}", { status: 200 })) as unknown as typeof fetch;
|
|
86
|
+
const adapter = createLiveKitSipAdapter({
|
|
87
|
+
url: "http://lk",
|
|
88
|
+
apiKey: "k",
|
|
89
|
+
apiSecret: "s",
|
|
90
|
+
fromNumber: "+1",
|
|
91
|
+
fetchImpl,
|
|
92
|
+
});
|
|
93
|
+
await expect(adapter.transfer("sip:other@x.com")).rejects.toBeInstanceOf(CallSessionError);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("missing config throws", () => {
|
|
97
|
+
expect(() =>
|
|
98
|
+
createLiveKitSipAdapter({
|
|
99
|
+
url: "",
|
|
100
|
+
apiKey: "k",
|
|
101
|
+
apiSecret: "s",
|
|
102
|
+
fromNumber: "+1",
|
|
103
|
+
}),
|
|
104
|
+
).toThrow(CallSessionError);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 30 — LiveKit SIP telephony adapter for `@crewhaus/call-session`.
|
|
3
|
+
* LiveKit Cloud's SIP gateway provides outbound + inbound SIP trunks; this
|
|
4
|
+
* adapter targets their REST/RPC API for call control.
|
|
5
|
+
*/
|
|
6
|
+
import { CallSessionError, type TelephonyAdapter } from "../index";
|
|
7
|
+
|
|
8
|
+
export type LiveKitSipAdapterOptions = {
|
|
9
|
+
readonly url: string;
|
|
10
|
+
readonly apiKey: string;
|
|
11
|
+
readonly apiSecret: string;
|
|
12
|
+
readonly fromNumber: string;
|
|
13
|
+
readonly fetchImpl?: typeof fetch;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function createLiveKitSipAdapter(opts: LiveKitSipAdapterOptions): TelephonyAdapter {
|
|
17
|
+
if (!opts.url) throw new CallSessionError("livekit-sip adapter requires url");
|
|
18
|
+
if (!opts.apiKey) throw new CallSessionError("livekit-sip adapter requires apiKey");
|
|
19
|
+
if (!opts.apiSecret) throw new CallSessionError("livekit-sip adapter requires apiSecret");
|
|
20
|
+
if (!opts.fromNumber) throw new CallSessionError("livekit-sip adapter requires fromNumber");
|
|
21
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
22
|
+
let activeParticipantId: string | undefined;
|
|
23
|
+
|
|
24
|
+
async function rpc(method: string, body: Record<string, unknown>): Promise<unknown> {
|
|
25
|
+
const res = await fetchImpl(`${opts.url}/twirp/livekit.SIP/${method}`, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: {
|
|
28
|
+
Authorization: `Bearer ${opts.apiKey}:${opts.apiSecret}`,
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
},
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
throw new CallSessionError(
|
|
35
|
+
`livekit-sip ${method} returned ${res.status}: ${await res.text()}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return await res.json();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
kind: "livekit-sip",
|
|
43
|
+
async dial(toUri: string): Promise<void> {
|
|
44
|
+
const result = (await rpc("CreateSIPParticipant", {
|
|
45
|
+
sip_call_to: toUri,
|
|
46
|
+
sip_number: opts.fromNumber,
|
|
47
|
+
})) as { participant_id?: string };
|
|
48
|
+
activeParticipantId = result.participant_id ?? "unknown";
|
|
49
|
+
},
|
|
50
|
+
async answer(): Promise<void> {
|
|
51
|
+
// LiveKit dispatches answer via the room agent — adapter-level no-op.
|
|
52
|
+
},
|
|
53
|
+
async hold(): Promise<void> {
|
|
54
|
+
if (!activeParticipantId) throw new CallSessionError("livekit-sip: no active call to hold");
|
|
55
|
+
await rpc("UpdateSIPParticipant", {
|
|
56
|
+
participant_id: activeParticipantId,
|
|
57
|
+
muted: true,
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
async resume(): Promise<void> {
|
|
61
|
+
if (!activeParticipantId) throw new CallSessionError("livekit-sip: no active call to resume");
|
|
62
|
+
await rpc("UpdateSIPParticipant", {
|
|
63
|
+
participant_id: activeParticipantId,
|
|
64
|
+
muted: false,
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
async transfer(toUri: string): Promise<void> {
|
|
68
|
+
if (!activeParticipantId)
|
|
69
|
+
throw new CallSessionError("livekit-sip: no active call to transfer");
|
|
70
|
+
await rpc("TransferSIPParticipant", {
|
|
71
|
+
participant_id: activeParticipantId,
|
|
72
|
+
transfer_to: toUri,
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
async end(_reason: string): Promise<void> {
|
|
76
|
+
if (!activeParticipantId) return;
|
|
77
|
+
await rpc("DeleteSIPParticipant", { participant_id: activeParticipantId });
|
|
78
|
+
activeParticipantId = undefined;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 30 — Twilio telephony adapter for `@crewhaus/call-session`.
|
|
3
|
+
* Routes through the Twilio REST API (via fetch — no SDK dependency).
|
|
4
|
+
* Webhook signature verification lives outside this module (the daemon's
|
|
5
|
+
* inbound HTTP handler does that with `X-Twilio-Signature`).
|
|
6
|
+
*/
|
|
7
|
+
import { CallSessionError, type TelephonyAdapter } from "../index";
|
|
8
|
+
|
|
9
|
+
export type TwilioAdapterOptions = {
|
|
10
|
+
readonly accountSid: string;
|
|
11
|
+
readonly authToken: string;
|
|
12
|
+
readonly fromNumber: string;
|
|
13
|
+
/** Test override: inject a fake fetch. */
|
|
14
|
+
readonly fetchImpl?: typeof fetch;
|
|
15
|
+
/** Override base URL (defaults to https://api.twilio.com/2010-04-01). */
|
|
16
|
+
readonly baseUrl?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function createTwilioTelephonyAdapter(opts: TwilioAdapterOptions): TelephonyAdapter {
|
|
20
|
+
if (!opts.accountSid) throw new CallSessionError("twilio adapter requires accountSid");
|
|
21
|
+
if (!opts.authToken) throw new CallSessionError("twilio adapter requires authToken");
|
|
22
|
+
if (!opts.fromNumber) throw new CallSessionError("twilio adapter requires fromNumber");
|
|
23
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
24
|
+
const baseUrl = opts.baseUrl ?? "https://api.twilio.com/2010-04-01";
|
|
25
|
+
let activeCallSid: string | undefined;
|
|
26
|
+
|
|
27
|
+
function authHeader(): string {
|
|
28
|
+
const token = Buffer.from(`${opts.accountSid}:${opts.authToken}`).toString("base64");
|
|
29
|
+
return `Basic ${token}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function callsPost(body: Record<string, string>): Promise<{ sid: string }> {
|
|
33
|
+
const params = new URLSearchParams(body).toString();
|
|
34
|
+
const res = await fetchImpl(`${baseUrl}/Accounts/${opts.accountSid}/Calls.json`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: {
|
|
37
|
+
Authorization: authHeader(),
|
|
38
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
39
|
+
},
|
|
40
|
+
body: params,
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
throw new CallSessionError(`twilio Calls.create returned ${res.status}: ${await res.text()}`);
|
|
44
|
+
}
|
|
45
|
+
return (await res.json()) as { sid: string };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function callsUpdate(callSid: string, body: Record<string, string>): Promise<void> {
|
|
49
|
+
const params = new URLSearchParams(body).toString();
|
|
50
|
+
const res = await fetchImpl(`${baseUrl}/Accounts/${opts.accountSid}/Calls/${callSid}.json`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: {
|
|
53
|
+
Authorization: authHeader(),
|
|
54
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
55
|
+
},
|
|
56
|
+
body: params,
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
throw new CallSessionError(`twilio Calls.update returned ${res.status}: ${await res.text()}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
kind: "twilio",
|
|
65
|
+
async dial(toUri: string): Promise<void> {
|
|
66
|
+
const result = await callsPost({
|
|
67
|
+
From: opts.fromNumber,
|
|
68
|
+
To: toUri,
|
|
69
|
+
// Twilio requires a TwiML URL or instructions; v0 expects callers
|
|
70
|
+
// to wire the actual TwiML elsewhere. The empty Url makes the call
|
|
71
|
+
// fail loudly rather than silently dial.
|
|
72
|
+
Url: "https://demo.twilio.com/docs/voice.xml",
|
|
73
|
+
});
|
|
74
|
+
activeCallSid = result.sid;
|
|
75
|
+
},
|
|
76
|
+
async answer(): Promise<void> {
|
|
77
|
+
// Inbound calls answer via TwiML <Say>/<Connect> on the webhook —
|
|
78
|
+
// adapter-level answer is a no-op.
|
|
79
|
+
},
|
|
80
|
+
async hold(): Promise<void> {
|
|
81
|
+
if (!activeCallSid) throw new CallSessionError("twilio: no active call to hold");
|
|
82
|
+
await callsUpdate(activeCallSid, { Status: "queued" });
|
|
83
|
+
},
|
|
84
|
+
async resume(): Promise<void> {
|
|
85
|
+
if (!activeCallSid) throw new CallSessionError("twilio: no active call to resume");
|
|
86
|
+
await callsUpdate(activeCallSid, { Status: "in-progress" });
|
|
87
|
+
},
|
|
88
|
+
async transfer(toUri: string): Promise<void> {
|
|
89
|
+
if (!activeCallSid) throw new CallSessionError("twilio: no active call to transfer");
|
|
90
|
+
// Twilio transfer = update the active call to redirect to a TwiML
|
|
91
|
+
// <Dial> for the new endpoint. Caller's TwiML supplies the verb;
|
|
92
|
+
// here we POST the redirect URL.
|
|
93
|
+
await callsUpdate(activeCallSid, {
|
|
94
|
+
Url: `https://demo.twilio.com/docs/voice.xml?to=${encodeURIComponent(toUri)}`,
|
|
95
|
+
Method: "POST",
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
async end(_reason: string): Promise<void> {
|
|
99
|
+
if (!activeCallSid) return;
|
|
100
|
+
await callsUpdate(activeCallSid, { Status: "completed" });
|
|
101
|
+
activeCallSid = undefined;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
CallSessionError,
|
|
4
|
+
type CallTransition,
|
|
5
|
+
createCallSession,
|
|
6
|
+
createInMemoryTelephonyAdapter,
|
|
7
|
+
} from "./index.js";
|
|
8
|
+
|
|
9
|
+
describe("createCallSession (T1 + T9 state machine)", () => {
|
|
10
|
+
test("happy path: idle → dialing → connected → on-hold → connected → terminated", async () => {
|
|
11
|
+
const adapter = createInMemoryTelephonyAdapter();
|
|
12
|
+
const transitions: CallTransition[] = [];
|
|
13
|
+
const session = createCallSession({ adapter });
|
|
14
|
+
session.on((t) => transitions.push(t));
|
|
15
|
+
|
|
16
|
+
await session.dial("tel:+15555550100");
|
|
17
|
+
expect(session.state).toBe("dialing");
|
|
18
|
+
await session.answer();
|
|
19
|
+
expect(session.state).toBe("connected");
|
|
20
|
+
await session.hold();
|
|
21
|
+
expect(session.state).toBe("on-hold");
|
|
22
|
+
await session.resume();
|
|
23
|
+
expect(session.state).toBe("connected");
|
|
24
|
+
await session.end({ reason: "user hangup" });
|
|
25
|
+
expect(session.state).toBe("terminated");
|
|
26
|
+
|
|
27
|
+
expect(transitions.map((t) => `${t.from}→${t.to}`)).toEqual([
|
|
28
|
+
"idle→dialing",
|
|
29
|
+
"dialing→connected",
|
|
30
|
+
"connected→on-hold",
|
|
31
|
+
"on-hold→connected",
|
|
32
|
+
"connected→terminated",
|
|
33
|
+
]);
|
|
34
|
+
expect(adapter.calls.map((c) => c.verb)).toEqual(["dial", "answer", "hold", "resume", "end"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("transfer path: idle → dialing → connected → transferred → terminated", async () => {
|
|
38
|
+
const adapter = createInMemoryTelephonyAdapter();
|
|
39
|
+
const session = createCallSession({ adapter });
|
|
40
|
+
await session.dial("tel:a");
|
|
41
|
+
await session.answer();
|
|
42
|
+
await session.transfer("tel:b");
|
|
43
|
+
expect(session.state).toBe("transferred");
|
|
44
|
+
await session.end();
|
|
45
|
+
expect(session.state).toBe("terminated");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("illegal transitions throw CallSessionError without changing state", async () => {
|
|
49
|
+
const adapter = createInMemoryTelephonyAdapter();
|
|
50
|
+
const session = createCallSession({ adapter });
|
|
51
|
+
await expect(session.answer()).rejects.toThrow(CallSessionError); // idle → connected illegal
|
|
52
|
+
expect(session.state).toBe("idle");
|
|
53
|
+
await expect(session.hold()).rejects.toThrow(CallSessionError);
|
|
54
|
+
expect(session.state).toBe("idle");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("dial failure auto-transitions to terminated", async () => {
|
|
58
|
+
const adapter = createInMemoryTelephonyAdapter();
|
|
59
|
+
adapter.failNextDial("network down");
|
|
60
|
+
const session = createCallSession({ adapter });
|
|
61
|
+
await expect(session.dial("tel:a")).rejects.toThrow(/network down/);
|
|
62
|
+
expect(session.state).toBe("terminated");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("listener errors are isolated and do not block transitions", async () => {
|
|
66
|
+
const adapter = createInMemoryTelephonyAdapter();
|
|
67
|
+
const session = createCallSession({ adapter });
|
|
68
|
+
session.on(() => {
|
|
69
|
+
throw new Error("listener oops");
|
|
70
|
+
});
|
|
71
|
+
let okListenerHits = 0;
|
|
72
|
+
session.on(() => {
|
|
73
|
+
okListenerHits += 1;
|
|
74
|
+
});
|
|
75
|
+
await session.dial("tel:a");
|
|
76
|
+
expect(okListenerHits).toBe(1);
|
|
77
|
+
expect(session.state).toBe("dialing");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("history returns transitions in order with timestamps", async () => {
|
|
81
|
+
const adapter = createInMemoryTelephonyAdapter();
|
|
82
|
+
const session = createCallSession({ adapter });
|
|
83
|
+
await session.dial("tel:a");
|
|
84
|
+
await session.answer();
|
|
85
|
+
const log = session.history();
|
|
86
|
+
expect(log.length).toBe(2);
|
|
87
|
+
expect(log[0]?.from).toBe("idle");
|
|
88
|
+
expect(log[0]?.to).toBe("dialing");
|
|
89
|
+
expect(typeof log[0]?.at).toBe("string");
|
|
90
|
+
expect(log[1]?.to).toBe("connected");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("T9 property: every state's outgoing transitions are exactly the configured set", async () => {
|
|
94
|
+
// The state-machine definition is encoded as a constant; ensure
|
|
95
|
+
// illegal transitions are rejected uniformly.
|
|
96
|
+
const adapter = createInMemoryTelephonyAdapter();
|
|
97
|
+
const session = createCallSession({ adapter });
|
|
98
|
+
await session.dial("a");
|
|
99
|
+
await session.answer();
|
|
100
|
+
await session.end();
|
|
101
|
+
// Once terminated, every verb throws.
|
|
102
|
+
await expect(session.dial("b")).rejects.toThrow(CallSessionError);
|
|
103
|
+
await expect(session.hold()).rejects.toThrow(CallSessionError);
|
|
104
|
+
await expect(session.transfer("c")).rejects.toThrow(CallSessionError);
|
|
105
|
+
});
|
|
106
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog R16 `call-session` — Section 24 VOICE.
|
|
3
|
+
*
|
|
4
|
+
* Call lifecycle state machine with pluggable telephony adapter slots.
|
|
5
|
+
*
|
|
6
|
+
* idle ─dial()→ dialing ─answer()→ connected ─hold()→ on-hold
|
|
7
|
+
* │ │
|
|
8
|
+
* │ └─resume()→ connected
|
|
9
|
+
* ├─transfer()→ transferred ─end()→ terminated
|
|
10
|
+
* └─end()─────────────────────→ terminated
|
|
11
|
+
*
|
|
12
|
+
* Telephony adapters (Twilio, LiveKit SIP, Vonage) are STUBS in v0 —
|
|
13
|
+
* the interface is exercised by an in-memory adapter the tests + the
|
|
14
|
+
* smoke harness use to drive transitions deterministically. Real
|
|
15
|
+
* telephony lands in follow-up PRs (the kickoff explicitly defers it).
|
|
16
|
+
*
|
|
17
|
+
* Per-state hooks fire on transitions — the daemon uses them to log
|
|
18
|
+
* trace events, measure connect-time, etc. Hook errors are isolated:
|
|
19
|
+
* a misbehaving listener is logged but cannot wedge the state machine.
|
|
20
|
+
*/
|
|
21
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
22
|
+
|
|
23
|
+
export class CallSessionError extends CrewhausError {
|
|
24
|
+
override readonly name = "CallSessionError";
|
|
25
|
+
constructor(message: string, cause?: unknown) {
|
|
26
|
+
super("config", message, cause);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Section 30 — additional telephony adapters
|
|
31
|
+
export {
|
|
32
|
+
createTwilioTelephonyAdapter,
|
|
33
|
+
type TwilioAdapterOptions,
|
|
34
|
+
} from "./adapters/twilio";
|
|
35
|
+
export {
|
|
36
|
+
createLiveKitSipAdapter,
|
|
37
|
+
type LiveKitSipAdapterOptions,
|
|
38
|
+
} from "./adapters/livekit-sip";
|
|
39
|
+
|
|
40
|
+
export type CallState = "idle" | "dialing" | "connected" | "on-hold" | "transferred" | "terminated";
|
|
41
|
+
|
|
42
|
+
export type CallTransition = {
|
|
43
|
+
readonly from: CallState;
|
|
44
|
+
readonly to: CallState;
|
|
45
|
+
readonly at: string;
|
|
46
|
+
readonly reason?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type CallStateListener = (transition: CallTransition) => void;
|
|
50
|
+
|
|
51
|
+
export interface TelephonyAdapter {
|
|
52
|
+
readonly kind: string;
|
|
53
|
+
/** Begin the outbound dial — promise resolves on adapter ack. */
|
|
54
|
+
dial(toUri: string): Promise<void>;
|
|
55
|
+
/** Answer an incoming call (no-op for outbound flows). */
|
|
56
|
+
answer(): Promise<void>;
|
|
57
|
+
/** Briefly suspend the bidirectional audio path. */
|
|
58
|
+
hold(): Promise<void>;
|
|
59
|
+
resume(): Promise<void>;
|
|
60
|
+
/** Hand the call off to another agent / number. */
|
|
61
|
+
transfer(toUri: string): Promise<void>;
|
|
62
|
+
/** Hang up. */
|
|
63
|
+
end(reason: string): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CallSession {
|
|
67
|
+
readonly state: CallState;
|
|
68
|
+
dial(toUri: string, opts?: { reason?: string }): Promise<void>;
|
|
69
|
+
answer(opts?: { reason?: string }): Promise<void>;
|
|
70
|
+
hold(opts?: { reason?: string }): Promise<void>;
|
|
71
|
+
resume(opts?: { reason?: string }): Promise<void>;
|
|
72
|
+
transfer(toUri: string, opts?: { reason?: string }): Promise<void>;
|
|
73
|
+
end(opts?: { reason?: string }): Promise<void>;
|
|
74
|
+
on(listener: CallStateListener): () => void;
|
|
75
|
+
/** Diagnostic — full transition history (newest last). */
|
|
76
|
+
history(): ReadonlyArray<CallTransition>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type CreateCallSessionOptions = {
|
|
80
|
+
readonly adapter: TelephonyAdapter;
|
|
81
|
+
readonly now?: () => Date;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const TRANSITIONS: Readonly<Record<CallState, ReadonlyArray<CallState>>> = {
|
|
85
|
+
idle: ["dialing"],
|
|
86
|
+
dialing: ["connected", "terminated"],
|
|
87
|
+
connected: ["on-hold", "transferred", "terminated"],
|
|
88
|
+
"on-hold": ["connected", "terminated"],
|
|
89
|
+
transferred: ["terminated"],
|
|
90
|
+
terminated: [],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export function createCallSession(opts: CreateCallSessionOptions): CallSession {
|
|
94
|
+
const now = opts.now ?? (() => new Date());
|
|
95
|
+
const listeners = new Set<CallStateListener>();
|
|
96
|
+
const log: CallTransition[] = [];
|
|
97
|
+
let state: CallState = "idle";
|
|
98
|
+
|
|
99
|
+
function transitionTo(target: CallState, reason?: string): void {
|
|
100
|
+
const allowed = TRANSITIONS[state];
|
|
101
|
+
if (!allowed.includes(target)) {
|
|
102
|
+
throw new CallSessionError(
|
|
103
|
+
`illegal transition ${state} → ${target}${reason ? ` (reason: ${reason})` : ""}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const t: CallTransition = {
|
|
107
|
+
from: state,
|
|
108
|
+
to: target,
|
|
109
|
+
at: now().toISOString(),
|
|
110
|
+
...(reason !== undefined ? { reason } : {}),
|
|
111
|
+
};
|
|
112
|
+
state = target;
|
|
113
|
+
log.push(t);
|
|
114
|
+
for (const l of listeners) {
|
|
115
|
+
try {
|
|
116
|
+
l(t);
|
|
117
|
+
} catch {
|
|
118
|
+
// Isolate listener errors.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
get state() {
|
|
125
|
+
return state;
|
|
126
|
+
},
|
|
127
|
+
async dial(toUri, callOpts = {}) {
|
|
128
|
+
transitionTo("dialing", callOpts.reason);
|
|
129
|
+
try {
|
|
130
|
+
await opts.adapter.dial(toUri);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
transitionTo("terminated", `dial failed: ${(err as Error).message ?? String(err)}`);
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
async answer(callOpts = {}) {
|
|
137
|
+
await opts.adapter.answer();
|
|
138
|
+
transitionTo("connected", callOpts.reason);
|
|
139
|
+
},
|
|
140
|
+
async hold(callOpts = {}) {
|
|
141
|
+
await opts.adapter.hold();
|
|
142
|
+
transitionTo("on-hold", callOpts.reason);
|
|
143
|
+
},
|
|
144
|
+
async resume(callOpts = {}) {
|
|
145
|
+
await opts.adapter.resume();
|
|
146
|
+
transitionTo("connected", callOpts.reason);
|
|
147
|
+
},
|
|
148
|
+
async transfer(toUri, callOpts = {}) {
|
|
149
|
+
await opts.adapter.transfer(toUri);
|
|
150
|
+
transitionTo("transferred", callOpts.reason);
|
|
151
|
+
},
|
|
152
|
+
async end(callOpts = {}) {
|
|
153
|
+
await opts.adapter.end(callOpts.reason ?? "end");
|
|
154
|
+
transitionTo("terminated", callOpts.reason);
|
|
155
|
+
},
|
|
156
|
+
on(listener) {
|
|
157
|
+
listeners.add(listener);
|
|
158
|
+
return () => {
|
|
159
|
+
listeners.delete(listener);
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
history() {
|
|
163
|
+
return [...log];
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// In-memory adapter — for tests + the smoke harness.
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
export interface InMemoryTelephonyAdapter extends TelephonyAdapter {
|
|
173
|
+
readonly kind: "in-memory";
|
|
174
|
+
/** Test seam — every adapter call is recorded here in order. */
|
|
175
|
+
readonly calls: ReadonlyArray<{ readonly verb: string; readonly arg?: string }>;
|
|
176
|
+
/** Inject a failure on the next `dial(...)` call. */
|
|
177
|
+
failNextDial(message?: string): void;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function createInMemoryTelephonyAdapter(): InMemoryTelephonyAdapter {
|
|
181
|
+
const calls: { verb: string; arg?: string }[] = [];
|
|
182
|
+
let nextDialFailure: string | undefined;
|
|
183
|
+
return {
|
|
184
|
+
kind: "in-memory",
|
|
185
|
+
calls,
|
|
186
|
+
failNextDial(message = "in-memory: dial failed") {
|
|
187
|
+
nextDialFailure = message;
|
|
188
|
+
},
|
|
189
|
+
async dial(toUri) {
|
|
190
|
+
calls.push({ verb: "dial", arg: toUri });
|
|
191
|
+
if (nextDialFailure !== undefined) {
|
|
192
|
+
const m = nextDialFailure;
|
|
193
|
+
nextDialFailure = undefined;
|
|
194
|
+
throw new CallSessionError(m);
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
async answer() {
|
|
198
|
+
calls.push({ verb: "answer" });
|
|
199
|
+
},
|
|
200
|
+
async hold() {
|
|
201
|
+
calls.push({ verb: "hold" });
|
|
202
|
+
},
|
|
203
|
+
async resume() {
|
|
204
|
+
calls.push({ verb: "resume" });
|
|
205
|
+
},
|
|
206
|
+
async transfer(toUri) {
|
|
207
|
+
calls.push({ verb: "transfer", arg: toUri });
|
|
208
|
+
},
|
|
209
|
+
async end(reason) {
|
|
210
|
+
calls.push({ verb: "end", arg: reason });
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|