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.
- package/README.md +1 -1
- package/package.json +19 -2
- package/src/cycletls.js +2 -1
- package/src/flow.js +69 -5
- package/src/helpers/index.js +4 -0
- package/src/helpers/jetfuel.js +175 -0
- package/src/helpers/juicebox/chunk-BNv3lrIs.js +1 -0
- package/src/helpers/juicebox/index.js +30 -0
- package/src/helpers/juicebox/juicebox-sdk_bg.wasm +0 -0
- package/src/helpers/juicebox/sdk.js +1 -0
- package/src/helpers/xchat-call-media.js +127 -0
- package/src/helpers/xchat-calls.js +553 -0
- package/src/helpers/xchat-crypto.js +324 -0
- package/src/helpers/xchat-group-calls.js +340 -0
- package/src/helpers/xchat-juicebox.js +41 -0
- package/src/helpers/xchat-queries.js +3 -0
- package/src/helpers/xchat.js +794 -0
- package/src/index.js +2 -0
- package/src/instrumentation.js +124 -0
- package/src/jetfuel.js +92 -0
- package/src/parsers/jetfuel.js +226 -0
- package/src/v1.1.js +21 -7
- package/build/graphql.js +0 -19
- package/build/v1.1.js +0 -28
- package/build/v2.js +0 -28
- package/bun.lock +0 -93
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import getCycleTLS from "../cycletls.js";
|
|
3
|
+
import { recordAudioSink, recordVideoSink, streamAudioFile, streamVideoFile } from "./xchat-call-media.js";
|
|
4
|
+
|
|
5
|
+
const VENDOR = "m5-proxsee-login-a2011357b73e";
|
|
6
|
+
const PROXSEE = "https://proxsee.pscp.tv/api/v2";
|
|
7
|
+
const GUEST = "https://guest-cf.pscp.tv/api/v1";
|
|
8
|
+
const PERISCOPE_OP = { id: "zCYojd6h_gVXYjFlaAk4bA", name: "useDirectCallSetupQuery", doc: "query useDirectCallSetupQuery { authenticate_periscope }" };
|
|
9
|
+
|
|
10
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
11
|
+
|
|
12
|
+
let _webrtc = null;
|
|
13
|
+
export async function loadWebRTC(override) {
|
|
14
|
+
if (override) return override;
|
|
15
|
+
if (_webrtc) return _webrtc;
|
|
16
|
+
try {
|
|
17
|
+
const mod = await import("@roamhq/wrtc");
|
|
18
|
+
_webrtc = mod.default ?? mod;
|
|
19
|
+
return _webrtc;
|
|
20
|
+
} catch {
|
|
21
|
+
throw new Error(
|
|
22
|
+
"XChat calls need a WebRTC engine. Install one with `npm i @roamhq/wrtc`, or pass your own via client.xchat.useWebRTC(engine).",
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Bootstraps and holds the Periscope/guest-service session that backs calls for one client.
|
|
28
|
+
export class PeriscopeSession {
|
|
29
|
+
constructor(client) {
|
|
30
|
+
this.client = client;
|
|
31
|
+
this.cookie = null;
|
|
32
|
+
this.guestToken = null;
|
|
33
|
+
this.userType = null;
|
|
34
|
+
this.periscopeId = null;
|
|
35
|
+
this.ice = null;
|
|
36
|
+
this._ready = null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get _fp() {
|
|
40
|
+
return this.client.auth.client.fingerprints;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async _post(url, body, extraHeaders = {}) {
|
|
44
|
+
const cycleTLS = await getCycleTLS();
|
|
45
|
+
const res = await cycleTLS(
|
|
46
|
+
url,
|
|
47
|
+
{
|
|
48
|
+
headers: { "content-type": "application/json", accept: "application/json", ...extraHeaders },
|
|
49
|
+
userAgent: this._fp.userAgent,
|
|
50
|
+
ja3: this._fp.ja3,
|
|
51
|
+
ja4r: this._fp.ja4r,
|
|
52
|
+
body: JSON.stringify(body),
|
|
53
|
+
proxy: this.client.proxy || undefined,
|
|
54
|
+
},
|
|
55
|
+
"POST",
|
|
56
|
+
);
|
|
57
|
+
return { status: res.status, json: await res.json().catch(() => null) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async _periscopeJwt() {
|
|
61
|
+
const auth = this.client.auth;
|
|
62
|
+
const cycleTLS = await getCycleTLS();
|
|
63
|
+
const res = await cycleTLS(
|
|
64
|
+
`https://api.x.com/graphql/${PERISCOPE_OP.id}/${PERISCOPE_OP.name}`,
|
|
65
|
+
{
|
|
66
|
+
headers: {
|
|
67
|
+
accept: "*/*",
|
|
68
|
+
authorization: `Bearer ${auth.client.bearer}`,
|
|
69
|
+
"content-type": "application/json",
|
|
70
|
+
origin: "https://chat.x.com",
|
|
71
|
+
referer: "https://chat.x.com/",
|
|
72
|
+
"x-csrf-token": auth.csrfToken,
|
|
73
|
+
"x-twitter-active-user": "yes",
|
|
74
|
+
"x-twitter-auth-type": "OAuth2Session",
|
|
75
|
+
"x-twitter-client-language": "en",
|
|
76
|
+
cookie: auth.client.headers.cookie + (this.client.elevatedCookies ? `; ${this.client.elevatedCookies}` : ""),
|
|
77
|
+
},
|
|
78
|
+
userAgent: this._fp.userAgent,
|
|
79
|
+
ja3: this._fp.ja3,
|
|
80
|
+
ja4r: this._fp.ja4r,
|
|
81
|
+
body: JSON.stringify({ operationName: PERISCOPE_OP.name, variables: {}, query: PERISCOPE_OP.doc, queryId: PERISCOPE_OP.id }),
|
|
82
|
+
proxy: this.client.proxy || undefined,
|
|
83
|
+
},
|
|
84
|
+
"POST",
|
|
85
|
+
);
|
|
86
|
+
const json = await res.json();
|
|
87
|
+
const jwt = json?.data?.authenticate_periscope;
|
|
88
|
+
if (!jwt) throw new Error(`xchat.calls: could not get Periscope auth token${json?.errors ? `: ${json.errors[0]?.message}` : ""}`);
|
|
89
|
+
return jwt;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_prefix(name) {
|
|
93
|
+
return this.userType === "Twitter" ? `twitter/${name}` : name;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async ensure() {
|
|
97
|
+
if (this.cookie && this.guestToken) return this;
|
|
98
|
+
this._ready ??= (async () => {
|
|
99
|
+
const jwt = await this._periscopeJwt();
|
|
100
|
+
const login = await this._post(`${PROXSEE}/loginTwitterToken`, { jwt, vendor_id: VENDOR, create_user: true });
|
|
101
|
+
if (!login.json?.cookie) throw new Error(`xchat.calls: Periscope login failed (status ${login.status})`);
|
|
102
|
+
this.cookie = login.json.cookie;
|
|
103
|
+
this.userType = login.json.type;
|
|
104
|
+
this.periscopeId = login.json.user?.id ?? null;
|
|
105
|
+
const tok = await this._post(`${PROXSEE}/${this._prefix("authorizeToken")}`, { service: "guest", cookie: this.cookie });
|
|
106
|
+
this.guestToken = tok.json?.authorization_token;
|
|
107
|
+
if (!this.guestToken) throw new Error("xchat.calls: could not authorize guest service");
|
|
108
|
+
await this.refreshIceServers();
|
|
109
|
+
return this;
|
|
110
|
+
})();
|
|
111
|
+
try {
|
|
112
|
+
return await this._ready;
|
|
113
|
+
} catch (e) {
|
|
114
|
+
this._ready = null;
|
|
115
|
+
throw e;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async refreshIceServers() {
|
|
120
|
+
const turn = await this._post(`${PROXSEE}/${this._prefix("turnServers")}`, { cookie: this.cookie, p2p: true });
|
|
121
|
+
const uris = turn.json?.uris ?? [];
|
|
122
|
+
this.ice = uris.length ? [{ urls: uris, username: turn.json.username, credential: turn.json.password }] : [];
|
|
123
|
+
return this.ice;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async _guest(path, body, { signal } = {}) {
|
|
127
|
+
await this.ensure();
|
|
128
|
+
const send = () =>
|
|
129
|
+
getCycleTLS().then((c) =>
|
|
130
|
+
c(
|
|
131
|
+
`${GUEST}/${path}`,
|
|
132
|
+
{
|
|
133
|
+
headers: { "content-type": "application/json", accept: "application/json", authorization: this.guestToken },
|
|
134
|
+
userAgent: this._fp.userAgent,
|
|
135
|
+
ja3: this._fp.ja3,
|
|
136
|
+
ja4r: this._fp.ja4r,
|
|
137
|
+
body: JSON.stringify(body),
|
|
138
|
+
proxy: this.client.proxy || undefined,
|
|
139
|
+
...(signal ? { timeout: 40 } : {}),
|
|
140
|
+
},
|
|
141
|
+
"POST",
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
let res = await send();
|
|
145
|
+
if (res.status === 401) {
|
|
146
|
+
const tok = await this._post(`${PROXSEE}/${this._prefix("authorizeToken")}`, { service: "guest", cookie: this.cookie });
|
|
147
|
+
this.guestToken = tok.json?.authorization_token ?? this.guestToken;
|
|
148
|
+
res = await send();
|
|
149
|
+
}
|
|
150
|
+
return await res.json().catch(() => null);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
broadcastCreate(inviteeIds, audioOnly) {
|
|
154
|
+
return this._guest("p2p/broadcast/create", { invitees_twitter_str: inviteeIds.map(String), audio_only: !!audioOnly });
|
|
155
|
+
}
|
|
156
|
+
broadcastJoin(broadcastId) {
|
|
157
|
+
return this._guest("p2p/broadcast/join", { broadcast_id: broadcastId });
|
|
158
|
+
}
|
|
159
|
+
broadcastPublish(broadcastId) {
|
|
160
|
+
return this._guest("p2p/broadcast/publish", { broadcast_id: broadcastId });
|
|
161
|
+
}
|
|
162
|
+
broadcastLeave(broadcastId, sessionUuid, trigger = "User") {
|
|
163
|
+
return this._guest("p2p/broadcast/leave", { broadcast_id: broadcastId, session_uuid: sessionUuid, trigger });
|
|
164
|
+
}
|
|
165
|
+
broadcastStatus(broadcastId) {
|
|
166
|
+
return this._guest("p2p/broadcast/status", { broadcast_id: broadcastId });
|
|
167
|
+
}
|
|
168
|
+
signalingSend(broadcastId, recipientIdStr, message) {
|
|
169
|
+
return this._guest("signaling/send", { broadcast_id: broadcastId, recipient_id_str: String(recipientIdStr), message });
|
|
170
|
+
}
|
|
171
|
+
signalingReceive(broadcastId, cursor) {
|
|
172
|
+
return this._guest("signaling/receive", cursor != null ? { broadcast_id: broadcastId, cursor } : { broadcast_id: broadcastId }, { signal: true });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// generic TLS-fingerprinted request (used by the Janus client for the SFU REST API)
|
|
176
|
+
async http(url, { method = "POST", body, headers = {}, timeoutSec } = {}) {
|
|
177
|
+
const cycleTLS = await getCycleTLS();
|
|
178
|
+
const opts = { headers, userAgent: this._fp.userAgent, ja3: this._fp.ja3, ja4r: this._fp.ja4r, proxy: this.client.proxy || undefined };
|
|
179
|
+
if (body !== undefined) opts.body = typeof body === "string" ? body : JSON.stringify(body);
|
|
180
|
+
if (timeoutSec) opts.timeout = timeoutSec;
|
|
181
|
+
const res = await cycleTLS(url, opts, method);
|
|
182
|
+
return { status: res.status, json: await res.json().catch(() => null) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- audiospace/group broadcast lifecycle (proxsee) ---
|
|
186
|
+
async createBroadcast({ audioOnly = true, conversationId = "", region = "us-west-2" } = {}) {
|
|
187
|
+
await this.ensure();
|
|
188
|
+
const r = await this._post(`${PROXSEE}/createBroadcast`, {
|
|
189
|
+
app_component: "audio-room",
|
|
190
|
+
is_webrtc: true,
|
|
191
|
+
content_type: audioOnly ? "audio_spaces" : "video",
|
|
192
|
+
height: 720,
|
|
193
|
+
has_moderation: false,
|
|
194
|
+
width: 1080,
|
|
195
|
+
narrow_cast_space_type: 3,
|
|
196
|
+
region,
|
|
197
|
+
cookie: this.cookie,
|
|
198
|
+
languages: ["en", "ar"],
|
|
199
|
+
conversation_id: conversationId,
|
|
200
|
+
});
|
|
201
|
+
return r.json;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async publishBroadcast(body) {
|
|
205
|
+
await this.ensure();
|
|
206
|
+
const r = await this._post(`${PROXSEE}/publishBroadcast`, { cookie: this.cookie, ...body }, { "X-Idempotence": String(Date.now()), "X-Periscope-User-Agent": "Twitter/m5" });
|
|
207
|
+
return r.json;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async getAudiospace(broadcastId) {
|
|
211
|
+
await this.ensure();
|
|
212
|
+
return (await this._post(`${PROXSEE}/getAudiospace`, { broadcast_id: broadcastId, cookie: this.cookie })).json;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// guest join handshake: returns the Janus gateway for a live broadcast
|
|
216
|
+
async joinBroadcast(broadcastId, { audioOnly = true } = {}) {
|
|
217
|
+
await this.ensure();
|
|
218
|
+
const aus = await this.getAudiospace(broadcastId);
|
|
219
|
+
const sp = aus?.audioSpace ?? aus?.audio_space ?? {};
|
|
220
|
+
await this._guest("audiospace/join", { should_auto_join: false, broadcast_id: broadcastId });
|
|
221
|
+
const p2 = await this._guest("audiospace/join", { should_auto_join: true, audio_only: audioOnly, join_as_admin: false, ntp_for_live_frame: 0, ntp_for_broadcaster_frame: 0, broadcast_id: broadcastId, chat_token: "" });
|
|
222
|
+
if (!p2?.session_uuid) throw new Error("xchat.calls: audiospace join failed (broadcast not live / cannot auto-join)");
|
|
223
|
+
const neg = await this._guest("audiospace/stream/negotiate", { session_uuid: p2.session_uuid });
|
|
224
|
+
return { sessionUuid: p2.session_uuid, janusUrl: neg.webrtc_gw_url, jwt: neg.janus_jwt, encryptionInfo: sp.encryption_info, primaryAdmin: sp.primary_admin_user_id };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// One live 1:1 call. EventEmitter: 'connected', 'track', 'mediastatus', 'ended', 'error'.
|
|
229
|
+
export class XChatCall extends EventEmitter {
|
|
230
|
+
constructor({ periscope, webrtc, iceServers, role, broadcastId, sessionUuid, selfId, peerId, conversationId, audioOnly, transport }) {
|
|
231
|
+
super();
|
|
232
|
+
this.periscope = periscope;
|
|
233
|
+
this.webrtc = webrtc;
|
|
234
|
+
this.iceServers = iceServers ?? [];
|
|
235
|
+
this.transport = transport ?? null; // pluggable signaling; default is the Periscope relay
|
|
236
|
+
this.role = role; // "host" | "guest"
|
|
237
|
+
this.broadcastId = broadcastId;
|
|
238
|
+
this.sessionUuid = sessionUuid;
|
|
239
|
+
this.selfId = String(selfId);
|
|
240
|
+
this.peerId = String(peerId);
|
|
241
|
+
this.conversationId = conversationId;
|
|
242
|
+
this.audioOnly = !!audioOnly;
|
|
243
|
+
this.connected = false;
|
|
244
|
+
this.startedAt = null;
|
|
245
|
+
this._closed = false;
|
|
246
|
+
this._published = false;
|
|
247
|
+
this._candidateQueue = [];
|
|
248
|
+
this._remoteSet = false;
|
|
249
|
+
this.remoteTracks = [];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
get isHost() {
|
|
253
|
+
return this.role === "host";
|
|
254
|
+
}
|
|
255
|
+
get durationSeconds() {
|
|
256
|
+
return this.startedAt ? Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000)) : 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async start() {
|
|
260
|
+
const { RTCPeerConnection } = this.webrtc;
|
|
261
|
+
const ns = this.webrtc.nonstandard;
|
|
262
|
+
this.pc = new RTCPeerConnection({ iceServers: this.iceServers });
|
|
263
|
+
|
|
264
|
+
this.audioSource = new ns.RTCAudioSource();
|
|
265
|
+
this.pc.addTrack(this.audioSource.createTrack());
|
|
266
|
+
if (!this.audioOnly) {
|
|
267
|
+
this.videoSource = new ns.RTCVideoSource();
|
|
268
|
+
this.pc.addTrack(this.videoSource.createTrack());
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
this.pc.onicecandidate = (e) => {
|
|
272
|
+
if (!e.candidate || !e.candidate.candidate) return;
|
|
273
|
+
this._send({
|
|
274
|
+
type: "CANDIDATE",
|
|
275
|
+
message: JSON.stringify({ id: e.candidate.sdpMid, label: e.candidate.sdpMLineIndex, candidate: e.candidate.candidate }),
|
|
276
|
+
}).catch(() => {});
|
|
277
|
+
};
|
|
278
|
+
this.pc.ontrack = (e) => {
|
|
279
|
+
const track = e.track;
|
|
280
|
+
this.remoteTracks.push(track);
|
|
281
|
+
this.emit("track", { kind: track.kind, track });
|
|
282
|
+
};
|
|
283
|
+
const onState = () => {
|
|
284
|
+
const s = this.pc.connectionState;
|
|
285
|
+
if ((s === "connected" || this.pc.iceConnectionState === "connected" || this.pc.iceConnectionState === "completed") && !this.connected) {
|
|
286
|
+
this.connected = true;
|
|
287
|
+
this.startedAt = Date.now();
|
|
288
|
+
this.emit("connected");
|
|
289
|
+
}
|
|
290
|
+
if (s === "failed" || s === "closed") this._end("connection_" + s);
|
|
291
|
+
};
|
|
292
|
+
this.pc.onconnectionstatechange = onState;
|
|
293
|
+
this.pc.oniceconnectionstatechange = onState;
|
|
294
|
+
|
|
295
|
+
if (this.transport) {
|
|
296
|
+
this.transport.onMessage = (m) => this._ingest(m);
|
|
297
|
+
this.transport.start?.();
|
|
298
|
+
} else {
|
|
299
|
+
this._loop();
|
|
300
|
+
}
|
|
301
|
+
// announce presence; the host publishes + offers when it sees this
|
|
302
|
+
await this._send({ type: "MEDIA_STATUS", message: JSON.stringify({ isCameraDeactivated: this.audioOnly, isMicrophoneDeactivated: false }) }).catch(() => {});
|
|
303
|
+
return this;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
_send(message) {
|
|
307
|
+
const full = { id: cryptoRandomId(), ...message };
|
|
308
|
+
if (this.transport) return Promise.resolve(this.transport.send(full));
|
|
309
|
+
return this.periscope.signalingSend(this.broadcastId, this.peerId, full);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// unified inbound handler: host publishes + offers on first signal, then dispatch
|
|
313
|
+
_ingest(m) {
|
|
314
|
+
if (this.isHost && !this._published) {
|
|
315
|
+
this._published = true;
|
|
316
|
+
const pub = this.periscope && !this.transport ? this.periscope.broadcastPublish(this.broadcastId) : Promise.resolve();
|
|
317
|
+
pub.then(() => this._makeOffer()).catch((e) => this.emit("error", e));
|
|
318
|
+
}
|
|
319
|
+
this._handle(m).catch((e) => this.emit("error", e));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async _loop() {
|
|
323
|
+
let cursor;
|
|
324
|
+
while (!this._closed) {
|
|
325
|
+
let resp;
|
|
326
|
+
try {
|
|
327
|
+
resp = await this.periscope.signalingReceive(this.broadcastId, cursor);
|
|
328
|
+
} catch (e) {
|
|
329
|
+
if (this._closed) break;
|
|
330
|
+
await delay(800);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (this._closed) break;
|
|
334
|
+
if (resp?.cursor != null) cursor = resp.cursor;
|
|
335
|
+
const messages = resp?.messages ?? [];
|
|
336
|
+
for (const m of messages) this._ingest(m);
|
|
337
|
+
if (!messages.length) await delay(300);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async _makeOffer() {
|
|
342
|
+
const offer = await this.pc.createOffer();
|
|
343
|
+
await this.pc.setLocalDescription(offer);
|
|
344
|
+
await this._send({ type: "OFFER", message: this.pc.localDescription.sdp });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async _handle(m) {
|
|
348
|
+
switch (m.type) {
|
|
349
|
+
case "OFFER": {
|
|
350
|
+
await this.pc.setRemoteDescription({ type: "offer", sdp: m.message });
|
|
351
|
+
this._remoteSet = true;
|
|
352
|
+
await this._flushCandidates();
|
|
353
|
+
const answer = await this.pc.createAnswer();
|
|
354
|
+
await this.pc.setLocalDescription(answer);
|
|
355
|
+
await this._send({ type: "ANSWER", message: this.pc.localDescription.sdp });
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
case "ANSWER": {
|
|
359
|
+
if (this.pc.signalingState === "have-local-offer") {
|
|
360
|
+
await this.pc.setRemoteDescription({ type: "answer", sdp: m.message });
|
|
361
|
+
this._remoteSet = true;
|
|
362
|
+
await this._flushCandidates();
|
|
363
|
+
}
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
case "CANDIDATE": {
|
|
367
|
+
const c = JSON.parse(m.message);
|
|
368
|
+
const ice = { sdpMid: c.id, sdpMLineIndex: c.label, candidate: c.candidate };
|
|
369
|
+
if (this._remoteSet) await this.pc.addIceCandidate(ice).catch(() => {});
|
|
370
|
+
else this._candidateQueue.push(ice);
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case "MEDIA_STATUS": {
|
|
374
|
+
try {
|
|
375
|
+
this.emit("mediastatus", JSON.parse(m.message));
|
|
376
|
+
} catch {}
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async _flushCandidates() {
|
|
383
|
+
const q = this._candidateQueue;
|
|
384
|
+
this._candidateQueue = [];
|
|
385
|
+
for (const c of q) await this.pc.addIceCandidate(c).catch(() => {});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// push an audio file into the call (transcoded to PCM, paced in real time)
|
|
389
|
+
async sendAudioFile(filePath, opts = {}) {
|
|
390
|
+
if (!this.audioSource) throw new Error("call has no audio track");
|
|
391
|
+
const ctl = await streamAudioFile(this.audioSource, filePath, opts);
|
|
392
|
+
return ctl;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// push a video file into the call (video frames + its audio track)
|
|
396
|
+
async sendVideoFile(filePath, opts = {}) {
|
|
397
|
+
if (!this.videoSource) throw new Error("this is an audio-only call; start it with { video: true } to send video");
|
|
398
|
+
const video = await streamVideoFile(this.videoSource, filePath, opts);
|
|
399
|
+
let audio = null;
|
|
400
|
+
if (this.audioSource && opts.audio !== false) {
|
|
401
|
+
try {
|
|
402
|
+
audio = await streamAudioFile(this.audioSource, filePath, opts);
|
|
403
|
+
} catch {}
|
|
404
|
+
}
|
|
405
|
+
return { video, audio, done: Promise.all([video.done, audio?.done].filter(Boolean)) };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// record incoming media to files; returns recorders with .stop()
|
|
409
|
+
recordIncoming({ audioPath, videoPath } = {}) {
|
|
410
|
+
const ns = this.webrtc.nonstandard;
|
|
411
|
+
const recorders = {};
|
|
412
|
+
const attach = (track) => {
|
|
413
|
+
if (track.kind === "audio" && audioPath && !recorders.audio) recorders.audio = recordAudioSink(new ns.RTCAudioSink(track), audioPath);
|
|
414
|
+
if (track.kind === "video" && videoPath && !recorders.video) recorders.video = recordVideoSink(new ns.RTCVideoSink(track), videoPath);
|
|
415
|
+
};
|
|
416
|
+
for (const t of this.remoteTracks) attach(t);
|
|
417
|
+
this.on("track", ({ track }) => attach(track));
|
|
418
|
+
return recorders;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async hangup() {
|
|
422
|
+
await this._end("hung_up");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async _end(reason) {
|
|
426
|
+
if (this._closed) return;
|
|
427
|
+
this._closed = true;
|
|
428
|
+
this.endReason = reason;
|
|
429
|
+
const dur = this.durationSeconds;
|
|
430
|
+
try {
|
|
431
|
+
this.pc?.close();
|
|
432
|
+
} catch {}
|
|
433
|
+
try {
|
|
434
|
+
this.transport?.close?.();
|
|
435
|
+
} catch {}
|
|
436
|
+
try {
|
|
437
|
+
if (this.periscope && !this.transport) await this.periscope.broadcastLeave(this.broadcastId, this.sessionUuid, "User");
|
|
438
|
+
} catch {}
|
|
439
|
+
if (this.onLifecycle) {
|
|
440
|
+
try {
|
|
441
|
+
await this.onLifecycle(this.connected ? "ended" : "missed", { durationSeconds: dur, audioOnly: this.audioOnly });
|
|
442
|
+
} catch {}
|
|
443
|
+
}
|
|
444
|
+
this.emit("ended", { reason, durationSeconds: dur });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function cryptoRandomId() {
|
|
449
|
+
return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// A cross-wired pair of in-memory signaling transports (for tests / custom relays).
|
|
453
|
+
// Each delivers what the other sends, asynchronously, like the real relay.
|
|
454
|
+
export function loopbackPair() {
|
|
455
|
+
const mk = () => ({ peer: null, onMessage: null, send(m) { setTimeout(() => this.peer?.onMessage?.(m), 0); }, close() {} });
|
|
456
|
+
const a = mk();
|
|
457
|
+
const b = mk();
|
|
458
|
+
a.peer = b;
|
|
459
|
+
b.peer = a;
|
|
460
|
+
return [a, b];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// --- incoming call detection ---
|
|
464
|
+
|
|
465
|
+
// LivePipeline SSE subscription to /avcall/create/<userId>; falls back to message polling.
|
|
466
|
+
export function subscribeIncomingCalls(client, periscope, myUserId, onCall, opts = {}) {
|
|
467
|
+
const topics = [`/avcall/create/${myUserId}`, `/avcall/clear/${myUserId}`];
|
|
468
|
+
const url = `https://api.x.com/live_pipeline/events?topic=${topics.map(encodeURIComponent).join(",")}`;
|
|
469
|
+
const ac = new AbortController();
|
|
470
|
+
let stopped = false;
|
|
471
|
+
let usedFallback = false;
|
|
472
|
+
|
|
473
|
+
const handleEnvelope = (obj) => {
|
|
474
|
+
const payloadStr = obj?.payload;
|
|
475
|
+
let payload = payloadStr;
|
|
476
|
+
if (typeof payloadStr === "string") {
|
|
477
|
+
try {
|
|
478
|
+
payload = JSON.parse(payloadStr);
|
|
479
|
+
} catch {}
|
|
480
|
+
}
|
|
481
|
+
const create = payload?.["avcall/create"] ?? payload?.avcall_create ?? (obj?.topic?.includes("avcall/create") ? payload : null);
|
|
482
|
+
if (create?.broadcast_id && create?.sender_id) {
|
|
483
|
+
onCall({ broadcastId: create.broadcast_id, hostId: String(create.sender_id), conversationId: create.conversation_id ?? null, audioOnly: !!create.audio_only, source: "livepipeline" });
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const sse = async () => {
|
|
488
|
+
const auth = client.auth;
|
|
489
|
+
const res = await fetch(url, {
|
|
490
|
+
headers: {
|
|
491
|
+
accept: "text/event-stream",
|
|
492
|
+
authorization: `Bearer ${auth.client.bearer}`,
|
|
493
|
+
"x-csrf-token": auth.csrfToken,
|
|
494
|
+
"x-twitter-auth-type": "OAuth2Session",
|
|
495
|
+
cookie: auth.client.headers.cookie,
|
|
496
|
+
},
|
|
497
|
+
signal: ac.signal,
|
|
498
|
+
});
|
|
499
|
+
if (!res.ok || !res.body) throw new Error(`live_pipeline status ${res.status}`);
|
|
500
|
+
let buf = "";
|
|
501
|
+
for await (const chunk of res.body) {
|
|
502
|
+
if (stopped) break;
|
|
503
|
+
buf += Buffer.from(chunk).toString("utf8");
|
|
504
|
+
let idx;
|
|
505
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
506
|
+
const line = buf.slice(0, idx).trim();
|
|
507
|
+
buf = buf.slice(idx + 1);
|
|
508
|
+
if (!line.startsWith("data:")) continue;
|
|
509
|
+
try {
|
|
510
|
+
handleEnvelope(JSON.parse(line.slice(5).trim()));
|
|
511
|
+
} catch {}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const poll = async () => {
|
|
517
|
+
usedFallback = true;
|
|
518
|
+
const seen = new Set();
|
|
519
|
+
const intervalMs = opts.pollMs ?? 4000;
|
|
520
|
+
let first = true;
|
|
521
|
+
while (!stopped) {
|
|
522
|
+
try {
|
|
523
|
+
const convos = await client.xchat.conversations();
|
|
524
|
+
for (const c of convos) {
|
|
525
|
+
const lm = c.latestMessage;
|
|
526
|
+
if (lm?.kind === "av_call_started" && lm.broadcastId && !seen.has(lm.broadcastId)) {
|
|
527
|
+
seen.add(lm.broadcastId);
|
|
528
|
+
if (!first && lm.senderId && String(lm.senderId) !== String(myUserId)) {
|
|
529
|
+
onCall({ broadcastId: lm.broadcastId, hostId: String(lm.senderId), conversationId: c.conversationId, audioOnly: !!lm.audioOnly, source: "poll" });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
} catch {}
|
|
534
|
+
first = false;
|
|
535
|
+
await delay(intervalMs);
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
(async () => {
|
|
540
|
+
if (opts.transport !== "poll") {
|
|
541
|
+
try {
|
|
542
|
+
await sse();
|
|
543
|
+
if (!stopped && !usedFallback) await poll();
|
|
544
|
+
} catch {
|
|
545
|
+
if (!stopped) await poll();
|
|
546
|
+
}
|
|
547
|
+
} else {
|
|
548
|
+
await poll();
|
|
549
|
+
}
|
|
550
|
+
})();
|
|
551
|
+
|
|
552
|
+
return { close: () => { stopped = true; ac.abort(); } };
|
|
553
|
+
}
|