@relaymessenger/sdk 0.3.6-staging.3 → 0.3.6-staging.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.
package/README.md CHANGED
@@ -182,25 +182,45 @@ Calls join one user and one agent in an existing individual Chat. Use
182
182
  `relay.calls.create(chatId, { to: [handle], mode: "audio" }, { idempotencyKey })`;
183
183
  keep the same key and body when retrying an uncertain create response.
184
184
 
185
- `relay.calls` also exposes `retrieve`, `list`, `accept`, `decline`, `end`, and
186
- `connected`. Receive typed `call.created`, `call.updated`, and `call.ended`
187
- events through the existing signed Webhook or Agent WebSocket.
188
-
189
- After accepting a Call, an agent creates its audio connection with
190
- `relay.calls.connections.create(callId, { transport: "websocket" })`.
191
- The returned `connection.url` and short-lived `connection.token` belong to
192
- that Call's media socket. Connect using `Authorization: Bearer <token>`.
193
- They do not replace the Agent Token used for REST requests.
194
-
195
- Media uses raw PCM16 little-endian, 48 kHz stereo binary frames. The server
196
- sends JSON `start` with the format, then `ready`. Send `{"type":"clear"}` to
197
- discard unsent speech. An `ended` frame terminates the media socket. This
198
- socket is separate from `relay.websocket.run`, which carries durable events.
199
-
200
- WebRTC clients use the same `connections.create` resource with an SDP offer
201
- and microphone MID, then `connections.subscribe` and
202
- `connections.renegotiate`. The SDK exposes no media-provider credentials,
203
- session IDs, model configuration, or audio generation.
185
+ `relay.calls` also exposes `retrieve`, `list`, `accept`, `decline`, and `end`.
186
+ Receive typed `call.created`, `call.updated`, and `call.ended` events through
187
+ the existing signed Webhook or Agent WebSocket.
188
+
189
+ Every participant holds one socket to the Call room. `relay.calls.room(callId)`
190
+ opens `GET /v1/calls/{callId}/room` with the same bearer as every REST route,
191
+ sends `join`, and keeps `room.state` at the latest `roomState` the room pushed
192
+ after every change. Nothing is polled.
193
+
194
+ ```ts
195
+ const room = relay.calls.room(event.data.call.id);
196
+ room.on("roomState", ({ call, participants, media }) => {
197
+ // `media` (the audio-socket url and short-lived token) is present only on
198
+ // the agent's own socket. Open it, then accept.
199
+ if (call.status === "ringing" && media) room.accept();
200
+ });
201
+ room.on("offer", ({ session_description, track }) => {
202
+ // The room started pulling the other side's track; answer it.
203
+ room.send({ type: "answer", session_description: { type: "answer", sdp } });
204
+ });
205
+ room.on("ended", ({ reason }) => { /* the socket closes right after */ });
206
+ room.connected();
207
+ room.userUpdate({ muted: true });
208
+ room.end();
209
+ ```
210
+
211
+ `room.send(frame)` takes any client frame from the contract (`join`, `offer`,
212
+ `answer`, `userUpdate`, `accept`, `decline`, `end`, `connected`, `heartbeat`);
213
+ `accept()`, `decline()`, `end()`, `connected()` and `userUpdate()` send the
214
+ matching frame. The client sends `heartbeat` every 15 s until `close()`. An
215
+ invalid room frame closes the socket 4400 and fires `error`.
216
+
217
+ The agent's audio flows on the media socket named in `roomState.media`, with
218
+ `Authorization: Bearer <media.token>`: raw PCM16 little-endian, 48 kHz stereo
219
+ binary frames. The server sends JSON `start` with the format, then `ready`.
220
+ Send `{"type":"clear"}` to discard unsent speech. An `ended` frame terminates
221
+ the media socket. Both sockets are separate from `relay.websocket.run`, which
222
+ carries durable events. The SDK exposes no media-provider credentials, session
223
+ IDs, model configuration, or audio generation.
204
224
 
205
225
  ## Webhooks
206
226
 
@@ -0,0 +1,64 @@
1
+ import type { ButtonsPart } from "./types.js";
2
+ /**
3
+ * The fence tag a text-only agent uses to put buttons under its answer. The
4
+ * block body is the `items` array of a `buttons` part, exactly as the API
5
+ * takes it, so an agent that has read the contract needs nothing else:
6
+ *
7
+ * ```buttons
8
+ * [{"label": "Approve"}, {"label": "Open report", "url": "https://..."}]
9
+ * ```
10
+ */
11
+ export declare const BUTTONS_FENCE = "buttons";
12
+ /**
13
+ * When an agent should send buttons. One text, carried verbatim by every
14
+ * runtime's tool description or prompt, so a person gets buttons under the
15
+ * same conditions whichever agent they talk to. The rules are the ones the
16
+ * messaging platforms give their own business agents: Apple's quick replies
17
+ * ("avoid expecting customers to type responses that could be handled with a
18
+ * tap", 2 to 5 options), Google's RCS suggestions ("design for the most
19
+ * common responses"; never "mimic phone trees"), WhatsApp's reply and
20
+ * call-to-action buttons (distinct options; one call to action).
21
+ */
22
+ export declare const BUTTONS_GUIDANCE: string;
23
+ /**
24
+ * How a text-only agent puts buttons under its answer: the same words for
25
+ * every bridge that sends the agent's final text for it.
26
+ */
27
+ export declare const BUTTONS_BLOCK_INSTRUCTION: string;
28
+ /** The server's limits (Discord's button limits): items 1..5, label 1..80, url <= 2048. */
29
+ export declare const BUTTONS_MAX_ITEMS = 5;
30
+ export declare const BUTTON_LABEL_MAX_LENGTH = 80;
31
+ export declare const BUTTON_URL_MAX_LENGTH = 2048;
32
+ export interface SplitButtons {
33
+ /** The answer with the fenced block removed and the edges trimmed. */
34
+ text: string;
35
+ /** The buttons part the block described, when there was a valid one. */
36
+ buttons?: ButtonsPart;
37
+ /** Why a block that was there could not be used. The text then keeps it. */
38
+ error?: string;
39
+ }
40
+ /**
41
+ * Turns a decoded value, an array of items or a whole part, into a `buttons`
42
+ * part, or explains why it cannot. The checks are the server's own, so a bad
43
+ * value fails here with a readable reason instead of a 400 from the API.
44
+ */
45
+ export declare const buttonsPart: (parsed: unknown, oneTime?: boolean) => ButtonsPart | string;
46
+ /** Parses the body of a buttons block, JSON, into a `buttons` part. */
47
+ export declare const parseButtonsBlock: (body: string) => ButtonsPart | string;
48
+ /**
49
+ * Lifts the first ```buttons block out of an agent's answer. The text around
50
+ * it becomes the text part; the block becomes the buttons part. When the
51
+ * block is malformed, the answer is returned untouched with `error` set, so
52
+ * the person still gets the words and the operator sees why.
53
+ */
54
+ export declare const splitButtons: (answer: string) => SplitButtons;
55
+ /**
56
+ * The parts a text answer with optional buttons becomes: the text first, when
57
+ * there is any, then the buttons. A buttons-only message is one the server
58
+ * accepts, so an answer that is nothing but the block sends just the buttons.
59
+ */
60
+ export declare const partsWithButtons: (text: string, buttons: ButtonsPart | undefined, limit?: number) => Array<{
61
+ type: "text";
62
+ value: string;
63
+ } | ButtonsPart>;
64
+ //# sourceMappingURL=buttons.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"buttons.d.ts","sourceRoot":"","sources":["../src/buttons.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAc,WAAW,EAAE,MAAM,YAAY,CAAC;AAE1D;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,YAAY,CAAC;AAEvC;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,QAOlB,CAAC;AAEZ;;;GAGG;AACH,eAAO,MAAM,yBAAyB,QAI8F,CAAC;AAErI,2FAA2F;AAC3F,eAAO,MAAM,iBAAiB,IAAI,CAAC;AACnC,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAC1C,eAAO,MAAM,qBAAqB,OAAQ,CAAC;AAE3C,MAAM,WAAW,YAAY;IAC3B,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,wEAAwE;IACxE,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAgCD;;;;GAIG;AACH,eAAO,MAAM,WAAW,GAAI,QAAQ,OAAO,EAAE,UAAU,OAAO,KAAG,WAAW,GAAG,MAmB9E,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,iBAAiB,GAAI,MAAM,MAAM,KAAG,WAAW,GAAG,MAQ9D,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,YAAY,GAAI,QAAQ,MAAM,KAAG,YAU7C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAC3B,MAAM,MAAM,EACZ,SAAS,WAAW,GAAG,SAAS,EAChC,cAAgC,KAC/B,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,WAAW,CAGrD,CAAC"}
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The fence tag a text-only agent uses to put buttons under its answer. The
3
+ * block body is the `items` array of a `buttons` part, exactly as the API
4
+ * takes it, so an agent that has read the contract needs nothing else:
5
+ *
6
+ * ```buttons
7
+ * [{"label": "Approve"}, {"label": "Open report", "url": "https://..."}]
8
+ * ```
9
+ */
10
+ export const BUTTONS_FENCE = "buttons";
11
+ /**
12
+ * When an agent should send buttons. One text, carried verbatim by every
13
+ * runtime's tool description or prompt, so a person gets buttons under the
14
+ * same conditions whichever agent they talk to. The rules are the ones the
15
+ * messaging platforms give their own business agents: Apple's quick replies
16
+ * ("avoid expecting customers to type responses that could be handled with a
17
+ * tap", 2 to 5 options), Google's RCS suggestions ("design for the most
18
+ * common responses"; never "mimic phone trees"), WhatsApp's reply and
19
+ * call-to-action buttons (distinct options; one call to action).
20
+ */
21
+ export const BUTTONS_GUIDANCE = [
22
+ "Send buttons when your message ends with a question the person can answer by picking one of 2 to 5 short options you already know: yes or no, choosing between things you named, picking a next step, or a multiple-choice question in a quiz. Each label is a complete answer, so a tap replaces typing. Put the question in text beside the buttons.",
23
+ "Send one button when there is one thing to do next. A url button opens it inside the app: connect an account, sign in, open the page, pay. A plain button confirms one step: Start, Done, Continue. Do not paste a link or ask \"ready?\" when a single button does the job.",
24
+ "Do not send buttons when the answer is open-ended, when your options are not the full set of likely answers, or when you are not asking anything and there is nothing to do. One question or one action per message; never a menu of things you can do, and never as decoration.",
25
+ "If you would otherwise write \"reply 1, 2 or 3\", list choices for the person to type, or paste a link for them to open, send buttons instead. If the person asks for buttons, send them.",
26
+ "A tap comes back to you as an ordinary message whose text is the label. Labels are at most 80 characters.",
27
+ "Buttons disappear once tapped. Set one_time to false only for controls the person is meant to tap again and again, such as Next, Another one, or Refresh.",
28
+ ].join(" ");
29
+ /**
30
+ * How a text-only agent puts buttons under its answer: the same words for
31
+ * every bridge that sends the agent's final text for it.
32
+ */
33
+ export const BUTTONS_BLOCK_INSTRUCTION = "To put buttons under your answer, end it with a fenced code block tagged `" + BUTTONS_FENCE + "` "
34
+ + "holding a JSON array of 1 to 5 items, each {\"label\": \"...\"} or {\"label\": \"...\", \"url\": \"https://...\"}. "
35
+ + "The block is removed from the text and drawn as buttons. "
36
+ + "To keep the buttons on screen after a tap, write the block as {\"one_time\": false, \"items\": [...]} instead of a bare array.";
37
+ /** The server's limits (Discord's button limits): items 1..5, label 1..80, url <= 2048. */
38
+ export const BUTTONS_MAX_ITEMS = 5;
39
+ export const BUTTON_LABEL_MAX_LENGTH = 80;
40
+ export const BUTTON_URL_MAX_LENGTH = 2_048;
41
+ const FENCE = new RegExp("(^|\\n)[ \\t]*```[ \\t]*" + BUTTONS_FENCE + "[ \\t]*\\r?\\n([\\s\\S]*?)\\r?\\n[ \\t]*```[ \\t]*(?=\\r?\\n|$)", "u");
42
+ const asItem = (value, index) => {
43
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
44
+ return `item ${index + 1} is not an object`;
45
+ }
46
+ const record = value;
47
+ const keys = Object.keys(record).filter((key) => key !== "label" && key !== "url");
48
+ if (keys.length > 0)
49
+ return `item ${index + 1} has unknown field ${keys[0]}`;
50
+ const { label, url } = record;
51
+ if (typeof label !== "string" || label.length === 0)
52
+ return `item ${index + 1} needs a label`;
53
+ if (label.length > BUTTON_LABEL_MAX_LENGTH) {
54
+ return `item ${index + 1} label is over ${BUTTON_LABEL_MAX_LENGTH} characters`;
55
+ }
56
+ if (url === undefined)
57
+ return { label };
58
+ if (typeof url !== "string" || url.length > BUTTON_URL_MAX_LENGTH) {
59
+ return `item ${index + 1} url is not a string of at most ${BUTTON_URL_MAX_LENGTH} characters`;
60
+ }
61
+ try {
62
+ const parsed = new URL(url);
63
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
64
+ throw new Error();
65
+ }
66
+ catch {
67
+ return `item ${index + 1} url is not an http(s) URL`;
68
+ }
69
+ return { url, label };
70
+ };
71
+ /**
72
+ * Turns a decoded value, an array of items or a whole part, into a `buttons`
73
+ * part, or explains why it cannot. The checks are the server's own, so a bad
74
+ * value fails here with a readable reason instead of a 400 from the API.
75
+ */
76
+ export const buttonsPart = (parsed, oneTime) => {
77
+ const wrapped = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
78
+ ? parsed
79
+ : undefined;
80
+ const items = Array.isArray(parsed) ? parsed : Array.isArray(wrapped?.items) ? wrapped.items : undefined;
81
+ if (items === undefined)
82
+ return "the buttons block must be a JSON array of items";
83
+ const one_time = oneTime ?? wrapped?.one_time;
84
+ if (one_time !== undefined && typeof one_time !== "boolean")
85
+ return "one_time must be true or false";
86
+ if (items.length === 0)
87
+ return "the buttons block has no items";
88
+ if (items.length > BUTTONS_MAX_ITEMS) {
89
+ return `the buttons block has ${items.length} items; the most is ${BUTTONS_MAX_ITEMS}`;
90
+ }
91
+ const result = [];
92
+ for (const [index, value] of items.entries()) {
93
+ const item = asItem(value, index);
94
+ if (typeof item === "string")
95
+ return item;
96
+ result.push(item);
97
+ }
98
+ return { type: "buttons", items: result, ...(one_time === undefined ? {} : { one_time }) };
99
+ };
100
+ /** Parses the body of a buttons block, JSON, into a `buttons` part. */
101
+ export const parseButtonsBlock = (body) => {
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(body);
105
+ }
106
+ catch {
107
+ return "the buttons block is not valid JSON";
108
+ }
109
+ return buttonsPart(parsed);
110
+ };
111
+ /**
112
+ * Lifts the first ```buttons block out of an agent's answer. The text around
113
+ * it becomes the text part; the block becomes the buttons part. When the
114
+ * block is malformed, the answer is returned untouched with `error` set, so
115
+ * the person still gets the words and the operator sees why.
116
+ */
117
+ export const splitButtons = (answer) => {
118
+ const match = FENCE.exec(answer);
119
+ if (!match)
120
+ return { text: answer };
121
+ const parsed = parseButtonsBlock(match[2] ?? "");
122
+ if (typeof parsed === "string")
123
+ return { text: answer, error: parsed };
124
+ const start = match.index + (match[1]?.length ?? 0);
125
+ const before = answer.slice(0, start).replace(/\s+$/u, "");
126
+ const after = answer.slice(match.index + match[0].length).replace(/^\s+/u, "");
127
+ const text = before && after ? `${before}\n\n${after}` : before || after;
128
+ return { text, buttons: parsed };
129
+ };
130
+ /**
131
+ * The parts a text answer with optional buttons becomes: the text first, when
132
+ * there is any, then the buttons. A buttons-only message is one the server
133
+ * accepts, so an answer that is nothing but the block sends just the buttons.
134
+ */
135
+ export const partsWithButtons = (text, buttons, limit = Number.POSITIVE_INFINITY) => [
136
+ ...(text.length > 0 ? [{ type: "text", value: text.slice(0, limit) }] : []),
137
+ ...(buttons ? [buttons] : []),
138
+ ];
139
+ //# sourceMappingURL=buttons.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"buttons.js","sourceRoot":"","sources":["../src/buttons.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ import type { CallRoomAnswerFrame, CallRoomClientFrame, CallRoomEndedFrame, CallRoomErrorFrame, CallRoomOfferFrame, CallRoomStateFrame } from "./types.js";
2
+ import type { WebSocketConstructor } from "./websocket.js";
3
+ export interface CallRoomOptions {
4
+ WebSocket?: WebSocketConstructor;
5
+ /** Clients send `heartbeat` every 15 s (the room's hibernation auto-response). */
6
+ heartbeatIntervalMs?: number;
7
+ }
8
+ export interface CallRoomEvents {
9
+ roomState: (frame: CallRoomStateFrame) => void;
10
+ offer: (frame: CallRoomOfferFrame) => void;
11
+ answer: (frame: CallRoomAnswerFrame) => void;
12
+ ended: (frame: CallRoomEndedFrame) => void;
13
+ /** A server `error` frame, an upgrade failure, or an invalid frame (closed 4400). */
14
+ error: (error: CallRoomErrorFrame | Error) => void;
15
+ close: (event: {
16
+ code: number;
17
+ reason: string;
18
+ }) => void;
19
+ }
20
+ /**
21
+ * One participant's socket to a Call room. Opens `GET /v1/calls/{callId}/room`
22
+ * with the same bearer as every REST route, sends `join`, and keeps `state`
23
+ * at the latest `roomState` the room pushed. Nothing is polled.
24
+ */
25
+ export declare class CallRoom {
26
+ #private;
27
+ readonly url: string;
28
+ /** The latest `roomState` frame, or null before the room has answered `join`. */
29
+ state: CallRoomStateFrame | null;
30
+ constructor(baseURL: string, callID: string, token: string, options?: CallRoomOptions);
31
+ get closed(): boolean;
32
+ on<K extends keyof CallRoomEvents>(event: K, listener: CallRoomEvents[K]): this;
33
+ off<K extends keyof CallRoomEvents>(event: K, listener: CallRoomEvents[K]): this;
34
+ /** Send one client frame from the room contract. */
35
+ send(frame: CallRoomClientFrame): void;
36
+ /** Callee only: ringing → connecting. */
37
+ accept(): void;
38
+ /** Callee only: ringing → ended(declined). */
39
+ decline(): void;
40
+ /** Either side: ringing → ended(canceled) by the caller, else ended(completed). */
41
+ end(): void;
42
+ /** "My remote track is playing"; the second side's `connected` makes the Call active. */
43
+ connected(): void;
44
+ /** Mute state for the other side's screen; the room answers with `roomState`. */
45
+ userUpdate(update: {
46
+ muted: boolean;
47
+ }): void;
48
+ close(code?: number, reason?: string): void;
49
+ }
50
+ //# sourceMappingURL=calls-room.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"calls-room.d.ts","sourceRoot":"","sources":["../src/calls-room.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAEV,mBAAmB,EACnB,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,EAElB,kBAAkB,EAGlB,kBAAkB,EACnB,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,oBAAoB,EAAiB,MAAM,gBAAgB,CAAC;AAE1E,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,EAAE,oBAAoB,CAAC;IACjC,kFAAkF;IAClF,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,CAAC;IAC/C,KAAK,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,CAAC;IAC3C,MAAM,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,IAAI,CAAC;IAC7C,KAAK,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,CAAC;IAC3C,qFAAqF;IACrF,KAAK,EAAE,CAAC,KAAK,EAAE,kBAAkB,GAAG,KAAK,KAAK,IAAI,CAAC;IACnD,KAAK,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CAC1D;AAsID;;;;GAIG;AACH,qBAAa,QAAQ;;IACnB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,iFAAiF;IACjF,KAAK,EAAE,kBAAkB,GAAG,IAAI,CAAQ;gBAStC,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,eAAoB;IAkB/B,IAAI,MAAM,IAAI,OAAO,CAEpB;IAED,EAAE,CAAC,CAAC,SAAS,MAAM,cAAc,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,cAAc,CAAC,CAAC,CAAC,GAAG,IAAI;IAO/E,GAAG,CAAC,CAAC,SAAS,MAAM,cAAc,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,cAAc,CAAC,CAAC,CAAC,GAAG,IAAI;IAKhF,oDAAoD;IACpD,IAAI,CAAC,KAAK,EAAE,mBAAmB,GAAG,IAAI;IAKtC,yCAAyC;IACzC,MAAM,IAAI,IAAI;IAId,8CAA8C;IAC9C,OAAO,IAAI,IAAI;IAIf,mFAAmF;IACnF,GAAG,IAAI,IAAI;IAIX,yFAAyF;IACzF,SAAS,IAAI,IAAI;IAIjB,iFAAiF;IACjF,UAAU,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI;IAI5C,KAAK,CAAC,IAAI,SAAO,EAAE,MAAM,SAAkB,GAAG,IAAI;CA4InD"}
@@ -0,0 +1,319 @@
1
+ import NodeWebSocket from "ws";
2
+ import { RelayAPIError } from "./errors.js";
3
+ const HEARTBEAT_INTERVAL_MS = 15_000;
4
+ /** The exact text frame the room answers from the runtime, never by code. */
5
+ const HEARTBEAT_FRAME = JSON.stringify({ type: "heartbeat" });
6
+ const CLIENT_CLOSE_INVALID_FRAME = 4400;
7
+ const CALL_STATUSES = new Set(["ringing", "connecting", "active", "ended"]);
8
+ const END_REASONS = new Set([
9
+ "completed", "declined", "canceled", "no_answer", "disconnected", "failed",
10
+ ]);
11
+ const ROOM_ERROR_CODES = new Set(["invalid_frame", "not_allowed", "media_unavailable"]);
12
+ const TRACKS = new Set(["microphone", "agent-voice"]);
13
+ const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
14
+ const hasKeys = (value, required, optional = []) => {
15
+ const keys = Object.keys(value);
16
+ return required.every((key) => keys.includes(key))
17
+ && keys.every((key) => required.includes(key) || optional.includes(key));
18
+ };
19
+ const isSessionDescription = (value, type) => isRecord(value)
20
+ && hasKeys(value, ["type", "sdp"])
21
+ && value.type === type
22
+ && typeof value.sdp === "string"
23
+ && value.sdp.length > 0;
24
+ const isCall = (value) => isRecord(value)
25
+ && typeof value.id === "string"
26
+ && typeof value.chat_id === "string"
27
+ && isRecord(value.from)
28
+ && Array.isArray(value.to)
29
+ && value.mode === "audio"
30
+ && CALL_STATUSES.has(String(value.status))
31
+ && Number.isInteger(value.revision)
32
+ && typeof value.created_at === "string"
33
+ && (value.end_reason === null || END_REASONS.has(String(value.end_reason)));
34
+ const isParticipant = (value) => isRecord(value)
35
+ && hasKeys(value, ["contact_id", "kind", "attached", "track", "muted", "connected"])
36
+ && typeof value.contact_id === "string"
37
+ && (value.kind === "user" || value.kind === "agent")
38
+ && typeof value.attached === "boolean"
39
+ && (value.track === null || TRACKS.has(String(value.track)))
40
+ && typeof value.muted === "boolean"
41
+ && typeof value.connected === "boolean";
42
+ const isMedia = (value) => isRecord(value)
43
+ && hasKeys(value, ["url", "token", "expires_at", "audio_format"])
44
+ && typeof value.url === "string"
45
+ && typeof value.token === "string"
46
+ && typeof value.expires_at === "string"
47
+ && isRecord(value.audio_format)
48
+ && value.audio_format.encoding === "pcm_s16le"
49
+ && value.audio_format.sample_rate === 48_000
50
+ && value.audio_format.channels === 2;
51
+ const parseServerFrame = (value) => {
52
+ if (!isRecord(value) || typeof value.type !== "string")
53
+ return undefined;
54
+ switch (value.type) {
55
+ case "roomState":
56
+ return hasKeys(value, ["type", "call", "participants"], ["media"])
57
+ && isCall(value.call)
58
+ && Array.isArray(value.participants)
59
+ && value.participants.every(isParticipant)
60
+ && (!Object.hasOwn(value, "media") || isMedia(value.media))
61
+ ? value
62
+ : undefined;
63
+ case "answer":
64
+ return hasKeys(value, ["type", "session_description"])
65
+ && isSessionDescription(value.session_description, "answer")
66
+ ? value
67
+ : undefined;
68
+ case "offer":
69
+ return hasKeys(value, ["type", "session_description", "track"])
70
+ && isSessionDescription(value.session_description, "offer")
71
+ && TRACKS.has(String(value.track))
72
+ ? value
73
+ : undefined;
74
+ case "ended":
75
+ return hasKeys(value, ["type", "reason"]) && END_REASONS.has(String(value.reason))
76
+ ? value
77
+ : undefined;
78
+ case "error":
79
+ return hasKeys(value, ["type", "code", "message"])
80
+ && ROOM_ERROR_CODES.has(String(value.code))
81
+ && typeof value.message === "string"
82
+ ? value
83
+ : undefined;
84
+ case "heartbeat":
85
+ return hasKeys(value, ["type"]) ? { type: "heartbeat" } : undefined;
86
+ default:
87
+ return undefined;
88
+ }
89
+ };
90
+ const deriveRoomURL = (baseURL, callID) => {
91
+ let url;
92
+ try {
93
+ url = new URL(baseURL);
94
+ }
95
+ catch {
96
+ throw new TypeError("Relay baseURL must be an absolute HTTP(S) URL.");
97
+ }
98
+ if ((url.protocol !== "http:" && url.protocol !== "https:")
99
+ || url.username !== ""
100
+ || url.password !== "") {
101
+ throw new TypeError("Relay baseURL must be an absolute HTTP(S) URL.");
102
+ }
103
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
104
+ url.pathname = `/v1/calls/${encodeURIComponent(callID)}/room`;
105
+ url.search = "";
106
+ url.hash = "";
107
+ return url.toString();
108
+ };
109
+ /**
110
+ * One participant's socket to a Call room. Opens `GET /v1/calls/{callId}/room`
111
+ * with the same bearer as every REST route, sends `join`, and keeps `state`
112
+ * at the latest `roomState` the room pushed. Nothing is polled.
113
+ */
114
+ export class CallRoom {
115
+ url;
116
+ /** The latest `roomState` frame, or null before the room has answered `join`. */
117
+ state = null;
118
+ #socket;
119
+ #listeners = new Map();
120
+ #heartbeatIntervalMs;
121
+ #heartbeat;
122
+ #open = false;
123
+ #closed = false;
124
+ constructor(baseURL, callID, token, options = {}) {
125
+ if (!callID.trim())
126
+ throw new TypeError("A Call id is required to join its room.");
127
+ if (!token.trim())
128
+ throw new TypeError("A Relay token is required to join a Call room.");
129
+ this.url = deriveRoomURL(baseURL, callID);
130
+ this.#heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
131
+ const Constructor = options.WebSocket
132
+ ?? NodeWebSocket;
133
+ this.#socket = new Constructor(this.url, {
134
+ headers: { Authorization: `Bearer ${token}` },
135
+ });
136
+ this.#socket.addEventListener("open", this.#onOpen);
137
+ this.#socket.addEventListener("message", this.#onMessage);
138
+ this.#socket.addEventListener("close", this.#onClose);
139
+ this.#socket.addEventListener("error", this.#onError);
140
+ this.#socket.on?.("unexpected-response", this.#onUnexpectedResponse);
141
+ }
142
+ get closed() {
143
+ return this.#closed;
144
+ }
145
+ on(event, listener) {
146
+ const listeners = this.#listeners.get(event) ?? new Set();
147
+ listeners.add(listener);
148
+ this.#listeners.set(event, listeners);
149
+ return this;
150
+ }
151
+ off(event, listener) {
152
+ this.#listeners.get(event)?.delete(listener);
153
+ return this;
154
+ }
155
+ /** Send one client frame from the room contract. */
156
+ send(frame) {
157
+ if (this.#closed)
158
+ throw new Error("Relay Call room socket is closed.");
159
+ this.#socket.send(JSON.stringify(frame));
160
+ }
161
+ /** Callee only: ringing → connecting. */
162
+ accept() {
163
+ this.send({ type: "accept" });
164
+ }
165
+ /** Callee only: ringing → ended(declined). */
166
+ decline() {
167
+ this.send({ type: "decline" });
168
+ }
169
+ /** Either side: ringing → ended(canceled) by the caller, else ended(completed). */
170
+ end() {
171
+ this.send({ type: "end" });
172
+ }
173
+ /** "My remote track is playing"; the second side's `connected` makes the Call active. */
174
+ connected() {
175
+ this.send({ type: "connected" });
176
+ }
177
+ /** Mute state for the other side's screen; the room answers with `roomState`. */
178
+ userUpdate(update) {
179
+ this.send({ type: "userUpdate", muted: update.muted });
180
+ }
181
+ close(code = 1000, reason = "client closed") {
182
+ if (this.#closed)
183
+ return;
184
+ this.#stopHeartbeat();
185
+ try {
186
+ this.#socket.close(code, reason);
187
+ }
188
+ catch {
189
+ this.#finish({ code, reason });
190
+ }
191
+ }
192
+ #emit(event, ...args) {
193
+ for (const listener of this.#listeners.get(event) ?? [])
194
+ listener(...args);
195
+ }
196
+ #stopHeartbeat() {
197
+ if (this.#heartbeat !== undefined)
198
+ clearInterval(this.#heartbeat);
199
+ this.#heartbeat = undefined;
200
+ }
201
+ #finish(event) {
202
+ if (this.#closed)
203
+ return;
204
+ this.#closed = true;
205
+ this.#stopHeartbeat();
206
+ this.#socket.removeEventListener("open", this.#onOpen);
207
+ this.#socket.removeEventListener("message", this.#onMessage);
208
+ this.#socket.removeEventListener("close", this.#onClose);
209
+ this.#socket.removeEventListener("error", this.#onError);
210
+ this.#socket.off?.("unexpected-response", this.#onUnexpectedResponse);
211
+ this.#emit("close", event);
212
+ }
213
+ #invalid(message) {
214
+ this.#emit("error", new Error(`Relay Call room received an invalid frame: ${message}`));
215
+ this.close(CLIENT_CLOSE_INVALID_FRAME, "invalid frame");
216
+ }
217
+ #onOpen = () => {
218
+ if (this.#open || this.#closed)
219
+ return;
220
+ this.#open = true;
221
+ this.send({ type: "join" });
222
+ this.#heartbeat = setInterval(() => {
223
+ try {
224
+ this.#socket.send(HEARTBEAT_FRAME);
225
+ }
226
+ catch (cause) {
227
+ this.#emit("error", cause instanceof Error ? cause : new Error(String(cause)));
228
+ this.close(1000, "heartbeat failed");
229
+ }
230
+ }, this.#heartbeatIntervalMs);
231
+ };
232
+ #onMessage = (message) => {
233
+ if (this.#closed)
234
+ return;
235
+ let value;
236
+ try {
237
+ const data = message.data;
238
+ const textData = typeof data === "string"
239
+ ? data
240
+ : data instanceof ArrayBuffer || ArrayBuffer.isView(data)
241
+ ? new TextDecoder().decode(data)
242
+ : undefined;
243
+ if (textData === undefined) {
244
+ this.#invalid("non-text frame");
245
+ return;
246
+ }
247
+ value = JSON.parse(textData);
248
+ }
249
+ catch {
250
+ this.#invalid("invalid JSON");
251
+ return;
252
+ }
253
+ const frame = parseServerFrame(value);
254
+ if (frame === undefined) {
255
+ this.#invalid(isRecord(value) && typeof value.type === "string"
256
+ ? `unexpected ${value.type} frame`
257
+ : "missing type");
258
+ return;
259
+ }
260
+ switch (frame.type) {
261
+ case "roomState":
262
+ this.state = frame;
263
+ this.#emit("roomState", frame);
264
+ return;
265
+ case "offer":
266
+ this.#emit("offer", frame);
267
+ return;
268
+ case "answer":
269
+ this.#emit("answer", frame);
270
+ return;
271
+ case "ended":
272
+ this.#emit("ended", frame);
273
+ // The room closes 1000 "Call ended" right after; closing here too
274
+ // is idempotent and frees the timer without waiting for it.
275
+ this.close(1000, "Call ended");
276
+ return;
277
+ case "error":
278
+ this.#emit("error", frame);
279
+ return;
280
+ case "heartbeat":
281
+ return;
282
+ }
283
+ };
284
+ #onClose = (event) => {
285
+ this.#finish({ code: event.code ?? 1006, reason: event.reason ?? "" });
286
+ };
287
+ #onError = () => {
288
+ if (this.#closed)
289
+ return;
290
+ this.#emit("error", new Error("Relay Call room connection failed."));
291
+ };
292
+ #onUnexpectedResponse = (_request, response) => {
293
+ const chunks = [];
294
+ response.setEncoding?.("utf8");
295
+ response.on("data", (chunk) => {
296
+ if (chunks.join("").length < 65_536)
297
+ chunks.push(String(chunk));
298
+ });
299
+ const settle = () => {
300
+ const status = response.statusCode ?? 0;
301
+ let message = `Relay Call room upgrade failed with HTTP ${status}.`;
302
+ let body = chunks.join("");
303
+ try {
304
+ body = JSON.parse(chunks.join(""));
305
+ if (isRecord(body) && isRecord(body.error) && typeof body.error.message === "string") {
306
+ message = body.error.message;
307
+ }
308
+ }
309
+ catch {
310
+ // Keep the unparsed body for diagnostics.
311
+ }
312
+ this.#emit("error", new RelayAPIError(message, { status, body }));
313
+ this.#finish({ code: 1006, reason: `HTTP ${status}` });
314
+ };
315
+ response.on("end", settle);
316
+ response.on("error", settle);
317
+ };
318
+ }
319
+ //# sourceMappingURL=calls-room.js.map