@hainiva/agent-sdk 0.0.0-stage → 0.2.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/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/SECURITY.md +33 -0
- package/dist/cjs/agent.cjs +316 -0
- package/dist/cjs/agent.d.cts +235 -0
- package/dist/cjs/calls.cjs +277 -0
- package/dist/cjs/calls.d.cts +232 -0
- package/dist/cjs/contract.cjs +14 -0
- package/dist/cjs/contract.d.cts +215 -0
- package/dist/cjs/embed.cjs +11 -0
- package/dist/cjs/embed.d.cts +13 -0
- package/dist/cjs/emitter.cjs +50 -0
- package/dist/cjs/emitter.d.cts +22 -0
- package/dist/cjs/errors.cjs +28 -0
- package/dist/cjs/errors.d.cts +15 -0
- package/dist/cjs/events.cjs +97 -0
- package/dist/cjs/events.d.cts +97 -0
- package/dist/cjs/index.cjs +72 -0
- package/dist/cjs/index.d.cts +28 -0
- package/dist/cjs/livekit.cjs +56 -0
- package/dist/cjs/livekit.d.cts +35 -0
- package/dist/cjs/log.cjs +15 -0
- package/dist/cjs/log.d.cts +5 -0
- package/dist/cjs/react-native.cjs +27 -0
- package/dist/cjs/react-native.d.cts +38 -0
- package/dist/cjs/redact.cjs +71 -0
- package/dist/cjs/redact.d.cts +21 -0
- package/dist/cjs/rest.cjs +280 -0
- package/dist/cjs/rest.d.cts +166 -0
- package/dist/cjs/session.cjs +159 -0
- package/dist/cjs/session.d.cts +101 -0
- package/dist/cjs/singleFlight.cjs +49 -0
- package/dist/cjs/singleFlight.d.cts +32 -0
- package/dist/cjs/url.cjs +39 -0
- package/dist/cjs/url.d.cts +13 -0
- package/dist/cjs/web.cjs +28 -0
- package/dist/cjs/web.d.cts +23 -0
- package/dist/cjs/ws.cjs +246 -0
- package/dist/cjs/ws.d.cts +134 -0
- package/dist/esm/agent.d.ts +236 -0
- package/dist/esm/agent.d.ts.map +1 -0
- package/dist/esm/agent.js +313 -0
- package/dist/esm/agent.js.map +1 -0
- package/dist/esm/calls.d.ts +233 -0
- package/dist/esm/calls.d.ts.map +1 -0
- package/dist/esm/calls.js +271 -0
- package/dist/esm/calls.js.map +1 -0
- package/dist/esm/contract.d.ts +216 -0
- package/dist/esm/contract.d.ts.map +1 -0
- package/dist/esm/contract.js +12 -0
- package/dist/esm/contract.js.map +1 -0
- package/dist/esm/embed.d.ts +14 -0
- package/dist/esm/embed.d.ts.map +1 -0
- package/dist/esm/embed.js +9 -0
- package/dist/esm/embed.js.map +1 -0
- package/dist/esm/emitter.d.ts +23 -0
- package/dist/esm/emitter.d.ts.map +1 -0
- package/dist/esm/emitter.js +47 -0
- package/dist/esm/emitter.js.map +1 -0
- package/dist/esm/errors.d.ts +16 -0
- package/dist/esm/errors.d.ts.map +1 -0
- package/dist/esm/errors.js +25 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/events.d.ts +98 -0
- package/dist/esm/events.d.ts.map +1 -0
- package/dist/esm/events.js +90 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.d.ts +29 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +29 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/livekit.d.ts +36 -0
- package/dist/esm/livekit.d.ts.map +1 -0
- package/dist/esm/livekit.js +54 -0
- package/dist/esm/livekit.js.map +1 -0
- package/dist/esm/log.d.ts +6 -0
- package/dist/esm/log.d.ts.map +1 -0
- package/dist/esm/log.js +13 -0
- package/dist/esm/log.js.map +1 -0
- package/dist/esm/react-native.d.ts +39 -0
- package/dist/esm/react-native.d.ts.map +1 -0
- package/dist/esm/react-native.js +23 -0
- package/dist/esm/react-native.js.map +1 -0
- package/dist/esm/redact.d.ts +22 -0
- package/dist/esm/redact.d.ts.map +1 -0
- package/dist/esm/redact.js +66 -0
- package/dist/esm/redact.js.map +1 -0
- package/dist/esm/rest.d.ts +167 -0
- package/dist/esm/rest.d.ts.map +1 -0
- package/dist/esm/rest.js +275 -0
- package/dist/esm/rest.js.map +1 -0
- package/dist/esm/session.d.ts +102 -0
- package/dist/esm/session.d.ts.map +1 -0
- package/dist/esm/session.js +155 -0
- package/dist/esm/session.js.map +1 -0
- package/dist/esm/singleFlight.d.ts +33 -0
- package/dist/esm/singleFlight.d.ts.map +1 -0
- package/dist/esm/singleFlight.js +46 -0
- package/dist/esm/singleFlight.js.map +1 -0
- package/dist/esm/url.d.ts +14 -0
- package/dist/esm/url.d.ts.map +1 -0
- package/dist/esm/url.js +37 -0
- package/dist/esm/url.js.map +1 -0
- package/dist/esm/web.d.ts +24 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +23 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/esm/ws.d.ts +135 -0
- package/dist/esm/ws.d.ts.map +1 -0
- package/dist/esm/ws.js +242 -0
- package/dist/esm/ws.js.map +1 -0
- package/package.json +80 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@hainiva/agent-sdk` are listed here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/). While the version is `0.x`, a minor
|
|
6
|
+
release (`0.2.0`) may change the API; a patch release (`0.1.1`) never does.
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.0]
|
|
11
|
+
|
|
12
|
+
Preview, not yet published.
|
|
13
|
+
|
|
14
|
+
- `HainivaAgent`: one object for a staff member's app — `connect()`, `me`,
|
|
15
|
+
`setStatus`, `rings.pending`, `calls.accept/decline/end/hold/mute/transfer`,
|
|
16
|
+
`joinMedia` (through the `/web` or `/react-native` LiveKit adapter),
|
|
17
|
+
`conversations.*`, `messages.*`, `wrapUp`, `dispositions`, `supervisor.*`,
|
|
18
|
+
`push.register/unregister`, `on(event)`, `disconnect({ signOut })`.
|
|
19
|
+
- `AgentRestClient`: every `/agent/v1` endpoint, typed with the Agent API
|
|
20
|
+
contract (`AgentMeDto`, `AgentAcceptDto`, …, exported). Errors are
|
|
21
|
+
`HainivaAgentError` from the `{error:{code,message}}` envelope. A 401
|
|
22
|
+
`token_expired` refreshes once (single flight) and retries; a refused refresh
|
|
23
|
+
signs in again with a new assertion; `session_revoked` is final. Every POST
|
|
24
|
+
carries an `Idempotency-Key` (reused by the retry). Timeouts and
|
|
25
|
+
`AbortSignal`. Ids are checked before they go into a path.
|
|
26
|
+
- `/agent/v1/ws` bindings (`agentSocketBindings`): auth first frame with the
|
|
27
|
+
resume position, `token.refresh`, `session.expiring` (refresh at once),
|
|
28
|
+
`resync_required` (rings refetched, `resync` event), close codes 4001 / 4003
|
|
29
|
+
/ 4009. `call.*` events drive the `CallStore`.
|
|
30
|
+
- `SessionManager.invalidate(refusedToken)` ignores a refusal of an older token;
|
|
31
|
+
`SessionManager.clear()` forgets a session the server already ended.
|
|
32
|
+
- `ReconnectingSocket` `closeCodes.fatal`: close codes that stop reconnecting.
|
|
33
|
+
|
|
34
|
+
## [0.1.0]
|
|
35
|
+
|
|
36
|
+
Preview, not yet published.
|
|
37
|
+
|
|
38
|
+
- `SessionManager`: sign-in with a fresh assertion from your backend every
|
|
39
|
+
time, pluggable `SessionStore` (memory by default), single-flight refresh
|
|
40
|
+
before expiry, new sign-in when a refresh is refused.
|
|
41
|
+
- `ReconnectingSocket`: first-frame auth, exponential backoff with jitter,
|
|
42
|
+
heartbeat with dead-connection timeout, `resume {lastSeq}` hook, duplicate
|
|
43
|
+
frame suppression, revoked / token-expired close codes, pluggable frames.
|
|
44
|
+
- `CallStore` / `transition`: the agent call state machine (ringing,
|
|
45
|
+
connecting, active, held, transferring, ended; concurrent rings; ring expiry).
|
|
46
|
+
- `TypedEmitter`, `singleFlight`, `createTokenRefresher`, `assertSafeUrl`,
|
|
47
|
+
`Redactor`, `HainivaAgentError` (redacted).
|
|
48
|
+
- `/web`: `localStorageStore` (opt-in), `joinCallRoom` (LiveKit).
|
|
49
|
+
- `/react-native`: `secureStoreSessionStore`, `joinCallRoom` (LiveKit).
|
|
50
|
+
- `/embed`: placeholder, throws.
|
|
51
|
+
|
|
52
|
+
[Unreleased]: https://github.com/hainiva/agent-sdk/compare/v0.2.0...HEAD
|
|
53
|
+
[0.2.0]: https://github.com/hainiva/agent-sdk/compare/v0.1.0...v0.2.0
|
|
54
|
+
[0.1.0]: https://github.com/hainiva/agent-sdk/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ePacific Telecom (HAINIVA)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,202 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hainiva/agent-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Build **your own staff app** on [HAINIVA AICC](https://hainiva.com): your staff
|
|
4
|
+
sign in with an assertion from your backend, receive their rings and
|
|
5
|
+
conversations over one reconnecting socket, and take calls with LiveKit — in a
|
|
6
|
+
browser, in React Native, or in Node.
|
|
7
|
+
|
|
8
|
+
> **Status: 0.x preview.** The API may change in a minor release until 1.0.
|
|
9
|
+
|
|
10
|
+
| Import | What | Needs |
|
|
11
|
+
| --- | --- | --- |
|
|
12
|
+
| `@hainiva/agent-sdk` | `HainivaAgent`, the REST client, the socket, the call state machine, redaction | nothing (no LiveKit, DOM or React Native) |
|
|
13
|
+
| `@hainiva/agent-sdk/web` | `joinCallRoom` (LiveKit), `localStorageStore` (opt-in) | `livekit-client` |
|
|
14
|
+
| `@hainiva/agent-sdk/react-native` | `joinCallRoom`, `secureStoreSessionStore` | `livekit-client`, `@livekit/react-native`, `expo-secure-store` |
|
|
15
|
+
| `@hainiva/agent-sdk/embed` | the embeddable desk | not yet available (throws) |
|
|
16
|
+
|
|
17
|
+
Native ringing on phones (CallKit / Android call notification) is
|
|
18
|
+
[`@hainiva/react-native-call`](https://github.com/hainiva/react-native-call).
|
|
19
|
+
Samples: [`examples/`](./examples) — a Node assertion signer, an Expo staff
|
|
20
|
+
app and a web softphone.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install @hainiva/agent-sdk livekit-client
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { HainivaAgent } from "@hainiva/agent-sdk";
|
|
32
|
+
import { joinCallRoom } from "@hainiva/agent-sdk/web";
|
|
33
|
+
|
|
34
|
+
const agent = new HainivaAgent({
|
|
35
|
+
baseUrl: "https://api.hainiva.com",
|
|
36
|
+
tenantId: "YOUR_ORGANISATION_ID",
|
|
37
|
+
// Ask YOUR backend for a fresh assertion — every time (see below).
|
|
38
|
+
getAssertion: () => fetch("/my-backend/hainiva-assertion", { method: "POST", credentials: "include" })
|
|
39
|
+
.then((r) => r.json()).then((j) => j.assertion),
|
|
40
|
+
media: joinCallRoom,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
agent.on("call.ringing", (r) => showRing(r.callId, r.contactName ?? r.callerNumber));
|
|
44
|
+
agent.on("calls", (state) => render(state)); // the call state machine
|
|
45
|
+
agent.on("wrapup.required", (w) => askForWrapUp(w.callId));
|
|
46
|
+
agent.on("revoked", () => showSignIn()); // HAINIVA ended the session
|
|
47
|
+
|
|
48
|
+
const me = await agent.connect(); // sign in, me, socket, rings waiting
|
|
49
|
+
await agent.setStatus("online");
|
|
50
|
+
|
|
51
|
+
await agent.calls.accept(callId); // answers and joins the room
|
|
52
|
+
await agent.calls.hold(callId, true);
|
|
53
|
+
await agent.calls.transfer(callId, { kind: "queue", queueId, name: "Support" });
|
|
54
|
+
await agent.calls.end(callId);
|
|
55
|
+
await agent.wrapUp(callId, { dispositionCode: "resolved", notes: "…" });
|
|
56
|
+
|
|
57
|
+
await agent.disconnect({ signOut: true });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Everything else is on the same object: `conversations.list/get/assign/release/
|
|
61
|
+
transfer/close`, `messages.list/send/typing`, `dispositions()`,
|
|
62
|
+
`supervisor.liveCalls/agents/supervise/setMode`, `push.register/unregister`,
|
|
63
|
+
`rings.pending()`, `joinMedia(callId)` (re-join with a fresh LiveKit token).
|
|
64
|
+
`agent.rest` is the typed REST client for every `/agent/v1` endpoint, and the
|
|
65
|
+
response types (`AgentMeDto`, `AgentConversationDto`, …) are exported.
|
|
66
|
+
|
|
67
|
+
## Sign-in: your backend vouches for your staff member
|
|
68
|
+
|
|
69
|
+
Your backend holds the agent app's **private key** (HAINIVA stores only the
|
|
70
|
+
public key, or reads your `jwksUrl`). After it has authenticated the staff
|
|
71
|
+
member with **your own** login, it signs a short assertion:
|
|
72
|
+
|
|
73
|
+
| | |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| header | `alg: "ES256"` (or `RS256`), `kid`: the key id you registered |
|
|
76
|
+
| `iss` | your agent app id |
|
|
77
|
+
| `sub` | the member's `externalId` (`PUT /v1/members/by-external-id/:externalId`) |
|
|
78
|
+
| `aud` | `hainiva-agent` |
|
|
79
|
+
| `jti` | unique: each assertion works once |
|
|
80
|
+
| `iat` / `exp` | now / at most 5 minutes ahead |
|
|
81
|
+
|
|
82
|
+
Roles never come from the assertion — HAINIVA uses the role set on the member.
|
|
83
|
+
The SDK calls `getAssertion` every time it needs a new session and never caches
|
|
84
|
+
or stores the assertion. See [`examples/node-signer`](./examples/node-signer)
|
|
85
|
+
for key generation and a complete signer.
|
|
86
|
+
|
|
87
|
+
**Sessions.** An access token lasts 15 minutes and is refreshed before it
|
|
88
|
+
expires (one refresh however many requests are waiting; the refresh token
|
|
89
|
+
rotates). A request answered `401 token_expired` refreshes and is retried once.
|
|
90
|
+
A refused refresh (expired, revoked, reused) signs in again with a new
|
|
91
|
+
assertion. `401 session_revoked` — signed out elsewhere, member suspended, app
|
|
92
|
+
paused — is final: the SDK forgets the session and emits `revoked`.
|
|
93
|
+
|
|
94
|
+
## Events
|
|
95
|
+
|
|
96
|
+
`agent.on(name, fn)` — from the event socket (only this member's events,
|
|
97
|
+
filtered by HAINIVA):
|
|
98
|
+
|
|
99
|
+
| Event | Data |
|
|
100
|
+
| --- | --- |
|
|
101
|
+
| `call.ringing` | `{callId, conversationId, path: direct\|queue\|transfer, channel, line, contactName, callerNumber, transferMode?, expiresAt}` |
|
|
102
|
+
| `call.ended` | `{callId \| null, conversationId, reason: ended\|failed\|answered_elsewhere\|declined\|expired\|transfer_ended}` |
|
|
103
|
+
| `conversation.assigned` | `{conversationId, callId, channel, contactName}` |
|
|
104
|
+
| `message.created` | `{conversationId, interactionId, channel, preview, contentType, senderName}` |
|
|
105
|
+
| `wrapup.required` | `{callId, conversationId, deadlineAt}` |
|
|
106
|
+
| `presence.changed` | `{callId, participantId, role, presence}` |
|
|
107
|
+
| `supervisor.alert` | the alert (supervisors) |
|
|
108
|
+
| `event.unknown` | an event name newer than this SDK |
|
|
109
|
+
|
|
110
|
+
And from the SDK itself: `connection` (socket state), `ready`, `calls` (the
|
|
111
|
+
call state), `callEffect` (stop a native ring, leave media — media is handled
|
|
112
|
+
for you), `resync` (events were missed beyond what HAINIVA keeps: rings are
|
|
113
|
+
refetched, refetch your own lists), `session.expiring`, `revoked`, `error`.
|
|
114
|
+
|
|
115
|
+
The socket authenticates in its **first frame** (the token is never in the
|
|
116
|
+
URL), resumes from the last event after a reconnect, sends a refreshed token
|
|
117
|
+
without reconnecting, and reconnects with backoff — except when HAINIVA revoked
|
|
118
|
+
the session or the member has too many sockets open.
|
|
119
|
+
|
|
120
|
+
## Calls
|
|
121
|
+
|
|
122
|
+
`agent.callsState` / the `calls` event is the HAINIVA Agent app's call state
|
|
123
|
+
machine: one call at a time, concurrent rings, ring expiry, decline, taken
|
|
124
|
+
elsewhere, caller hanging up while you answer, hold, mute, warm and blind
|
|
125
|
+
transfer. `CallStore` and the pure `transition(state, event)` are exported for
|
|
126
|
+
Redux, Zustand or tests.
|
|
127
|
+
|
|
128
|
+
## Push: rings with YOUR app's own credentials
|
|
129
|
+
|
|
130
|
+
Phones ring through **your** app with **your** Firebase / APNs credentials.
|
|
131
|
+
|
|
132
|
+
1. **Upload them** on the agent app — Organization → Developer → Agent apps →
|
|
133
|
+
the bell, or `PUT /v1/agent-apps/:id/push-credentials` (key scope
|
|
134
|
+
`agent_apps:write`):
|
|
135
|
+
```jsonc
|
|
136
|
+
{
|
|
137
|
+
"fcm": { "serviceAccount": { /* the service-account key file */ }, "expiresAt": "2027-11-01T00:00:00Z" },
|
|
138
|
+
"apns": { "keyP8": "<contents of AuthKey_XXXXXXXXXX.p8>", "keyId": "XXXXXXXXXX", "teamId": "YYYYYYYYYY",
|
|
139
|
+
"topic": "com.example.agentapp", "environment": "production" }
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
HAINIVA checks each with Google / Apple before saving (a refusal names the
|
|
143
|
+
problem: `fcm_auth_failed`, `apns_auth_failed`, `apns_topic_not_allowed`, …),
|
|
144
|
+
stores it encrypted and never returns it — only a fingerprint, the status
|
|
145
|
+
and the expiry you declare (your admins are alerted 14 days before, and at
|
|
146
|
+
once if Google or Apple start refusing it). The APNs `topic` must be one of
|
|
147
|
+
the agent app's `allowedBundleIds`. `null` removes a platform.
|
|
148
|
+
2. **Register each phone** after sign-in:
|
|
149
|
+
```ts
|
|
150
|
+
await agent.push.register({ platform: "android", token: fcmToken, kind: "voip", bundleId: "com.example.agentapp", deviceId });
|
|
151
|
+
await agent.push.register({ platform: "ios", token: pushKitToken, kind: "voip", bundleId: "com.example.agentapp", deviceId });
|
|
152
|
+
```
|
|
153
|
+
A phone stops ringing when its sign-in ends; call `push.unregister(token)`
|
|
154
|
+
before signing out anyway.
|
|
155
|
+
3. **Show the ring** with `@hainiva/react-native-call` — Android: the FCM data
|
|
156
|
+
message `{type: "call", tenantId, interactionId, conversationId, path,
|
|
157
|
+
caller, line, expiresAt}` (and `call_cancel`); iPhone: a VoIP push to
|
|
158
|
+
`<topic>.voip` with the same fields, reported to CallKit natively. See
|
|
159
|
+
[`examples/expo-agent-app`](./examples/expo-agent-app).
|
|
160
|
+
|
|
161
|
+
## Web
|
|
162
|
+
|
|
163
|
+
Add your page's exact origin to the agent app's `allowedOrigins`. HAINIVA
|
|
164
|
+
answers the browser's CORS preflight only for origins an agent app lists, and
|
|
165
|
+
checks every request against the origins of the app its session belongs to.
|
|
166
|
+
|
|
167
|
+
The session is kept **in memory** by default: a reload signs in again with a
|
|
168
|
+
fresh assertion. `localStorageStore()` keeps it across reloads, but **any
|
|
169
|
+
script on your origin can read localStorage, refresh token included** — use it
|
|
170
|
+
only with a strict Content-Security-Policy and no third-party scripts.
|
|
171
|
+
|
|
172
|
+
## React Native
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import * as SecureStore from "expo-secure-store";
|
|
176
|
+
import { joinCallRoom, secureStoreSessionStore } from "@hainiva/agent-sdk/react-native";
|
|
177
|
+
const agent = new HainivaAgent({ …, store: secureStoreSessionStore(SecureStore), media: joinCallRoom });
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The session lives in the iOS Keychain (readable after first unlock, so a ring
|
|
181
|
+
that wakes a locked phone finds it; never migrated to another device) or the
|
|
182
|
+
Android Keystore. There is no fallback to a plain store. Call
|
|
183
|
+
`registerGlobals()` from `@livekit/react-native` once at startup.
|
|
184
|
+
|
|
185
|
+
## Security
|
|
186
|
+
|
|
187
|
+
- **No tokens in URLs or logs.** Tokens travel in the `Authorization` header or
|
|
188
|
+
the socket's first frame; configured URLs are checked (TLS only, no
|
|
189
|
+
`user:pass@`, no credential-like query parameter). Errors and the `log`
|
|
190
|
+
callback are redacted (JWTs, Bearer values, token parameters, HAINIVA key
|
|
191
|
+
prefixes and the exact tokens the SDK holds). The SDK never writes to the
|
|
192
|
+
console.
|
|
193
|
+
- **Never put the private key in an app.** Only your backend signs assertions.
|
|
194
|
+
Rotate keys with `POST /v1/agent-apps/:id/rotate-credential` (the old key
|
|
195
|
+
keeps working 24 h; `immediate: true` when it leaked) and push credentials by
|
|
196
|
+
uploading new ones.
|
|
197
|
+
- Every POST carries an `Idempotency-Key`, so a retry never acts twice.
|
|
198
|
+
- See [SECURITY.md](./SECURITY.md).
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
[MIT](./LICENSE)
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Please **do not open a public issue**. Email **security@epacific.com.vn** with a
|
|
6
|
+
description, the affected version and steps to reproduce. We acknowledge
|
|
7
|
+
reports within 2 business days and keep you informed until a fix ships.
|
|
8
|
+
|
|
9
|
+
## Supported versions
|
|
10
|
+
|
|
11
|
+
The latest minor release receives security fixes.
|
|
12
|
+
|
|
13
|
+
## Using the SDK safely
|
|
14
|
+
|
|
15
|
+
- **Never put the agent app's private key (or a test secret) in the app.**
|
|
16
|
+
Only your backend signs staff assertions, after it has authenticated the
|
|
17
|
+
staff member itself. Keep assertions short-lived (≤ 5 minutes) with a new
|
|
18
|
+
`jti` each time; live apps use ES256/RS256.
|
|
19
|
+
- `getAssertion` must fetch a new assertion every time — do not cache one.
|
|
20
|
+
- Web: keep the default in-memory session store unless you need reloads to stay
|
|
21
|
+
signed in; `localStorageStore` exposes the refresh token to any script on
|
|
22
|
+
your origin.
|
|
23
|
+
- React Native: use `secureStoreSessionStore`; never AsyncStorage for tokens.
|
|
24
|
+
- Push credentials (Firebase service account, APNs .p8) are uploaded to
|
|
25
|
+
HAINIVA once, by your admin or your backend — never shipped in the app, never
|
|
26
|
+
committed. HAINIVA stores them encrypted and shows only fingerprints; upload
|
|
27
|
+
new ones to rotate, and remove them (`null`) if they leak.
|
|
28
|
+
- Unregister a phone's push token before signing out on it.
|
|
29
|
+
- Do not log raw socket frames or API bodies in production: they carry
|
|
30
|
+
customer data. The SDK's own `log` callback is already redacted.
|
|
31
|
+
- Every release on npm is published from GitHub Actions with
|
|
32
|
+
[provenance](https://docs.npmjs.com/generating-provenance-statements); check it
|
|
33
|
+
with `npm audit signatures`.
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HainivaAgent = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* HainivaAgent — one object for a staff member's app: sign-in with your
|
|
6
|
+
* backend's assertion, the REST API, the live event socket and the agent's
|
|
7
|
+
* calls, wired together.
|
|
8
|
+
*
|
|
9
|
+
* const agent = new HainivaAgent({ baseUrl, tenantId, getAssertion, media: joinCallRoom });
|
|
10
|
+
* agent.on("call.ringing", (r) => showRing(r));
|
|
11
|
+
* await agent.connect();
|
|
12
|
+
* await agent.calls.accept(callId); // answers, then joins the room with `media`
|
|
13
|
+
*
|
|
14
|
+
* Transport-agnostic: LiveKit, the DOM and React Native come in through options
|
|
15
|
+
* (`media`, `WebSocket`, `store`), so the core runs in Node too.
|
|
16
|
+
*/
|
|
17
|
+
const calls_js_1 = require("./calls.cjs");
|
|
18
|
+
const emitter_js_1 = require("./emitter.cjs");
|
|
19
|
+
const errors_js_1 = require("./errors.cjs");
|
|
20
|
+
const events_js_1 = require("./events.cjs");
|
|
21
|
+
const log_js_1 = require("./log.cjs");
|
|
22
|
+
const redact_js_1 = require("./redact.cjs");
|
|
23
|
+
const rest_js_1 = require("./rest.cjs");
|
|
24
|
+
const session_js_1 = require("./session.cjs");
|
|
25
|
+
const ws_js_1 = require("./ws.cjs");
|
|
26
|
+
/**
|
|
27
|
+
* The staff member's app: session, REST, socket and calls.
|
|
28
|
+
*/
|
|
29
|
+
class HainivaAgent {
|
|
30
|
+
constructor(o) {
|
|
31
|
+
this.o = o;
|
|
32
|
+
this.socket = null;
|
|
33
|
+
this.meDto = null;
|
|
34
|
+
this.media = new Map();
|
|
35
|
+
this.offs = [];
|
|
36
|
+
this.rings = {
|
|
37
|
+
/** Rings waiting for you now (also fed into the call state). */
|
|
38
|
+
pending: () => this.syncRings(),
|
|
39
|
+
};
|
|
40
|
+
this.calls = {
|
|
41
|
+
/**
|
|
42
|
+
* Answer: the server's answer race decides (409 = someone else was
|
|
43
|
+
* faster). With `media`, joins the room and returns the session.
|
|
44
|
+
*/
|
|
45
|
+
accept: async (callId, ring) => {
|
|
46
|
+
const known = this.callStore.state.calls[callId];
|
|
47
|
+
if (!known || known.phase === "ended") {
|
|
48
|
+
// Answered from a push (CallKit / the Android call notification) before
|
|
49
|
+
// the socket told us about the ring: the state machine learns it now.
|
|
50
|
+
this.callStore.dispatch({ type: "ring", call: {
|
|
51
|
+
id: callId, direction: "inbound", path: "queue", from: ring?.from ?? "",
|
|
52
|
+
...(ring?.conversationId ? { conversationId: ring.conversationId } : {}), expiresAt: Date.now() + this.ringMs,
|
|
53
|
+
} });
|
|
54
|
+
}
|
|
55
|
+
this.callStore.dispatch({ type: "accept", id: callId });
|
|
56
|
+
let accepted;
|
|
57
|
+
try {
|
|
58
|
+
accepted = await this.guard(this.rest.acceptCall(callId));
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
const err = errors_js_1.HainivaAgentError.from(e, "accept_failed", this.redactor);
|
|
62
|
+
const reason = err.status === 409 ? "answered_elsewhere" : err.status === 404 || err.code === "call_ended" ? "gone" : "failed";
|
|
63
|
+
this.callStore.dispatch({ type: "accept_failed", id: callId, reason, error: err.code });
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
if (!accepted.media || !this.o.media) {
|
|
67
|
+
this.callStore.dispatch({ type: "connected", id: callId, answered: true });
|
|
68
|
+
return { accepted, media: null };
|
|
69
|
+
}
|
|
70
|
+
const media = await this.joinMedia(callId, accepted.media);
|
|
71
|
+
return { accepted, media };
|
|
72
|
+
},
|
|
73
|
+
decline: async (callId) => {
|
|
74
|
+
this.callStore.dispatch({ type: "decline", id: callId });
|
|
75
|
+
return this.guard(this.rest.declineCall(callId));
|
|
76
|
+
},
|
|
77
|
+
/** Hang up here: media is left at once, then the server ends the call. */
|
|
78
|
+
end: async (callId) => {
|
|
79
|
+
this.callStore.dispatch({ type: "hang_up", id: callId });
|
|
80
|
+
return this.guard(this.rest.endCall(callId));
|
|
81
|
+
},
|
|
82
|
+
hold: async (callId, on) => {
|
|
83
|
+
const r = await this.guard(this.rest.holdCall(callId, on));
|
|
84
|
+
this.callStore.dispatch(on ? { type: "hold", id: callId } : { type: "resume", id: callId });
|
|
85
|
+
return r;
|
|
86
|
+
},
|
|
87
|
+
mute: async (callId, muted) => {
|
|
88
|
+
await this.media.get(callId)?.setMuted(muted);
|
|
89
|
+
this.callStore.dispatch({ type: "mute", id: callId, muted });
|
|
90
|
+
},
|
|
91
|
+
/** Warm or blind to a colleague, to a queue (always blind), or to a phone number. */
|
|
92
|
+
transfer: async (callId, target, mode = "warm") => {
|
|
93
|
+
this.callStore.dispatch({ type: "transfer_start", id: callId, mode, target });
|
|
94
|
+
try {
|
|
95
|
+
return await this.guard(this.rest.transferCall(callId, target.kind === "agent" ? { agentId: target.agentId, mode } : target.kind === "queue" ? { queueId: target.queueId } : { number: target.number, mode }));
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
this.callStore.dispatch({ type: "transfer_cancelled", id: callId, notice: "transfer_failed" });
|
|
99
|
+
throw e;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
get: (callId) => this.guard(this.rest.getCall(callId)),
|
|
103
|
+
};
|
|
104
|
+
this.conversations = {
|
|
105
|
+
list: (q) => this.guard(this.rest.listConversations(q)),
|
|
106
|
+
get: (id) => this.guard(this.rest.getConversation(id)),
|
|
107
|
+
/** Take it yourself (no agentId) or, a supervisor, give it to a colleague. */
|
|
108
|
+
assign: (id, agentId) => this.guard(this.rest.assignConversation(id, agentId)),
|
|
109
|
+
release: (id, note) => this.guard(this.rest.releaseConversation(id, note)),
|
|
110
|
+
transfer: (id, to) => this.guard(this.rest.transferConversation(id, to)),
|
|
111
|
+
close: (id) => this.guard(this.rest.closeConversation(id)),
|
|
112
|
+
};
|
|
113
|
+
this.messages = {
|
|
114
|
+
list: (conversationId, q) => this.guard(this.rest.listMessages(conversationId, q)),
|
|
115
|
+
send: (conversationId, text) => this.guard(this.rest.sendMessage(conversationId, text)),
|
|
116
|
+
typing: (conversationId) => this.guard(this.rest.typing(conversationId)),
|
|
117
|
+
};
|
|
118
|
+
this.supervisor = {
|
|
119
|
+
liveCalls: () => this.guard(this.rest.liveCalls()),
|
|
120
|
+
agents: () => this.guard(this.rest.teamAgents()),
|
|
121
|
+
/** Listen in (monitor), coach the agent (whisper) or join the call (barge). Join `media` with your adapter. */
|
|
122
|
+
supervise: (callId, mode = "monitor") => this.guard(this.rest.supervise(callId, mode)),
|
|
123
|
+
setMode: (callId, mode, supervisorTrackSid) => this.guard(this.rest.setSuperviseMode(callId, mode, supervisorTrackSid)),
|
|
124
|
+
};
|
|
125
|
+
this.push = {
|
|
126
|
+
/** Register this device's FCM token (Android) or PushKit/APNs token (iPhone) for YOUR app's credentials. */
|
|
127
|
+
register: (sub) => this.guard(this.rest.registerPush(sub)),
|
|
128
|
+
unregister: (token) => this.guard(this.rest.unregisterPush(token)),
|
|
129
|
+
};
|
|
130
|
+
this.redactor = new redact_js_1.Redactor();
|
|
131
|
+
this.log = (0, log_js_1.safeLogger)(o.log, this.redactor);
|
|
132
|
+
this.events = new emitter_js_1.TypedEmitter((e) => this.log("listener_error", e));
|
|
133
|
+
this.ringMs = o.ringTimeoutMs ?? 60000;
|
|
134
|
+
this.rest = new rest_js_1.AgentRestClient({
|
|
135
|
+
baseUrl: o.baseUrl, tenantId: o.tenantId, redactor: this.redactor,
|
|
136
|
+
...(o.fetch ? { fetch: o.fetch } : {}), ...(o.timeoutMs ? { timeoutMs: o.timeoutMs } : {}),
|
|
137
|
+
...(o.device ? { device: o.device } : {}), ...(o.log ? { log: o.log } : {}),
|
|
138
|
+
...(o.allowInsecureLocalhost ? { allowInsecureLocalhost: true } : {}),
|
|
139
|
+
});
|
|
140
|
+
this.session = new session_js_1.SessionManager({
|
|
141
|
+
getAssertion: o.getAssertion, transport: this.rest.sessions, redactor: this.redactor,
|
|
142
|
+
...(o.store ? { store: o.store } : {}), ...(o.log ? { log: o.log } : {}),
|
|
143
|
+
});
|
|
144
|
+
this.rest.useTokens(this.session);
|
|
145
|
+
this.callStore = new calls_js_1.CallStore();
|
|
146
|
+
this.callStore.events.on("change", (s) => this.events.emit("calls", s));
|
|
147
|
+
this.callStore.events.on("effect", (e) => this.onEffect(e));
|
|
148
|
+
}
|
|
149
|
+
/** The signed-in member (after connect()). */
|
|
150
|
+
get me() { return this.meDto; }
|
|
151
|
+
/** The calls now (the state machine): ringing, connecting, active, held, ended. */
|
|
152
|
+
get callsState() { return this.callStore.state; }
|
|
153
|
+
get connection() { return this.socket?.state ?? "idle"; }
|
|
154
|
+
on(event, fn) {
|
|
155
|
+
return this.events.on(event, fn);
|
|
156
|
+
}
|
|
157
|
+
/** Sign in (or restore the stored session), load who you are and the rings waiting, open the event socket. */
|
|
158
|
+
async connect() {
|
|
159
|
+
await this.session.signIn();
|
|
160
|
+
this.meDto = await this.guard(this.rest.me());
|
|
161
|
+
if (!this.socket)
|
|
162
|
+
this.openSocket();
|
|
163
|
+
this.socket.connect();
|
|
164
|
+
await this.syncRings().catch((e) => this.log("pending_rings_failed", e));
|
|
165
|
+
return this.meDto;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Close the socket and leave every call's media. `signOut: true` also ends
|
|
169
|
+
* the session on the server (and removes it from the store) — do that when
|
|
170
|
+
* the staff member signs out; unregister this device's push first.
|
|
171
|
+
*/
|
|
172
|
+
async disconnect(opts = {}) {
|
|
173
|
+
this.socket?.disconnect();
|
|
174
|
+
this.socket = null;
|
|
175
|
+
for (const off of this.offs.splice(0))
|
|
176
|
+
off();
|
|
177
|
+
await Promise.all([...this.media.values()].map((m) => m.leave().catch(() => undefined)));
|
|
178
|
+
this.media.clear();
|
|
179
|
+
if (opts.signOut)
|
|
180
|
+
await this.session.signOut();
|
|
181
|
+
}
|
|
182
|
+
setStatus(status, o) { return this.guard(this.rest.setStatus(status, o)); }
|
|
183
|
+
/** Join (or re-join) a call's media room. Without credentials, a fresh LiveKit token is minted. */
|
|
184
|
+
async joinMedia(callId, creds) {
|
|
185
|
+
if (!this.o.media)
|
|
186
|
+
throw new errors_js_1.HainivaAgentError(0, "no_media_adapter", { hint: "pass options.media = joinCallRoom from @hainiva/agent-sdk/web or /react-native" });
|
|
187
|
+
const c = creds ?? await this.guard(this.rest.mediaToken(callId));
|
|
188
|
+
this.redactor.add(c.token);
|
|
189
|
+
const previous = this.media.get(callId);
|
|
190
|
+
if (previous)
|
|
191
|
+
await previous.leave().catch(() => undefined);
|
|
192
|
+
let m;
|
|
193
|
+
try {
|
|
194
|
+
m = await this.o.media({ url: c.livekitUrl, token: c.token });
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
this.callStore.dispatch({ type: "accept_failed", id: callId, reason: "failed", error: "media_connect_failed" });
|
|
198
|
+
throw errors_js_1.HainivaAgentError.from(e, "media_connect_failed", this.redactor);
|
|
199
|
+
}
|
|
200
|
+
this.media.set(callId, m);
|
|
201
|
+
m.onDisconnected(() => {
|
|
202
|
+
if (this.media.get(callId) !== m)
|
|
203
|
+
return;
|
|
204
|
+
this.media.delete(callId);
|
|
205
|
+
this.callStore.dispatch({ type: "remote_end", id: callId });
|
|
206
|
+
});
|
|
207
|
+
// Media that comes up after the caller hung up is left at once (the machine's media.leave effect).
|
|
208
|
+
this.callStore.dispatch({ type: "connected", id: callId, answered: true });
|
|
209
|
+
return m;
|
|
210
|
+
}
|
|
211
|
+
/** After a call: a disposition code from dispositions() and notes. */
|
|
212
|
+
wrapUp(callId, w = {}) { return this.guard(this.rest.wrapUp(callId, w)); }
|
|
213
|
+
dispositions(channel) { return this.guard(this.rest.dispositions(channel)); }
|
|
214
|
+
// ── Internals ───────────────────────────────────────────────────────────────
|
|
215
|
+
/** A REST call; a final 401 session_revoked tells the app and forgets the session. */
|
|
216
|
+
async guard(p) {
|
|
217
|
+
try {
|
|
218
|
+
return await p;
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
if (e instanceof errors_js_1.HainivaAgentError && e.status === 401 && e.code === "session_revoked")
|
|
222
|
+
await this.onRevoked("session_revoked");
|
|
223
|
+
throw e;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async onRevoked(reason) {
|
|
227
|
+
this.socket?.disconnect();
|
|
228
|
+
this.socket = null;
|
|
229
|
+
await this.session.clear();
|
|
230
|
+
this.events.emit("revoked", { reason });
|
|
231
|
+
}
|
|
232
|
+
openSocket() {
|
|
233
|
+
let sock;
|
|
234
|
+
sock = new ws_js_1.ReconnectingSocket({
|
|
235
|
+
url: this.rest.wsUrl,
|
|
236
|
+
getToken: () => this.session.accessToken(),
|
|
237
|
+
onTokenRejected: () => this.session.invalidate(),
|
|
238
|
+
redactor: this.redactor,
|
|
239
|
+
...(this.o.WebSocket ? { WebSocket: this.o.WebSocket } : {}),
|
|
240
|
+
...(this.o.log ? { log: this.o.log } : {}),
|
|
241
|
+
...(this.o.allowInsecureLocalhost ? { allowInsecureLocalhost: true } : {}),
|
|
242
|
+
...(0, events_js_1.agentSocketBindings)(() => sock.lastSeq),
|
|
243
|
+
});
|
|
244
|
+
this.socket = sock;
|
|
245
|
+
this.offs.push(sock.events.on("state", (s) => this.events.emit("connection", s)), sock.events.on("error", (e) => this.events.emit("error", e)), sock.events.on("revoked", () => { void this.onRevoked("socket_revoked"); }), sock.events.on("ready", (f) => {
|
|
246
|
+
const seq = f.type === "ready" && typeof f.seq === "number" ? f.seq : 0;
|
|
247
|
+
// A first connect starts at the server's newest event: old rings are not replayed.
|
|
248
|
+
if (sock.lastSeq === 0 && seq > 0)
|
|
249
|
+
sock.setLastSeq(seq);
|
|
250
|
+
this.events.emit("ready", { seq });
|
|
251
|
+
}), sock.events.on("frame", (f) => this.onFrame(sock, f)), this.session.events.on("token", (t) => { sock.updateToken(t); }));
|
|
252
|
+
}
|
|
253
|
+
onFrame(sock, f) {
|
|
254
|
+
switch (f.type) {
|
|
255
|
+
case "event": {
|
|
256
|
+
if (!(0, events_js_1.isEventFrame)(f))
|
|
257
|
+
return;
|
|
258
|
+
for (const ev of (0, events_js_1.toCallEvents)(f, this.callStore.state, Date.now(), this.ringMs))
|
|
259
|
+
this.callStore.dispatch(ev);
|
|
260
|
+
if ((0, events_js_1.isKnownEvent)(f.event))
|
|
261
|
+
this.events.emit(f.event, f.data);
|
|
262
|
+
else
|
|
263
|
+
this.events.emit("event.unknown", f);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
case "resync_required": {
|
|
267
|
+
const from = typeof f.fromSeq === "number" && f.fromSeq >= 0 ? f.fromSeq : 0;
|
|
268
|
+
sock.setLastSeq(from);
|
|
269
|
+
void this.syncRings().catch((e) => this.log("pending_rings_failed", e));
|
|
270
|
+
this.events.emit("resync", { fromSeq: from });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
case "session.expiring":
|
|
274
|
+
this.events.emit("session.expiring", { expiresAt: String(f.expiresAt ?? "") });
|
|
275
|
+
// The server's clock decides: refresh now even if ours thinks there is time.
|
|
276
|
+
// Refreshing emits "token", which hands the new token to this socket (token.refresh).
|
|
277
|
+
this.session.invalidate(this.session.current?.accessToken);
|
|
278
|
+
void this.session.accessToken().catch((e) => this.log("refresh_failed", e));
|
|
279
|
+
return;
|
|
280
|
+
case "ack":
|
|
281
|
+
if (!f.ok)
|
|
282
|
+
this.log("socket_ack_refused", { error: f.error });
|
|
283
|
+
return;
|
|
284
|
+
default:
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** Rings waiting on the server → the call state (a cold start, a missed push, a resync). */
|
|
289
|
+
async syncRings() {
|
|
290
|
+
const rings = await this.guard(this.rest.pendingRings());
|
|
291
|
+
const now = Date.now();
|
|
292
|
+
for (const r of rings) {
|
|
293
|
+
if (!r || typeof r.callId !== "string")
|
|
294
|
+
continue;
|
|
295
|
+
this.callStore.dispatch({
|
|
296
|
+
type: "ring",
|
|
297
|
+
call: {
|
|
298
|
+
id: r.callId, conversationId: r.conversationId, direction: "inbound",
|
|
299
|
+
path: r.path === "direct" ? "direct" : "queue",
|
|
300
|
+
from: r.contactName ?? r.callerNumber ?? "", expiresAt: now + this.ringMs,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return rings;
|
|
305
|
+
}
|
|
306
|
+
onEffect(e) {
|
|
307
|
+
if (e.type === "media.leave") {
|
|
308
|
+
const m = this.media.get(e.id);
|
|
309
|
+
this.media.delete(e.id);
|
|
310
|
+
if (m)
|
|
311
|
+
void m.leave().catch(() => undefined);
|
|
312
|
+
}
|
|
313
|
+
this.events.emit("callEffect", e);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
exports.HainivaAgent = HainivaAgent;
|