emusks 2.0.17 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,324 @@
1
+ import nacl from "tweetnacl";
2
+
3
+ const utf8 = (s) => new Uint8Array(Buffer.from(s, "utf8"));
4
+ const b64 = (u8) => Buffer.from(u8).toString("base64");
5
+ const b64url = (u8) => Buffer.from(u8).toString("base64url");
6
+ const b64decode = (s) => new Uint8Array(Buffer.from(s, "base64"));
7
+ const cat = (...arrs) => {
8
+ const out = new Uint8Array(arrs.reduce((n, a) => n + a.length, 0));
9
+ let o = 0;
10
+ for (const a of arrs) {
11
+ out.set(a, o);
12
+ o += a.length;
13
+ }
14
+ return out;
15
+ };
16
+
17
+ // --- Thrift TBinaryProtocol ---
18
+ const T = { BOOL: 2, I32: 8, I64: 10, STRING: 11, STRUCT: 12, LIST: 15 };
19
+ const i32be = (n) => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
20
+ const i64be = (n) => {
21
+ let v = BigInt(n);
22
+ const b = new Array(8);
23
+ for (let i = 7; i >= 0; i--) {
24
+ b[i] = Number(v & 0xffn);
25
+ v >>= 8n;
26
+ }
27
+ return b;
28
+ };
29
+ const fieldHeader = (type, id) => [type, (id >> 8) & 0xff, id & 0xff];
30
+ const STOP = [0];
31
+ const tString = (bytes) => [...i32be(bytes.length), ...bytes];
32
+ const fStr = (id, s) => [...fieldHeader(T.STRING, id), ...tString([...utf8(s)])];
33
+ const fBin = (id, u8) => [...fieldHeader(T.STRING, id), ...tString([...u8])];
34
+ const fBool = (id, v) => [...fieldHeader(T.BOOL, id), v ? 1 : 0];
35
+ const fI32 = (id, n) => [...fieldHeader(T.I32, id), ...i32be(n)];
36
+ const fI64 = (id, n) => [...fieldHeader(T.I64, id), ...i64be(n)];
37
+ const fStruct = (id, bytes) => [...fieldHeader(T.STRUCT, id), ...bytes];
38
+ const listOfStructs = (id, structs) => [...fieldHeader(T.LIST, id), T.STRUCT, ...i32be(structs.length), ...structs.flat()];
39
+
40
+ // --- message content variants (MessageEntryContents oneof) ---
41
+ // each returns the raw bytes of a MessageEntryHolder{1: MessageEntryContents{<id>: <struct>}}
42
+ function holder(variantId, structBytes) {
43
+ const entryContents = [...fStruct(variantId, structBytes), ...STOP];
44
+ return new Uint8Array([...fStruct(1, entryContents), ...STOP]);
45
+ }
46
+ export const content = {
47
+ text: (text, extra = []) => holder(1, [...fStr(1, text), ...extra, ...STOP]),
48
+ reactionAdd: (sequenceId, emoji, attachmentId) =>
49
+ holder(2, [...fStr(1, String(sequenceId)), ...fStr(2, emoji), ...(attachmentId ? fStr(3, String(attachmentId)) : []), ...STOP]),
50
+ reactionRemove: (sequenceId, emoji, attachmentId) =>
51
+ holder(3, [...fStr(1, String(sequenceId)), ...fStr(2, emoji), ...(attachmentId ? fStr(3, String(attachmentId)) : []), ...STOP]),
52
+ edit: (sequenceId, updatedText) => holder(4, [...fStr(1, String(sequenceId)), ...fStr(2, updatedText), ...STOP]),
53
+ markRead: (sequenceId, seenAtMillis) => holder(5, [...fStr(1, String(sequenceId)), ...fI64(2, seenAtMillis), ...STOP]),
54
+ markUnread: (sequenceId) => holder(6, [...fStr(1, String(sequenceId)), ...STOP]),
55
+ pin: (conversationId) => holder(7, [...fStr(1, conversationId), ...STOP]),
56
+ unpin: (conversationId) => holder(8, [...fStr(1, conversationId), ...STOP]),
57
+ nickname: (userId, nickname) => holder(14, [...fI64(1, userId), ...fStr(2, nickname), ...STOP]),
58
+ screenCapture: (type) => holder(9, [...fI32(1, type), ...STOP]),
59
+ avCallStarted: (broadcastId, isAudioOnly) => holder(16, [...fBool(1, isAudioOnly), ...fStr(3, broadcastId), ...STOP]),
60
+ avCallEnded: (broadcastId, durationSeconds, isAudioOnly, sentAtMillis = Date.now()) =>
61
+ holder(10, [...fI64(1, sentAtMillis), ...fI64(2, durationSeconds), ...fBool(3, isAudioOnly), ...fStr(5, broadcastId), ...STOP]),
62
+ avCallMissed: (isAudioOnly, sentAtMillis = Date.now()) => holder(11, [...fI64(1, sentAtMillis), ...fBool(2, isAudioOnly), ...STOP]),
63
+ };
64
+
65
+ // DmScreenCaptureType
66
+ export const SCREEN_CAPTURE = { Unknown: 0, Screenshot: 1, Recording: 2 };
67
+
68
+ export function messageCreateEvent(frame, ckeyVersion, { shouldNotify = true, ttlMsec } = {}) {
69
+ return new Uint8Array([
70
+ ...fBin(100, frame),
71
+ ...fStr(101, ckeyVersion),
72
+ ...fBool(102, shouldNotify),
73
+ ...(ttlMsec != null ? fI64(103, ttlMsec) : []),
74
+ ...STOP,
75
+ ]);
76
+ }
77
+
78
+ // --- keys ---
79
+ const b64urlDecode = (s) => new Uint8Array(Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64"));
80
+
81
+ export async function generateKeyMaterial() {
82
+ const identity = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
83
+ const signing = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
84
+ const identitySpki = new Uint8Array(await crypto.subtle.exportKey("spki", identity.publicKey));
85
+ const signingSpki = new Uint8Array(await crypto.subtle.exportKey("spki", signing.publicKey));
86
+ const signature = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, signing.privateKey, identitySpki));
87
+ const identityPrivateJwk = await crypto.subtle.exportKey("jwk", identity.privateKey);
88
+ const signingPrivateJwk = await crypto.subtle.exportKey("jwk", signing.privateKey);
89
+ // Juicebox-backed secret = the two P-256 private scalars (identity ‖ signing)
90
+ const juiceboxSecret = cat(b64urlDecode(identityPrivateJwk.d), b64urlDecode(signingPrivateJwk.d));
91
+ return {
92
+ identity,
93
+ signing,
94
+ publicKeyB64: b64(identitySpki),
95
+ signingPublicKeyB64: b64(signingSpki),
96
+ identitySignatureB64: b64(signature),
97
+ identityPrivateJwk,
98
+ signingPrivateJwk,
99
+ juiceboxSecret,
100
+ };
101
+ }
102
+
103
+ // rebuild a P-256 private CryptoKey from its raw 32-byte scalar + the published public key (for SPKI x/y)
104
+ async function importPrivFromScalar(scalar, spkiBytes, name, usages) {
105
+ const pub = await crypto.subtle.importKey("spki", spkiBytes, { name, namedCurve: "P-256" }, true, name === "ECDH" ? [] : ["verify"]);
106
+ const { x, y } = await crypto.subtle.exportKey("jwk", pub);
107
+ const d = Buffer.from(scalar).toString("base64url");
108
+ return crypto.subtle.importKey("jwk", { kty: "EC", crv: "P-256", d, x, y, ext: false }, { name, namedCurve: "P-256" }, false, usages);
109
+ }
110
+
111
+ // reconstruct the identity + signing keys from a recovered Juicebox secret (idScalar ‖ signScalar)
112
+ export async function recoverIdentityKeys({ publicKeyB64, signingPublicKeyB64, secret }) {
113
+ const idScalar = secret.slice(0, 32);
114
+ const identityKey = await importPrivFromScalar(idScalar, b64decode(publicKeyB64), "ECDH", ["deriveBits"]);
115
+ let signingKey = null;
116
+ if (secret.length >= 64 && signingPublicKeyB64) {
117
+ signingKey = await importPrivFromScalar(secret.slice(32, 64), b64decode(signingPublicKeyB64), "ECDSA", ["sign"]);
118
+ }
119
+ return { identityKey, signingKey };
120
+ }
121
+
122
+ export async function importIdentity({ identityPrivateJwk, signingPrivateJwk }) {
123
+ return {
124
+ identityKey: await crypto.subtle.importKey("jwk", identityPrivateJwk, { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]),
125
+ signingKey: await crypto.subtle.importKey("jwk", signingPrivateJwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]),
126
+ };
127
+ }
128
+
129
+ // --- ECIES wrap of the conversation key to a recipient (P-256) ---
130
+ export async function eciesWrap(cKey, recipientSpki) {
131
+ const pub = await crypto.subtle.importKey("spki", recipientSpki, { name: "ECDH", namedCurve: "P-256" }, false, []);
132
+ const eph = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
133
+ const z = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: pub }, eph.privateKey, 256));
134
+ const ephRaw = new Uint8Array(await crypto.subtle.exportKey("raw", eph.publicKey));
135
+ const out = new Uint8Array(await crypto.subtle.digest("SHA-256", cat(z, new Uint8Array([0, 0, 0, 1]), ephRaw)));
136
+ const key = await crypto.subtle.importKey("raw", out.slice(0, 16), { name: "AES-GCM" }, false, ["encrypt"]);
137
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: out.slice(16, 32) }, key, cKey));
138
+ return b64(cat(ephRaw, ct));
139
+ }
140
+
141
+ // --- body cipher (libsodium crypto_secretbox_easy == tweetnacl secretbox) ---
142
+ export function encryptBody(plaintext, cKey) {
143
+ const nonce = crypto.getRandomValues(new Uint8Array(24));
144
+ return cat(nonce, nacl.secretbox(plaintext, nonce, cKey));
145
+ }
146
+
147
+ // --- event signature (ECDSA P-256 / SHA-256), signature_version 7: the format the app uses ---
148
+ // payload = ["MessageCreateEvent", conversation_token, sender, conversationId, cKeyVersion, base64url(frame)]
149
+ // struct carries signing_public_key (field 4) and a key-info list of just the sender (with its signing key).
150
+ export async function eventSignature({ signingKey, signingPublicKeyB64, conversationToken = "", senderId, conversationId, conversationKeyVersion, frame, myVersion }) {
151
+ const payload = ["MessageCreateEvent", conversationToken, String(senderId), String(conversationId), String(conversationKeyVersion), b64url(frame)].join(",");
152
+ const sig = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, signingKey, utf8(payload)));
153
+ const keyInfo = [...fStr(1, String(senderId)), ...fStr(2, String(myVersion)), ...fStr(3, signingPublicKeyB64), ...STOP];
154
+ const struct = new Uint8Array([
155
+ ...fStr(1, b64url(sig)),
156
+ ...fStr(2, String(myVersion)),
157
+ ...fStr(3, "7"),
158
+ ...fStr(4, signingPublicKeyB64),
159
+ ...listOfStructs(5, [keyInfo]),
160
+ ...STOP,
161
+ ]);
162
+ return b64(struct);
163
+ }
164
+
165
+ // the server's encoded_message_event is a Thrift MessageEvent whose field 1 is the sequence_id string
166
+ export function readLeadingSequenceId(b64str) {
167
+ try {
168
+ const b = b64decode(b64str);
169
+ if (b[0] === 0x0b && b[1] === 0x00 && b[2] === 0x01) {
170
+ const len = (b[3] << 24) | (b[4] << 16) | (b[5] << 8) | b[6];
171
+ return Buffer.from(b.slice(7, 7 + len)).toString("utf8");
172
+ }
173
+ } catch {}
174
+ return null;
175
+ }
176
+
177
+ const listOfStrings = (id, strings) => [
178
+ ...fieldHeader(T.LIST, id),
179
+ T.STRING,
180
+ ...i32be(strings.length),
181
+ ...strings.flatMap((s) => tString([...utf8(String(s))])),
182
+ ];
183
+
184
+ // MessageEventDetail{7: MessageDeleteEvent{1: sequence_ids, 2: delete_message_action}}
185
+ export function deleteEventDetail(sequenceIds, actionValue) {
186
+ const deleteEvent = [...listOfStrings(1, sequenceIds), ...fI32(2, actionValue), ...STOP];
187
+ return new Uint8Array([...fStruct(7, deleteEvent), ...STOP]);
188
+ }
189
+
190
+ // build an ActionSignatureInput (a GraphQL JSON object) for admin/mutation actions
191
+ export async function buildActionSignature({ signingKey, signingPublicKeyB64, typeName, conversationToken = "", senderId, conversationId, dataElements = [], eventDetailBytes, myVersion }) {
192
+ const payload = [typeName, conversationToken, String(senderId), String(conversationId), ...dataElements.map(String)].join(",");
193
+ const sig = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, signingKey, utf8(payload)));
194
+ return {
195
+ chat_fanout_behavior_version: "V1",
196
+ message_id: crypto.randomUUID(),
197
+ message_event_signature: {
198
+ signature: b64url(sig),
199
+ public_key_version: String(myVersion),
200
+ signature_version: "7",
201
+ signing_public_key: signingPublicKeyB64,
202
+ message_signing_key_info_list: [
203
+ { member_id: String(senderId), public_key_version: String(myVersion), signing_public_key: signingPublicKeyB64 },
204
+ ],
205
+ },
206
+ encoded_message_event_detail: eventDetailBytes ? b64url(eventDetailBytes) : undefined,
207
+ signature_payload: payload,
208
+ };
209
+ }
210
+
211
+ // read a top-level string field by id from a base64 Thrift struct (only walks leading scalar fields)
212
+ export function readStringField(b64str, fieldId) {
213
+ try {
214
+ const b = b64decode(b64str);
215
+ let i = 0;
216
+ while (i < b.length) {
217
+ const type = b[i];
218
+ if (type === 0) break;
219
+ const id = (b[i + 1] << 8) | b[i + 2];
220
+ i += 3;
221
+ if (type === 11) {
222
+ const len = (b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3];
223
+ i += 4;
224
+ const val = Buffer.from(b.slice(i, i + len)).toString("utf8");
225
+ i += len;
226
+ if (id === fieldId) return val;
227
+ } else if (type === 2) i += 1;
228
+ else if (type === 8) i += 4;
229
+ else if (type === 10) i += 8;
230
+ else break;
231
+ }
232
+ } catch {}
233
+ return null;
234
+ }
235
+
236
+ // --- Thrift TBinaryProtocol parser (for reading inbound events) ---
237
+ function readValue(buf, pos, type) {
238
+ switch (type) {
239
+ case 2: return [buf[pos] !== 0, pos + 1];
240
+ case 3: return [buf[pos], pos + 1];
241
+ case 8: return [(buf[pos] << 24) | (buf[pos + 1] << 16) | (buf[pos + 2] << 8) | buf[pos + 3], pos + 4];
242
+ case 10: {
243
+ let v = 0n;
244
+ for (let k = 0; k < 8; k++) v = (v << 8n) | BigInt(buf[pos + k]);
245
+ return [v, pos + 8];
246
+ }
247
+ case 11: {
248
+ const len = (buf[pos] << 24) | (buf[pos + 1] << 16) | (buf[pos + 2] << 8) | buf[pos + 3];
249
+ const start = pos + 4;
250
+ return [buf.slice(start, start + len), start + len];
251
+ }
252
+ case 12: return parseStruct(buf, pos);
253
+ case 15: {
254
+ const elemType = buf[pos];
255
+ const count = (buf[pos + 1] << 24) | (buf[pos + 2] << 16) | (buf[pos + 3] << 8) | buf[pos + 4];
256
+ let p = pos + 5;
257
+ const arr = [];
258
+ for (let k = 0; k < count; k++) {
259
+ const [v, np] = readValue(buf, p, elemType);
260
+ arr.push(v);
261
+ p = np;
262
+ }
263
+ return [arr, p];
264
+ }
265
+ case 13: {
266
+ const keyType = buf[pos];
267
+ const valType = buf[pos + 1];
268
+ const count = (buf[pos + 2] << 24) | (buf[pos + 3] << 16) | (buf[pos + 4] << 8) | buf[pos + 5];
269
+ let p = pos + 6;
270
+ const map = {};
271
+ for (let k = 0; k < count; k++) {
272
+ const [key, p1] = readValue(buf, p, keyType);
273
+ const [val, p2] = readValue(buf, p1, valType);
274
+ map[Buffer.from(key).toString("utf8")] = val;
275
+ p = p2;
276
+ }
277
+ return [map, p];
278
+ }
279
+ default: throw new Error(`thrift: unknown type ${type} at ${pos}`);
280
+ }
281
+ }
282
+ function parseStruct(buf, pos) {
283
+ const fields = {};
284
+ let p = pos;
285
+ while (p < buf.length) {
286
+ const type = buf[p];
287
+ if (type === 0) {
288
+ p += 1;
289
+ break;
290
+ }
291
+ const id = (buf[p + 1] << 8) | buf[p + 2];
292
+ const [val, np] = readValue(buf, p + 3, type);
293
+ fields[id] = val;
294
+ p = np;
295
+ }
296
+ return [fields, p];
297
+ }
298
+ export function parseThrift(bytes) {
299
+ return parseStruct(bytes, 0)[0];
300
+ }
301
+ export const thriftStr = (v) => (v == null ? null : Buffer.from(v).toString("utf8"));
302
+
303
+ // inverse of eciesWrap: recover the cKey with the viewer's identity (ECDH) private key
304
+ export async function eciesUnwrap(wrappedB64, identityPrivateKey) {
305
+ const all = b64decode(wrappedB64);
306
+ const ephRaw = all.slice(0, 65);
307
+ const ct = all.slice(65);
308
+ const ephPub = await crypto.subtle.importKey("raw", ephRaw, { name: "ECDH", namedCurve: "P-256" }, false, []);
309
+ const z = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: ephPub }, identityPrivateKey, 256));
310
+ const out = new Uint8Array(await crypto.subtle.digest("SHA-256", cat(z, new Uint8Array([0, 0, 0, 1]), ephRaw)));
311
+ const key = await crypto.subtle.importKey("raw", out.slice(0, 16), { name: "AES-GCM" }, false, ["decrypt"]);
312
+ return new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: out.slice(16, 32) }, key, ct));
313
+ }
314
+
315
+ export function decryptBody(frame, cKey) {
316
+ return nacl.secretbox.open(frame.slice(24), frame.slice(0, 24), cKey);
317
+ }
318
+
319
+ export function conversationId1on1(a, b) {
320
+ const [x, y] = [BigInt(a), BigInt(b)].sort((p, q) => (p < q ? -1 : 1));
321
+ return `${x}:${y}`;
322
+ }
323
+
324
+ export { b64, b64decode };
@@ -0,0 +1,340 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { recordAudioSink, recordVideoSink, streamAudioFile, streamVideoFile } from "./xchat-call-media.js";
3
+
4
+ const txn = () => Math.random().toString(36).slice(2);
5
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
6
+
7
+ export function extractStreamName(jwt) {
8
+ try {
9
+ return JSON.parse(Buffer.from(String(jwt).split(".")[1], "base64").toString("utf8")).StreamName ?? "";
10
+ } catch {
11
+ return "";
12
+ }
13
+ }
14
+
15
+ // Minimal Janus REST + long-poll client (videoroom plugin). Uses the periscope session's
16
+ // TLS-fingerprinted transport. Auth = the raw Janus JWT in the Authorization header.
17
+ class JanusClient {
18
+ constructor({ http, janusUrl, jwt }) {
19
+ this.http = http;
20
+ this.janusUrl = janusUrl;
21
+ this.jwt = jwt;
22
+ this.sessionId = null;
23
+ this._poll = false;
24
+ }
25
+ async _req(url, body, method = "POST") {
26
+ let lastErr;
27
+ for (let attempt = 0; attempt < 3; attempt++) {
28
+ let r;
29
+ try {
30
+ r = await this.http(url, { method, body, headers: { "Content-Type": "application/json", Authorization: this.jwt } });
31
+ } catch (e) {
32
+ lastErr = e;
33
+ await new Promise((res) => setTimeout(res, 500));
34
+ continue;
35
+ }
36
+ if (r.status >= 200 && r.status <= 399) return r.json;
37
+ lastErr = new Error(`janus ${method} -> ${r.status}`);
38
+ if (r.status !== 408 && r.status !== 429 && r.status < 500) break; // only retry transient
39
+ await new Promise((res) => setTimeout(res, 500));
40
+ }
41
+ throw lastErr;
42
+ }
43
+ async create() {
44
+ const r = await this._req(this.janusUrl, { janus: "create", transaction: txn() });
45
+ this.sessionId = r?.data?.id;
46
+ return this.sessionId;
47
+ }
48
+ async attach() {
49
+ const r = await this._req(`${this.janusUrl}/${this.sessionId}`, { janus: "attach", plugin: "janus.plugin.videoroom", transaction: txn() });
50
+ return r?.data?.id;
51
+ }
52
+ message(handleId, body, jsep) {
53
+ return this._req(`${this.janusUrl}/${this.sessionId}/${handleId}`, { janus: "message", transaction: txn(), body, ...(jsep ? { jsep } : {}) });
54
+ }
55
+ trickle(handleId, candidate) {
56
+ return this._req(`${this.janusUrl}/${this.sessionId}/${handleId}`, { janus: "trickle", transaction: txn(), candidate });
57
+ }
58
+ async destroy() {
59
+ this._poll = false;
60
+ try {
61
+ await this._req(`${this.janusUrl}/${this.sessionId}`, { janus: "destroy", transaction: txn() });
62
+ } catch {}
63
+ }
64
+ startPolling(onEvent) {
65
+ this._poll = true;
66
+ (async () => {
67
+ while (this._poll) {
68
+ let r;
69
+ try {
70
+ r = await this.http(`${this.janusUrl}/${this.sessionId}?maxev=1`, { method: "GET", headers: { Authorization: this.jwt } });
71
+ } catch {
72
+ await delay(400);
73
+ continue;
74
+ }
75
+ if (!this._poll) break;
76
+ if (r.status === 404) {
77
+ onEvent({ janus: "restart" });
78
+ break;
79
+ }
80
+ if (r.json?.janus) onEvent(r.json);
81
+ else await delay(200);
82
+ }
83
+ })();
84
+ }
85
+ }
86
+
87
+ // A Janus SFU group call (1..N participants). EventEmitter: connected, track, ended, error.
88
+ export class GroupCall extends EventEmitter {
89
+ constructor({ periscope, webrtc, iceServers, janusUrl, jwt, sessionUuid, streamName, periscopeUserId, roomId, audioOnly, isHost, conversationId, recipientIds, videoCodec }) {
90
+ super();
91
+ this.periscope = periscope;
92
+ this.webrtc = webrtc;
93
+ this.iceServers = iceServers ?? [];
94
+ this.janusUrl = janusUrl;
95
+ this.jwt = jwt;
96
+ this.sessionUuid = sessionUuid ?? "";
97
+ this.streamName = streamName || extractStreamName(jwt);
98
+ this.periscopeUserId = periscopeUserId;
99
+ this.roomId = String(roomId);
100
+ this.audioOnly = !!audioOnly;
101
+ // X's app uses h264; @roamhq/wrtc only does vp8/vp9/av1. Default to vp8 so calls between
102
+ // emusks clients have working video out of the box; pass "h264" with an h264-capable engine
103
+ // for interop with the official X app.
104
+ this.videoCodec = videoCodec ?? "vp8";
105
+ this.isHost = !!isHost;
106
+ this.conversationId = conversationId ?? "";
107
+ this.recipientIds = (recipientIds ?? []).map(String);
108
+ this.connected = false;
109
+ this.startedAt = null;
110
+ this._published = false;
111
+ this._subscriberJoined = false;
112
+ this._subscribedFeeds = new Set();
113
+ this._closed = false;
114
+ this.remoteTracks = [];
115
+ }
116
+
117
+ get durationSeconds() {
118
+ return this.startedAt ? Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000)) : 0;
119
+ }
120
+
121
+ async start() {
122
+ const { RTCPeerConnection, nonstandard } = this.webrtc;
123
+ this.janus = new JanusClient({ http: this.periscope.http.bind(this.periscope), janusUrl: this.janusUrl, jwt: this.jwt });
124
+ await this.janus.create();
125
+ this.pubHandle = await this.janus.attach();
126
+ this.subHandle = await this.janus.attach();
127
+
128
+ this.pubPc = new RTCPeerConnection({ iceServers: this.iceServers });
129
+ this.subPc = new RTCPeerConnection({ iceServers: this.iceServers });
130
+ this.audioSource = new nonstandard.RTCAudioSource();
131
+ this.pubPc.addTransceiver(this.audioSource.createTrack(), { direction: "sendonly" });
132
+ if (!this.audioOnly) {
133
+ this.videoSource = new nonstandard.RTCVideoSource();
134
+ const vt = this.pubPc.addTransceiver(this.videoSource.createTrack(), { direction: "sendonly" });
135
+ try {
136
+ const caps = this.webrtc.RTCRtpSender?.getCapabilities?.("video");
137
+ if (caps?.codecs && vt.setCodecPreferences) {
138
+ const h264 = caps.codecs.filter((c) => /h264/i.test(c.mimeType));
139
+ if (h264.length) vt.setCodecPreferences([...h264, ...caps.codecs.filter((c) => !/h264/i.test(c.mimeType))]);
140
+ }
141
+ } catch {}
142
+ }
143
+
144
+ this.pubPc.onicecandidate = (e) => {
145
+ if (e.candidate?.candidate) this.janus.trickle(this.pubHandle, e.candidate.toJSON()).catch(() => {});
146
+ };
147
+ this.subPc.onicecandidate = (e) => {
148
+ if (e.candidate?.candidate) this.janus.trickle(this.subHandle, e.candidate.toJSON()).catch(() => {});
149
+ };
150
+ this.subPc.ontrack = (e) => {
151
+ this.remoteTracks.push(e.track);
152
+ this.emit("track", { kind: e.track.kind, track: e.track });
153
+ };
154
+ const onState = () => {
155
+ const s = this.pubPc.connectionState;
156
+ if ((s === "connected" || this.pubPc.iceConnectionState === "connected" || this.pubPc.iceConnectionState === "completed") && !this.connected) {
157
+ this.connected = true;
158
+ this.startedAt = Date.now();
159
+ this.emit("connected");
160
+ }
161
+ if (s === "failed" || s === "closed") this._end("connection_" + s);
162
+ };
163
+ this.pubPc.onconnectionstatechange = onState;
164
+ this.pubPc.oniceconnectionstatechange = onState;
165
+
166
+ this.janus.startPolling((ev) => this._onEvent(ev).catch((e) => this.emit("error", e)));
167
+
168
+ if (this.isHost) {
169
+ await this.janus.message(this.pubHandle, {
170
+ room: this.roomId,
171
+ periscope_user_id: this.periscopeUserId,
172
+ request: "create",
173
+ audiocodec: "opus",
174
+ videocodec: this.videoCodec,
175
+ transport_wide_cc_ext: true,
176
+ app_component: "audio-room",
177
+ h264_profile: "42e01f",
178
+ dummy_publisher: true,
179
+ });
180
+ }
181
+ await this.janus.message(this.pubHandle, {
182
+ room: this.roomId,
183
+ periscope_user_id: this.periscopeUserId,
184
+ request: "join",
185
+ ptype: "publisher",
186
+ display: this.sessionUuid || this.periscopeUserId,
187
+ session_uuid: this.sessionUuid,
188
+ stream_name: this.streamName,
189
+ vidman_token: this.jwt,
190
+ is_private: false,
191
+ });
192
+ // Janus' new-publisher push is unreliable here; actively poll the room for publishers.
193
+ this._discoverTimer = setInterval(() => this._discoverPublishers().catch(() => {}), 2500);
194
+ return this;
195
+ }
196
+
197
+ async _discoverPublishers() {
198
+ if (this._closed) return;
199
+ const r = await this.janus.message(this.pubHandle, { room: this.roomId, periscope_user_id: this.periscopeUserId, request: "listparticipants" });
200
+ const parts = r?.plugindata?.data?.participants ?? [];
201
+ if (this.listenerCount("debug")) this.emit("debug", { participants: parts.map((p) => ({ id: p.id, display: p.display, publisher: p.publisher })), me: this.publisherId });
202
+ const fresh = parts
203
+ .filter((p) => p.publisher && p.display !== "Dummy publisher" && String(p.id) !== String(this.publisherId) && !this._subscribedFeeds.has(p.id))
204
+ .map((p) => p.id);
205
+ if (fresh.length) {
206
+ fresh.forEach((f) => this._subscribedFeeds.add(f));
207
+ this.emit("debug", { discovered: fresh });
208
+ await this._subscribeTo(fresh.map((feed) => ({ feed })));
209
+ }
210
+ }
211
+
212
+ async _onEvent(j) {
213
+ if (j.janus === "restart") return; // SFU dropped us; surface via 'ended' on pc failure
214
+ const data = j.plugindata?.data;
215
+ if (data?.videoroom && data.videoroom !== "event") this.emit("debug", { videoroom: data.videoroom, hasJsep: !!j.jsep });
216
+ if (data?.videoroom === "joined") {
217
+ this.publisherId = data.id;
218
+ await this._publish();
219
+ if (this.isHost) this._goLive().catch((e) => this.emit("error", e));
220
+ if (Array.isArray(data.publishers)) await this._subscribe(data.publishers);
221
+ }
222
+ if (data && Array.isArray(data.publishers)) await this._subscribe(data.publishers);
223
+ if (j.jsep) {
224
+ if (j.sender === this.pubHandle && j.jsep.type === "answer") await this.pubPc.setRemoteDescription(j.jsep).catch((e) => this.emit("error", e));
225
+ else if (j.sender === this.subHandle && j.jsep.type === "offer") await this._answerSubscriber(j.jsep);
226
+ }
227
+ }
228
+
229
+ async _publish() {
230
+ if (this._published) return;
231
+ this._published = true;
232
+ const offer = await this.pubPc.createOffer();
233
+ await this.pubPc.setLocalDescription(offer);
234
+ await this.janus.message(
235
+ this.pubHandle,
236
+ {
237
+ room: this.roomId,
238
+ periscope_user_id: this.periscopeUserId,
239
+ request: "configure",
240
+ session_uuid: this.sessionUuid,
241
+ stream_name: this.streamName,
242
+ vidman_token: this.jwt,
243
+ audio: true,
244
+ video: !this.audioOnly,
245
+ is_private: false,
246
+ },
247
+ { type: "offer", sdp: this.pubPc.localDescription.sdp },
248
+ );
249
+ }
250
+
251
+ async _goLive() {
252
+ await this.periscope.publishBroadcast({
253
+ broadcast_id: this.roomId,
254
+ status: this.conversationId,
255
+ content_type: this.audioOnly ? "audio" : "video",
256
+ mentioned_twitter_user_ids: this.recipientIds,
257
+ narrow_cast_space_type: 3,
258
+ accept_guests: true,
259
+ janus_room_id: this.roomId,
260
+ janus_publisher_id: this.publisherId,
261
+ webrtc_handle_id: this.pubHandle,
262
+ webrtc_session_id: this.janus.sessionId,
263
+ });
264
+ this.emit("live");
265
+ }
266
+
267
+ async _subscribe(publishers) {
268
+ const streams = [];
269
+ for (const p of publishers) {
270
+ if (p.dummy || p.display === "Dummy publisher" || String(p.periscope_user_id) === String(this.periscopeUserId) || String(p.id) === String(this.publisherId)) continue;
271
+ if (this._subscribedFeeds.has(p.id)) continue;
272
+ this._subscribedFeeds.add(p.id);
273
+ const mids = (p.streams ?? []).map((s) => ({ feed: p.id, mid: s.mid }));
274
+ streams.push(...(mids.length ? mids : [{ feed: p.id }]));
275
+ }
276
+ if (streams.length) await this._subscribeTo(streams);
277
+ }
278
+
279
+ async _subscribeTo(streams) {
280
+ if (!streams.length) return;
281
+ if (!this._subscriberJoined) {
282
+ this._subscriberJoined = true;
283
+ await this.janus.message(this.subHandle, { room: this.roomId, periscope_user_id: this.periscopeUserId, request: "join", ptype: "subscriber", streams });
284
+ } else {
285
+ await this.janus.message(this.subHandle, { room: this.roomId, periscope_user_id: this.periscopeUserId, request: "subscribe", streams });
286
+ }
287
+ }
288
+
289
+ async _answerSubscriber(offer) {
290
+ await this.subPc.setRemoteDescription(offer);
291
+ const answer = await this.subPc.createAnswer();
292
+ await this.subPc.setLocalDescription(answer);
293
+ await this.janus.message(this.subHandle, { room: this.roomId, periscope_user_id: this.periscopeUserId, request: "start" }, { type: "answer", sdp: this.subPc.localDescription.sdp });
294
+ }
295
+
296
+ async sendAudioFile(filePath, opts = {}) {
297
+ if (!this.audioSource) throw new Error("call has no audio track");
298
+ return streamAudioFile(this.audioSource, filePath, opts);
299
+ }
300
+ async sendVideoFile(filePath, opts = {}) {
301
+ if (!this.videoSource) throw new Error("audio-only call; start with { video: true } to send video");
302
+ const video = await streamVideoFile(this.videoSource, filePath, opts);
303
+ let audio = null;
304
+ if (this.audioSource && opts.audio !== false) audio = await streamAudioFile(this.audioSource, filePath, opts).catch(() => null);
305
+ return { video, audio, done: Promise.all([video.done, audio?.done].filter(Boolean)) };
306
+ }
307
+ recordIncoming({ audioPath, videoPath } = {}) {
308
+ const ns = this.webrtc.nonstandard;
309
+ const recorders = {};
310
+ const attach = (track) => {
311
+ if (track.kind === "audio" && audioPath && !recorders.audio) recorders.audio = recordAudioSink(new ns.RTCAudioSink(track), audioPath);
312
+ if (track.kind === "video" && videoPath && !recorders.video) recorders.video = recordVideoSink(new ns.RTCVideoSink(track), videoPath);
313
+ };
314
+ for (const t of this.remoteTracks) attach(t);
315
+ this.on("track", ({ track }) => attach(track));
316
+ return recorders;
317
+ }
318
+
319
+ async hangup() {
320
+ await this._end("hung_up");
321
+ }
322
+ async _end(reason) {
323
+ if (this._closed) return;
324
+ this._closed = true;
325
+ this.endReason = reason;
326
+ try {
327
+ clearInterval(this._discoverTimer);
328
+ } catch {}
329
+ try {
330
+ this.pubPc?.close();
331
+ } catch {}
332
+ try {
333
+ this.subPc?.close();
334
+ } catch {}
335
+ try {
336
+ await this.janus?.destroy();
337
+ } catch {}
338
+ this.emit("ended", { reason, durationSeconds: this.durationSeconds });
339
+ }
340
+ }