@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 +39 -19
- package/dist/buttons.d.ts +64 -0
- package/dist/buttons.d.ts.map +1 -0
- package/dist/buttons.js +139 -0
- package/dist/buttons.js.map +1 -0
- package/dist/calls-room.d.ts +50 -0
- package/dist/calls-room.d.ts.map +1 -0
- package/dist/calls-room.js +319 -0
- package/dist/calls-room.js.map +1 -0
- package/dist/client.d.ts +8 -22
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +12 -31
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/operations.d.ts +0 -16
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +0 -20
- package/dist/operations.js.map +1 -1
- package/dist/types.d.ts +101 -44
- package/dist/types.d.ts.map +1 -1
- package/dist/websocket.d.ts +2 -2
- package/dist/websocket.d.ts.map +1 -1
- package/package.json +1 -1
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
`
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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"}
|
package/dist/buttons.js
ADDED
|
@@ -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
|