@crewhaus/call-session 0.1.3 → 0.1.5

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.
@@ -0,0 +1,14 @@
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 { type TelephonyAdapter } from "../index";
7
+ export type LiveKitSipAdapterOptions = {
8
+ readonly url: string;
9
+ readonly apiKey: string;
10
+ readonly apiSecret: string;
11
+ readonly fromNumber: string;
12
+ readonly fetchImpl?: typeof fetch;
13
+ };
14
+ export declare function createLiveKitSipAdapter(opts: LiveKitSipAdapterOptions): TelephonyAdapter;
@@ -0,0 +1,75 @@
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 } from "../index";
7
+ export function createLiveKitSipAdapter(opts) {
8
+ if (!opts.url)
9
+ throw new CallSessionError("livekit-sip adapter requires url");
10
+ if (!opts.apiKey)
11
+ throw new CallSessionError("livekit-sip adapter requires apiKey");
12
+ if (!opts.apiSecret)
13
+ throw new CallSessionError("livekit-sip adapter requires apiSecret");
14
+ if (!opts.fromNumber)
15
+ throw new CallSessionError("livekit-sip adapter requires fromNumber");
16
+ const fetchImpl = opts.fetchImpl ?? fetch;
17
+ let activeParticipantId;
18
+ async function rpc(method, body) {
19
+ const res = await fetchImpl(`${opts.url}/twirp/livekit.SIP/${method}`, {
20
+ method: "POST",
21
+ headers: {
22
+ Authorization: `Bearer ${opts.apiKey}:${opts.apiSecret}`,
23
+ "Content-Type": "application/json",
24
+ },
25
+ body: JSON.stringify(body),
26
+ });
27
+ if (!res.ok) {
28
+ throw new CallSessionError(`livekit-sip ${method} returned ${res.status}: ${await res.text()}`);
29
+ }
30
+ return await res.json();
31
+ }
32
+ return {
33
+ kind: "livekit-sip",
34
+ async dial(toUri) {
35
+ const result = (await rpc("CreateSIPParticipant", {
36
+ sip_call_to: toUri,
37
+ sip_number: opts.fromNumber,
38
+ }));
39
+ activeParticipantId = result.participant_id ?? "unknown";
40
+ },
41
+ async answer() {
42
+ // LiveKit dispatches answer via the room agent — adapter-level no-op.
43
+ },
44
+ async hold() {
45
+ if (!activeParticipantId)
46
+ throw new CallSessionError("livekit-sip: no active call to hold");
47
+ await rpc("UpdateSIPParticipant", {
48
+ participant_id: activeParticipantId,
49
+ muted: true,
50
+ });
51
+ },
52
+ async resume() {
53
+ if (!activeParticipantId)
54
+ throw new CallSessionError("livekit-sip: no active call to resume");
55
+ await rpc("UpdateSIPParticipant", {
56
+ participant_id: activeParticipantId,
57
+ muted: false,
58
+ });
59
+ },
60
+ async transfer(toUri) {
61
+ if (!activeParticipantId)
62
+ throw new CallSessionError("livekit-sip: no active call to transfer");
63
+ await rpc("TransferSIPParticipant", {
64
+ participant_id: activeParticipantId,
65
+ transfer_to: toUri,
66
+ });
67
+ },
68
+ async end(_reason) {
69
+ if (!activeParticipantId)
70
+ return;
71
+ await rpc("DeleteSIPParticipant", { participant_id: activeParticipantId });
72
+ activeParticipantId = undefined;
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,17 @@
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 { type TelephonyAdapter } from "../index";
8
+ export type TwilioAdapterOptions = {
9
+ readonly accountSid: string;
10
+ readonly authToken: string;
11
+ readonly fromNumber: string;
12
+ /** Test override: inject a fake fetch. */
13
+ readonly fetchImpl?: typeof fetch;
14
+ /** Override base URL (defaults to https://api.twilio.com/2010-04-01). */
15
+ readonly baseUrl?: string;
16
+ };
17
+ export declare function createTwilioTelephonyAdapter(opts: TwilioAdapterOptions): TelephonyAdapter;
@@ -0,0 +1,96 @@
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 } from "../index";
8
+ export function createTwilioTelephonyAdapter(opts) {
9
+ if (!opts.accountSid)
10
+ throw new CallSessionError("twilio adapter requires accountSid");
11
+ if (!opts.authToken)
12
+ throw new CallSessionError("twilio adapter requires authToken");
13
+ if (!opts.fromNumber)
14
+ throw new CallSessionError("twilio adapter requires fromNumber");
15
+ const fetchImpl = opts.fetchImpl ?? fetch;
16
+ const baseUrl = opts.baseUrl ?? "https://api.twilio.com/2010-04-01";
17
+ let activeCallSid;
18
+ function authHeader() {
19
+ const token = Buffer.from(`${opts.accountSid}:${opts.authToken}`).toString("base64");
20
+ return `Basic ${token}`;
21
+ }
22
+ async function callsPost(body) {
23
+ const params = new URLSearchParams(body).toString();
24
+ const res = await fetchImpl(`${baseUrl}/Accounts/${opts.accountSid}/Calls.json`, {
25
+ method: "POST",
26
+ headers: {
27
+ Authorization: authHeader(),
28
+ "Content-Type": "application/x-www-form-urlencoded",
29
+ },
30
+ body: params,
31
+ });
32
+ if (!res.ok) {
33
+ throw new CallSessionError(`twilio Calls.create returned ${res.status}: ${await res.text()}`);
34
+ }
35
+ return (await res.json());
36
+ }
37
+ async function callsUpdate(callSid, body) {
38
+ const params = new URLSearchParams(body).toString();
39
+ const res = await fetchImpl(`${baseUrl}/Accounts/${opts.accountSid}/Calls/${callSid}.json`, {
40
+ method: "POST",
41
+ headers: {
42
+ Authorization: authHeader(),
43
+ "Content-Type": "application/x-www-form-urlencoded",
44
+ },
45
+ body: params,
46
+ });
47
+ if (!res.ok) {
48
+ throw new CallSessionError(`twilio Calls.update returned ${res.status}: ${await res.text()}`);
49
+ }
50
+ }
51
+ return {
52
+ kind: "twilio",
53
+ async dial(toUri) {
54
+ const result = await callsPost({
55
+ From: opts.fromNumber,
56
+ To: toUri,
57
+ // Twilio requires a TwiML URL or instructions; v0 expects callers
58
+ // to wire the actual TwiML elsewhere. The empty Url makes the call
59
+ // fail loudly rather than silently dial.
60
+ Url: "https://demo.twilio.com/docs/voice.xml",
61
+ });
62
+ activeCallSid = result.sid;
63
+ },
64
+ async answer() {
65
+ // Inbound calls answer via TwiML <Say>/<Connect> on the webhook —
66
+ // adapter-level answer is a no-op.
67
+ },
68
+ async hold() {
69
+ if (!activeCallSid)
70
+ throw new CallSessionError("twilio: no active call to hold");
71
+ await callsUpdate(activeCallSid, { Status: "queued" });
72
+ },
73
+ async resume() {
74
+ if (!activeCallSid)
75
+ throw new CallSessionError("twilio: no active call to resume");
76
+ await callsUpdate(activeCallSid, { Status: "in-progress" });
77
+ },
78
+ async transfer(toUri) {
79
+ if (!activeCallSid)
80
+ throw new CallSessionError("twilio: no active call to transfer");
81
+ // Twilio transfer = update the active call to redirect to a TwiML
82
+ // <Dial> for the new endpoint. Caller's TwiML supplies the verb;
83
+ // here we POST the redirect URL.
84
+ await callsUpdate(activeCallSid, {
85
+ Url: `https://demo.twilio.com/docs/voice.xml?to=${encodeURIComponent(toUri)}`,
86
+ Method: "POST",
87
+ });
88
+ },
89
+ async end(_reason) {
90
+ if (!activeCallSid)
91
+ return;
92
+ await callsUpdate(activeCallSid, { Status: "completed" });
93
+ activeCallSid = undefined;
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,89 @@
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
+ export declare class CallSessionError extends CrewhausError {
23
+ readonly name = "CallSessionError";
24
+ constructor(message: string, cause?: unknown);
25
+ }
26
+ export { createTwilioTelephonyAdapter, type TwilioAdapterOptions, } from "./adapters/twilio";
27
+ export { createLiveKitSipAdapter, type LiveKitSipAdapterOptions, } from "./adapters/livekit-sip";
28
+ export type CallState = "idle" | "dialing" | "connected" | "on-hold" | "transferred" | "terminated";
29
+ export type CallTransition = {
30
+ readonly from: CallState;
31
+ readonly to: CallState;
32
+ readonly at: string;
33
+ readonly reason?: string;
34
+ };
35
+ export type CallStateListener = (transition: CallTransition) => void;
36
+ export interface TelephonyAdapter {
37
+ readonly kind: string;
38
+ /** Begin the outbound dial — promise resolves on adapter ack. */
39
+ dial(toUri: string): Promise<void>;
40
+ /** Answer an incoming call (no-op for outbound flows). */
41
+ answer(): Promise<void>;
42
+ /** Briefly suspend the bidirectional audio path. */
43
+ hold(): Promise<void>;
44
+ resume(): Promise<void>;
45
+ /** Hand the call off to another agent / number. */
46
+ transfer(toUri: string): Promise<void>;
47
+ /** Hang up. */
48
+ end(reason: string): Promise<void>;
49
+ }
50
+ export interface CallSession {
51
+ readonly state: CallState;
52
+ dial(toUri: string, opts?: {
53
+ reason?: string;
54
+ }): Promise<void>;
55
+ answer(opts?: {
56
+ reason?: string;
57
+ }): Promise<void>;
58
+ hold(opts?: {
59
+ reason?: string;
60
+ }): Promise<void>;
61
+ resume(opts?: {
62
+ reason?: string;
63
+ }): Promise<void>;
64
+ transfer(toUri: string, opts?: {
65
+ reason?: string;
66
+ }): Promise<void>;
67
+ end(opts?: {
68
+ reason?: string;
69
+ }): Promise<void>;
70
+ on(listener: CallStateListener): () => void;
71
+ /** Diagnostic — full transition history (newest last). */
72
+ history(): ReadonlyArray<CallTransition>;
73
+ }
74
+ export type CreateCallSessionOptions = {
75
+ readonly adapter: TelephonyAdapter;
76
+ readonly now?: () => Date;
77
+ };
78
+ export declare function createCallSession(opts: CreateCallSessionOptions): CallSession;
79
+ export interface InMemoryTelephonyAdapter extends TelephonyAdapter {
80
+ readonly kind: "in-memory";
81
+ /** Test seam — every adapter call is recorded here in order. */
82
+ readonly calls: ReadonlyArray<{
83
+ readonly verb: string;
84
+ readonly arg?: string;
85
+ }>;
86
+ /** Inject a failure on the next `dial(...)` call. */
87
+ failNextDial(message?: string): void;
88
+ }
89
+ export declare function createInMemoryTelephonyAdapter(): InMemoryTelephonyAdapter;
package/dist/index.js ADDED
@@ -0,0 +1,144 @@
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
+ export class CallSessionError extends CrewhausError {
23
+ name = "CallSessionError";
24
+ constructor(message, cause) {
25
+ super("config", message, cause);
26
+ }
27
+ }
28
+ // Section 30 — additional telephony adapters
29
+ export { createTwilioTelephonyAdapter, } from "./adapters/twilio";
30
+ export { createLiveKitSipAdapter, } from "./adapters/livekit-sip";
31
+ const TRANSITIONS = {
32
+ idle: ["dialing"],
33
+ dialing: ["connected", "terminated"],
34
+ connected: ["on-hold", "transferred", "terminated"],
35
+ "on-hold": ["connected", "terminated"],
36
+ transferred: ["terminated"],
37
+ terminated: [],
38
+ };
39
+ export function createCallSession(opts) {
40
+ const now = opts.now ?? (() => new Date());
41
+ const listeners = new Set();
42
+ const log = [];
43
+ let state = "idle";
44
+ function transitionTo(target, reason) {
45
+ const allowed = TRANSITIONS[state];
46
+ if (!allowed.includes(target)) {
47
+ throw new CallSessionError(`illegal transition ${state} → ${target}${reason ? ` (reason: ${reason})` : ""}`);
48
+ }
49
+ const t = {
50
+ from: state,
51
+ to: target,
52
+ at: now().toISOString(),
53
+ ...(reason !== undefined ? { reason } : {}),
54
+ };
55
+ state = target;
56
+ log.push(t);
57
+ for (const l of listeners) {
58
+ try {
59
+ l(t);
60
+ }
61
+ catch {
62
+ // Isolate listener errors.
63
+ }
64
+ }
65
+ }
66
+ return {
67
+ get state() {
68
+ return state;
69
+ },
70
+ async dial(toUri, callOpts = {}) {
71
+ transitionTo("dialing", callOpts.reason);
72
+ try {
73
+ await opts.adapter.dial(toUri);
74
+ }
75
+ catch (err) {
76
+ transitionTo("terminated", `dial failed: ${err.message ?? String(err)}`);
77
+ throw err;
78
+ }
79
+ },
80
+ async answer(callOpts = {}) {
81
+ await opts.adapter.answer();
82
+ transitionTo("connected", callOpts.reason);
83
+ },
84
+ async hold(callOpts = {}) {
85
+ await opts.adapter.hold();
86
+ transitionTo("on-hold", callOpts.reason);
87
+ },
88
+ async resume(callOpts = {}) {
89
+ await opts.adapter.resume();
90
+ transitionTo("connected", callOpts.reason);
91
+ },
92
+ async transfer(toUri, callOpts = {}) {
93
+ await opts.adapter.transfer(toUri);
94
+ transitionTo("transferred", callOpts.reason);
95
+ },
96
+ async end(callOpts = {}) {
97
+ await opts.adapter.end(callOpts.reason ?? "end");
98
+ transitionTo("terminated", callOpts.reason);
99
+ },
100
+ on(listener) {
101
+ listeners.add(listener);
102
+ return () => {
103
+ listeners.delete(listener);
104
+ };
105
+ },
106
+ history() {
107
+ return [...log];
108
+ },
109
+ };
110
+ }
111
+ export function createInMemoryTelephonyAdapter() {
112
+ const calls = [];
113
+ let nextDialFailure;
114
+ return {
115
+ kind: "in-memory",
116
+ calls,
117
+ failNextDial(message = "in-memory: dial failed") {
118
+ nextDialFailure = message;
119
+ },
120
+ async dial(toUri) {
121
+ calls.push({ verb: "dial", arg: toUri });
122
+ if (nextDialFailure !== undefined) {
123
+ const m = nextDialFailure;
124
+ nextDialFailure = undefined;
125
+ throw new CallSessionError(m);
126
+ }
127
+ },
128
+ async answer() {
129
+ calls.push({ verb: "answer" });
130
+ },
131
+ async hold() {
132
+ calls.push({ verb: "hold" });
133
+ },
134
+ async resume() {
135
+ calls.push({ verb: "resume" });
136
+ },
137
+ async transfer(toUri) {
138
+ calls.push({ verb: "transfer", arg: toUri });
139
+ },
140
+ async end(reason) {
141
+ calls.push({ verb: "end", arg: reason });
142
+ },
143
+ };
144
+ }
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@crewhaus/call-session",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
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",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.3"
18
+ "@crewhaus/errors": "0.1.5"
16
19
  },
17
20
  "license": "Apache-2.0",
18
21
  "author": {
@@ -32,5 +35,5 @@
32
35
  "publishConfig": {
33
36
  "access": "public"
34
37
  },
35
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
38
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
36
39
  }