@softtynet/zca-js 2.3.4 → 2.3.6

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.
Files changed (129) hide show
  1. package/README.md +47 -11
  2. package/dist/apis/call/addUserGroupCall.d.ts +11 -0
  3. package/dist/apis/call/addUserGroupCall.js +48 -0
  4. package/dist/apis/call/answerAckVoiceCall.d.ts +5 -0
  5. package/dist/apis/call/answerAckVoiceCall.js +38 -0
  6. package/dist/apis/call/answerGroupCall.d.ts +10 -0
  7. package/dist/apis/call/answerGroupCall.js +45 -0
  8. package/dist/apis/call/answerVoiceCall.d.ts +11 -0
  9. package/dist/apis/call/answerVoiceCall.js +50 -0
  10. package/dist/apis/call/broadcastGroupCallState.d.ts +10 -0
  11. package/dist/apis/call/broadcastGroupCallState.js +45 -0
  12. package/dist/apis/call/cancelGroupCall.d.ts +10 -0
  13. package/dist/apis/call/cancelGroupCall.js +45 -0
  14. package/dist/apis/call/cancelVoiceCall.d.ts +1 -1
  15. package/dist/apis/call/cancelVoiceCall.js +5 -1
  16. package/dist/apis/call/createController.d.ts +7 -0
  17. package/dist/apis/call/createController.js +9 -0
  18. package/dist/apis/call/createRtcSession.d.ts +8 -0
  19. package/dist/apis/call/createRtcSession.js +10 -0
  20. package/dist/apis/call/endGroupCall.d.ts +10 -0
  21. package/dist/apis/call/endGroupCall.js +45 -0
  22. package/dist/apis/call/finishGroupCall.d.ts +12 -0
  23. package/dist/apis/call/finishGroupCall.js +47 -0
  24. package/dist/apis/call/getGroupCallState.d.ts +3 -0
  25. package/dist/apis/call/getGroupCallState.js +38 -0
  26. package/dist/apis/call/holdVoiceCall.d.ts +5 -0
  27. package/dist/apis/call/holdVoiceCall.js +34 -0
  28. package/dist/apis/call/joinGroupCall.d.ts +11 -0
  29. package/dist/apis/call/joinGroupCall.js +46 -0
  30. package/dist/apis/call/logEndVoiceCall.d.ts +8 -0
  31. package/dist/apis/call/logEndVoiceCall.js +46 -0
  32. package/dist/apis/call/notifyGroupCall.d.ts +9 -0
  33. package/dist/apis/call/notifyGroupCall.js +44 -0
  34. package/dist/apis/call/pingGroupCall.d.ts +10 -0
  35. package/dist/apis/call/pingGroupCall.js +45 -0
  36. package/dist/apis/call/requestAudioToVideoCall.d.ts +5 -0
  37. package/dist/apis/call/requestAudioToVideoCall.js +37 -0
  38. package/dist/apis/call/requestGroupCall.d.ts +9 -0
  39. package/dist/apis/call/requestGroupCall.js +46 -0
  40. package/dist/apis/call/requestShareScreenCall.d.ts +5 -0
  41. package/dist/apis/call/requestShareScreenCall.js +37 -0
  42. package/dist/apis/call/requestVoiceCall.d.ts +10 -0
  43. package/dist/apis/call/requestVoiceCall.js +47 -0
  44. package/dist/apis/call/ringRingGroupCall.d.ts +10 -0
  45. package/dist/apis/call/ringRingGroupCall.js +45 -0
  46. package/dist/apis/call/ringRingVoiceCall.d.ts +5 -0
  47. package/dist/apis/call/ringRingVoiceCall.js +38 -0
  48. package/dist/apis/call/sendRequestGroupCall.d.ts +11 -0
  49. package/dist/apis/call/sendRequestGroupCall.js +48 -0
  50. package/dist/apis/call/sendRequestVoiceCall.d.ts +11 -0
  51. package/dist/apis/call/sendRequestVoiceCall.js +51 -0
  52. package/dist/apis/call/sendVoiceCall.d.ts +8 -1
  53. package/dist/apis/call/sendVoiceCall.js +10 -8
  54. package/dist/apis/call/shareScreenGroupCall.d.ts +10 -0
  55. package/dist/apis/call/shareScreenGroupCall.js +45 -0
  56. package/dist/apis/call/unholdVoiceCall.d.ts +5 -0
  57. package/dist/apis/call/unholdVoiceCall.js +34 -0
  58. package/dist/apis/call/updateCallMediaState.d.ts +7 -0
  59. package/dist/apis/call/updateCallMediaState.js +61 -0
  60. package/dist/apis/sync/downloadTransferSync.js +19 -1
  61. package/dist/apis.d.ts +81 -0
  62. package/dist/apis.js +135 -0
  63. package/dist/call-engine/ZRTPPacketCodec.d.ts +262 -0
  64. package/dist/call-engine/ZRTPPacketCodec.js +506 -0
  65. package/dist/call-engine/ZaloCallController.d.ts +134 -0
  66. package/dist/call-engine/ZaloCallController.js +638 -0
  67. package/dist/call-engine/ZaloCallTransport.d.ts +83 -0
  68. package/dist/call-engine/ZaloCallTransport.js +259 -0
  69. package/dist/call-engine/ZaloRtcSession.d.ts +609 -0
  70. package/dist/call-engine/ZaloRtcSession.js +2939 -0
  71. package/dist/call-engine/ZaloSrtpSession.d.ts +80 -0
  72. package/dist/call-engine/ZaloSrtpSession.js +278 -0
  73. package/dist/call-engine/index.d.ts +18 -0
  74. package/dist/call-engine/index.js +24 -0
  75. package/dist/cjs/apis/call/addUserGroupCall.cjs +53 -0
  76. package/dist/cjs/apis/call/answerAckVoiceCall.cjs +43 -0
  77. package/dist/cjs/apis/call/answerGroupCall.cjs +50 -0
  78. package/dist/cjs/apis/call/answerVoiceCall.cjs +55 -0
  79. package/dist/cjs/apis/call/broadcastGroupCallState.cjs +50 -0
  80. package/dist/cjs/apis/call/cancelGroupCall.cjs +50 -0
  81. package/dist/cjs/apis/call/cancelVoiceCall.cjs +5 -1
  82. package/dist/cjs/apis/call/createController.cjs +14 -0
  83. package/dist/cjs/apis/call/createRtcSession.cjs +15 -0
  84. package/dist/cjs/apis/call/endGroupCall.cjs +50 -0
  85. package/dist/cjs/apis/call/finishGroupCall.cjs +52 -0
  86. package/dist/cjs/apis/call/getGroupCallState.cjs +43 -0
  87. package/dist/cjs/apis/call/holdVoiceCall.cjs +39 -0
  88. package/dist/cjs/apis/call/joinGroupCall.cjs +51 -0
  89. package/dist/cjs/apis/call/logEndVoiceCall.cjs +51 -0
  90. package/dist/cjs/apis/call/notifyGroupCall.cjs +49 -0
  91. package/dist/cjs/apis/call/pingGroupCall.cjs +50 -0
  92. package/dist/cjs/apis/call/requestAudioToVideoCall.cjs +42 -0
  93. package/dist/cjs/apis/call/requestGroupCall.cjs +51 -0
  94. package/dist/cjs/apis/call/requestShareScreenCall.cjs +42 -0
  95. package/dist/cjs/apis/call/requestVoiceCall.cjs +52 -0
  96. package/dist/cjs/apis/call/ringRingGroupCall.cjs +50 -0
  97. package/dist/cjs/apis/call/ringRingVoiceCall.cjs +43 -0
  98. package/dist/cjs/apis/call/sendRequestGroupCall.cjs +53 -0
  99. package/dist/cjs/apis/call/sendRequestVoiceCall.cjs +56 -0
  100. package/dist/cjs/apis/call/sendVoiceCall.cjs +10 -8
  101. package/dist/cjs/apis/call/shareScreenGroupCall.cjs +50 -0
  102. package/dist/cjs/apis/call/unholdVoiceCall.cjs +39 -0
  103. package/dist/cjs/apis/call/updateCallMediaState.cjs +66 -0
  104. package/dist/cjs/apis/sync/downloadTransferSync.cjs +19 -1
  105. package/dist/cjs/apis.cjs +135 -0
  106. package/dist/cjs/call-engine/ZRTPPacketCodec.cjs +525 -0
  107. package/dist/cjs/call-engine/ZaloCallController.cjs +643 -0
  108. package/dist/cjs/call-engine/ZaloCallTransport.cjs +265 -0
  109. package/dist/cjs/call-engine/ZaloRtcSession.cjs +2945 -0
  110. package/dist/cjs/call-engine/ZaloSrtpSession.cjs +287 -0
  111. package/dist/cjs/constants/protocol.cjs +1 -1
  112. package/dist/cjs/crypto/aes.cjs +3 -1
  113. package/dist/cjs/crypto/zdb-cipher.cjs +10 -3
  114. package/dist/cjs/index.cjs +61 -11
  115. package/dist/cjs/models/CallEvent.cjs +95 -1
  116. package/dist/cjs/realtime/handlers/control.handler.cjs +17 -3
  117. package/dist/cjs/realtime/listener.cjs +19 -0
  118. package/dist/constants/protocol.d.ts +1 -1
  119. package/dist/constants/protocol.js +1 -1
  120. package/dist/crypto/aes.js +3 -1
  121. package/dist/crypto/zdb-cipher.js +10 -3
  122. package/dist/index.d.ts +32 -4
  123. package/dist/index.js +2 -0
  124. package/dist/models/CallEvent.d.ts +15 -0
  125. package/dist/models/CallEvent.js +95 -1
  126. package/dist/realtime/handlers/control.handler.js +17 -3
  127. package/dist/realtime/listener.d.ts +5 -0
  128. package/dist/realtime/listener.js +19 -0
  129. package/package.json +2 -1
@@ -0,0 +1,2939 @@
1
+ import dgram from "node:dgram";
2
+ import EventEmitter from "node:events";
3
+ import { Buffer } from "node:buffer";
4
+ import { CallType } from "../apis/call/sendVoiceCall.js";
5
+ import { ZaloApiError } from "../Errors/ZaloApiError.js";
6
+ import { CallEventType } from "../models/CallEvent.js";
7
+ import { DEFAULT_ZALO_AUDIO_CODEC, ZRTCMediaSubCommand, ZRTPControlCommand, ZRTPForwardSubCommand, ZRTPLegacyMediaType, ZRTCRole, buildEndCallRequest, buildInitCallRequest, buildPingRequest, buildPingResponse, buildRelayPingRequest, buildRequestForwardPacket, buildZrtcExtendData, parseZRTPPacket, uidToUint32, wrapLegacyMediaPacket, wrapZrtcMediaPacket, } from "./ZRTPPacketCodec.js";
8
+ import { ZaloSrtpSession, buildRtpPacket, generateSrtpMasterKeyBase64, parseRtpPacket, } from "./ZaloSrtpSession.js";
9
+ function startsWithAnnexBStartCode(buf) {
10
+ if (!buf || buf.length < 3)
11
+ return false;
12
+ let zeroCount = 0;
13
+ let i = 0;
14
+ while (i < buf.length && buf[i] === 0x00) {
15
+ zeroCount++;
16
+ i++;
17
+ }
18
+ return zeroCount >= 2 && i < buf.length && buf[i] === 0x01;
19
+ }
20
+ function stripLeadingAnnexBStartCode(buf) {
21
+ if (!buf || buf.length === 0)
22
+ return buf;
23
+ let zeroCount = 0;
24
+ let i = 0;
25
+ while (i < buf.length && buf[i] === 0x00) {
26
+ zeroCount++;
27
+ i++;
28
+ }
29
+ if (zeroCount >= 2 && i < buf.length && buf[i] === 0x01) {
30
+ return buf.subarray(i + 1);
31
+ }
32
+ return buf;
33
+ }
34
+ function splitAnnexBNals(buf) {
35
+ const nals = [];
36
+ let i = 0;
37
+ let nalStart = -1;
38
+ while (i < buf.length) {
39
+ if (buf[i] === 0x00 && i + 2 < buf.length && buf[i + 1] === 0x00) {
40
+ let j = i;
41
+ while (j < buf.length && buf[j] === 0x00) {
42
+ j++;
43
+ }
44
+ if (j < buf.length && buf[j] === 0x01 && j - i >= 2) {
45
+ if (nalStart !== -1 && i > nalStart) {
46
+ nals.push(buf.subarray(nalStart, i));
47
+ }
48
+ nalStart = j + 1;
49
+ i = j + 1;
50
+ continue;
51
+ }
52
+ }
53
+ i++;
54
+ }
55
+ if (nalStart !== -1 && nalStart < buf.length) {
56
+ nals.push(buf.subarray(nalStart));
57
+ }
58
+ else if (nals.length === 0 && buf.length > 0) {
59
+ nals.push(buf);
60
+ }
61
+ return nals;
62
+ }
63
+ function tryParseAggregatedNals(buf, startOffset) {
64
+ if (!buf || startOffset >= buf.length)
65
+ return null;
66
+ const chunks = [];
67
+ let offset = startOffset;
68
+ while (offset + 2 <= buf.length) {
69
+ const nalSize = buf.readUInt16BE(offset);
70
+ offset += 2;
71
+ if (nalSize <= 0 || offset + nalSize > buf.length) {
72
+ return null;
73
+ }
74
+ chunks.push(buf.subarray(offset, offset + nalSize));
75
+ offset += nalSize;
76
+ }
77
+ return offset === buf.length && chunks.length > 0 ? chunks : null;
78
+ }
79
+ function isZaloVideoPayloadType(pt) {
80
+ return pt === 97 || pt === 98 || pt === 121 || pt === 122 || pt === 125 || pt === 126;
81
+ }
82
+ function isZaloKeyframePayloadType(pt) {
83
+ return pt === 98 || pt === 121 || pt === 126;
84
+ }
85
+ function parseEndpoint(addr) {
86
+ if (!addr)
87
+ return { address: "", host: "127.0.0.1", port: 0 };
88
+ const idx = addr.lastIndexOf(":");
89
+ if (idx === -1)
90
+ return { address: addr, host: addr, port: 0 };
91
+ const host = addr.slice(0, idx);
92
+ const port = Number.parseInt(addr.slice(idx + 1), 10) || 0;
93
+ return { address: addr, host, port };
94
+ }
95
+ function safeJsonParseRecord(value) {
96
+ if (!value)
97
+ return {};
98
+ if (typeof value === "object" && !Array.isArray(value)) {
99
+ return value;
100
+ }
101
+ if (typeof value === "string") {
102
+ try {
103
+ const parsed = JSON.parse(value);
104
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
105
+ return parsed;
106
+ }
107
+ }
108
+ catch {
109
+ // Ignore invalid JSON string
110
+ }
111
+ }
112
+ return {};
113
+ }
114
+ /**
115
+ * Giải mã chuẩn xác sự kiện cuộc gọi đến (`act: "request"` 1-1 hoặc `act: "group_request"` Nhóm)
116
+ * khớp 100% với `zrtc_call::ZMessageDeserializer::_parseActRequest` (`0x10001c824`),
117
+ * `_parseActRequestDetail` (`0x10001dfa0`), `_parseActRequestGroup` (`0x10001ffc4`) và
118
+ * `ZCallInfo::_prepareCallConfig` (`0x1000cd588`).
119
+ */
120
+ export function parseIncomingCallEvent(callEvent, localUid) {
121
+ const rawData = "data" in callEvent && typeof callEvent.data === "object" && callEvent.data !== null
122
+ ? callEvent.data
123
+ : callEvent;
124
+ const act = "act" in callEvent && typeof callEvent.act === "string"
125
+ ? callEvent.act
126
+ : String(rawData.act ?? "");
127
+ const parsedParams = rawData.params !== undefined
128
+ ? safeJsonParseRecord(rawData.params)
129
+ : rawData.data !== undefined
130
+ ? safeJsonParseRecord(rawData.data)
131
+ : rawData;
132
+ const extendRaw = parsedParams.extendData !== undefined
133
+ ? safeJsonParseRecord(parsedParams.extendData)
134
+ : rawData.extendData !== undefined
135
+ ? safeJsonParseRecord(rawData.extendData)
136
+ : parsedParams;
137
+ const callSetting = safeJsonParseRecord(rawData.callSetting ?? parsedParams.callSetting);
138
+ const isGroupCall = act.startsWith("group_") ||
139
+ Boolean(rawData.groupId) ||
140
+ Boolean(rawData.groupNoiseId) ||
141
+ Boolean(rawData.hostCall) ||
142
+ Number(rawData.callType) === CallType.GroupAudio ||
143
+ Number(rawData.callType) === CallType.GroupVideo;
144
+ const callId = Number(rawData.callId ??
145
+ rawData.call_id ??
146
+ rawData.id ??
147
+ parsedParams.callId ??
148
+ parsedParams.call_id ??
149
+ parsedParams.id ??
150
+ extendRaw.callId ??
151
+ extendRaw.id ??
152
+ 0);
153
+ const callerId = String(rawData.uidN ??
154
+ rawData.callerId ??
155
+ parsedParams.uidN ??
156
+ parsedParams.callerId ??
157
+ rawData.uidFrom ??
158
+ rawData.fromId ??
159
+ parsedParams.uidFrom ??
160
+ parsedParams.fromId ??
161
+ rawData.hostCall ??
162
+ "");
163
+ const hostCall = String(rawData.hostCall ??
164
+ parsedParams.hostCall ??
165
+ rawData.groupNoiseId ??
166
+ rawData.groupId ??
167
+ callerId);
168
+ const groupId = isGroupCall
169
+ ? String(rawData.groupNoiseId ?? rawData.groupId ?? parsedParams.groupId ?? hostCall)
170
+ : "";
171
+ const groupName = String(rawData.groupName ?? parsedParams.groupName ?? "");
172
+ const groupAvatar = String(rawData.groupAvatar ?? parsedParams.groupAvatar ?? "");
173
+ const callerName = String(parsedParams.Dname ??
174
+ rawData.Dname ??
175
+ parsedParams.callerName ??
176
+ rawData.callerName ??
177
+ parsedParams.name ??
178
+ rawData.name ??
179
+ groupName ??
180
+ callerId);
181
+ const callerAvatar = String(parsedParams.avatar ??
182
+ rawData.avatar ??
183
+ parsedParams.callerAvatar ??
184
+ rawData.callerAvatar ??
185
+ groupAvatar ??
186
+ "");
187
+ // Trong ZCallInfo::_prepareCallConfig (0x1000cd5c4):
188
+ // - Khi nhận cuộc gọi (Callee): localUid32 = uidTo (hoặc receiverId), partnerUid32 = uidFrom (hoặc fromId)
189
+ const fromUid32 = Number(rawData.uidTo ??
190
+ rawData.receiverId ??
191
+ parsedParams.uidTo ??
192
+ parsedParams.receiverId ??
193
+ 0) || uidToUint32(localUid ?? "");
194
+ const toUid32 = Number(rawData.uidFrom ?? rawData.fromId ?? parsedParams.uidFrom ?? parsedParams.fromId ?? 0) || uidToUint32(callerId);
195
+ const serversList = Array.isArray(parsedParams.servers)
196
+ ? parsedParams.servers
197
+ : Array.isArray(rawData.servers)
198
+ ? rawData.servers
199
+ : [];
200
+ const firstServersEntry = serversList[0];
201
+ const serverAddrList = Array.isArray(extendRaw.serverAddr)
202
+ ? extendRaw.serverAddr
203
+ : Array.isArray(parsedParams.serverAddr)
204
+ ? parsedParams.serverAddr
205
+ : [];
206
+ const firstServerAddr = serverAddrList[0];
207
+ const rtpArray = Array.isArray(extendRaw.rtp)
208
+ ? extendRaw.rtp
209
+ : Array.isArray(parsedParams.rtp)
210
+ ? parsedParams.rtp
211
+ : [];
212
+ const rtcpArray = Array.isArray(extendRaw.rtcp)
213
+ ? extendRaw.rtcp
214
+ : Array.isArray(parsedParams.rtcp)
215
+ ? parsedParams.rtcp
216
+ : [];
217
+ const rtpAddr = String(rawData.rtpAddress ??
218
+ rawData.rtpIP ??
219
+ rawData.rtpServer ??
220
+ parsedParams.rtpAddress ??
221
+ parsedParams.rtpIP ??
222
+ parsedParams.rtpServer ??
223
+ firstServersEntry?.rtpaddr ??
224
+ firstServersEntry?.rtp ??
225
+ firstServerAddr?.rtp ??
226
+ firstServerAddr?.rtpaddr ??
227
+ (rtpArray[0]?.ip && rtpArray[0]?.port ? `${rtpArray[0].ip}:${rtpArray[0].port}` : ""));
228
+ const rtcpFromArray = rtcpArray[0]?.ip && rtcpArray[0]?.port ? `${rtcpArray[0].ip}:${rtcpArray[0].port}` : "";
229
+ const rtcpAddr = String(rawData.rtcpAddress ??
230
+ rawData.rtcpIP ??
231
+ rawData.rtcpServer ??
232
+ parsedParams.rtcpAddress ??
233
+ parsedParams.rtcpIP ??
234
+ parsedParams.rtcpServer ??
235
+ firstServersEntry?.rtcpaddr ??
236
+ firstServersEntry?.rtcp ??
237
+ firstServerAddr?.rtcp ??
238
+ firstServerAddr?.rtcpaddr ??
239
+ (rtcpFromArray || rtpAddr));
240
+ const rtpEp = parseEndpoint(rtpAddr);
241
+ const rtcpEp = parseEndpoint(rtcpAddr || rtpAddr);
242
+ const rawCallType = Number(extendRaw.callType ?? parsedParams.callType ?? rawData.callType ?? 0);
243
+ const isVideo = rawCallType === 1 ||
244
+ rawCallType === CallType.Video ||
245
+ rawCallType === CallType.GroupVideo ||
246
+ Boolean(extendRaw.video);
247
+ const callType = isGroupCall
248
+ ? isVideo
249
+ ? CallType.GroupVideo
250
+ : CallType.GroupAudio
251
+ : isVideo
252
+ ? CallType.Video
253
+ : CallType.Audio;
254
+ const sessId = String(rawData.session ??
255
+ rawData.sessId ??
256
+ callSetting.session ??
257
+ parsedParams.sessId ??
258
+ parsedParams.session ??
259
+ extendRaw.sessId ??
260
+ (isGroupCall
261
+ ? `${hostCall || callerId}_${callId}_501`
262
+ : `${callerId}_${callId}_401`));
263
+ const rawPartners = Array.isArray(rawData.partners)
264
+ ? rawData.partners
265
+ : Array.isArray(rawData.partnerInfo)
266
+ ? rawData.partnerInfo
267
+ : Array.isArray(parsedParams.partners)
268
+ ? parsedParams.partners
269
+ : [];
270
+ const partners = rawPartners
271
+ .map((p) => {
272
+ if (typeof p === "string" || typeof p === "number")
273
+ return String(p);
274
+ if (p && typeof p === "object") {
275
+ const obj = p;
276
+ return String(obj.id ?? obj.uid ?? obj.userId ?? "");
277
+ }
278
+ return "";
279
+ })
280
+ .filter(Boolean);
281
+ const codec = typeof parsedParams.codec === "string" && parsedParams.codec !== "[]"
282
+ ? parsedParams.codec
283
+ : typeof rawData.codec === "string" && rawData.codec !== "[]"
284
+ ? rawData.codec
285
+ : DEFAULT_ZALO_AUDIO_CODEC;
286
+ return {
287
+ callId,
288
+ callerId,
289
+ callerName,
290
+ callerAvatar,
291
+ fromUid32,
292
+ toUid32,
293
+ sessId,
294
+ callType,
295
+ isVideo,
296
+ isGroupCall,
297
+ groupId,
298
+ groupName,
299
+ groupAvatar,
300
+ hostCall,
301
+ partners,
302
+ rtpAddress: rtpEp.address,
303
+ rtpHost: rtpEp.host,
304
+ rtpPort: rtpEp.port,
305
+ rtcpAddress: rtcpEp.address,
306
+ rtcpHost: rtcpEp.host,
307
+ rtcpPort: rtcpEp.port,
308
+ codec,
309
+ srtpKey: typeof extendRaw.srtpKey === "string" ? extendRaw.srtpKey : undefined,
310
+ srtpMode: extendRaw.srtpMode !== undefined ? Number(extendRaw.srtpMode) : undefined,
311
+ zrtpKey: typeof parsedParams.zrtpKey === "string"
312
+ ? parsedParams.zrtpKey
313
+ : typeof extendRaw.zrtpKey === "string"
314
+ ? extendRaw.zrtpKey
315
+ : undefined,
316
+ extendData: extendRaw,
317
+ rawEvent: callEvent,
318
+ };
319
+ }
320
+ /**
321
+ * Khung Opus silence / DTX 20ms chuẩn (TOC `0xf8` = Silk-only WB 20ms mono + `0xff 0xfe`)
322
+ */
323
+ const OPUS_SILENCE_20MS_FRAME = Buffer.from([0xf8, 0xff, 0xfe]);
324
+ /**
325
+ * Khung H.264 Baseline Profile Level 3.0 16x16 hợp lệ (SPS `0x67` + PPS `0x68` + IDR Slice `0x65`)
326
+ * giúp bộ giải mã phần cứng đối phương render đủ 15fps ngay cả khi chưa bật camera ngoài
327
+ * (`CallController::_checkEventLossVideoAndReason` @ `0x100182734`).
328
+ */
329
+ const H264_KEEPALIVE_ANNEXB_FRAME = Buffer.from("000000016742c01ed90f8888840000030004000003003c8f162e480000000168cb83cb20000000016588840033ff", "hex");
330
+ /**
331
+ * Bộ máy xử lý phiên gọi Thoại & Video thuần TypeScript / Node.js (`node:dgram` UDP + `ZRTPPacket` + `SRTP`).
332
+ *
333
+ * Hoạt động độc lập 100% trên Worker, Linux Server, Docker, hoặc Desktop mà **không cần cài đặt Zalo PC**:
334
+ * - Thực hiện trọn vẹn luồng Signaling 401 -> Bắt tay UDP `0x01 0x7e` (`cmd=11/12`) -> Nhận `routingToken` -> Signaling 416 / 402 -> Duy trì UDP Ping (`cmd=2/5/32`) & RTCP SR/RR.
335
+ * - Hỗ trợ chế độ **Direct Mobile Media Handoff** (`signalingOnly: true` / `handoffUdpToExternal()`): Worker chỉ tạo/nhận cuộc gọi qua HTTPS/WebSocket rồi trả gói `mobileDirectHandoff` cho Mobile App tự kết nối UDP trực tiếp tới máy chủ Relay của Zalo mà không cần đi qua trung gian.
336
+ * - Hỗ trợ đầy đủ cả **Chiều gọi đi (Outgoing)** lẫn **Chiều gọi đến (Incoming 1-1 & Nhóm)**: giải mã chuẩn `act: "request"` / `"group_request"`, báo đổ chuông (`ringIncomingCall`), bắt máy (`acceptIncomingCall` / `acceptIncomingGroupCall`), và từ chối (`rejectIncomingCall`).
337
+ */
338
+ export class ZaloRtcSession extends EventEmitter {
339
+ api;
340
+ state = "idle";
341
+ udpSocket = null;
342
+ pingTimer = null;
343
+ groupPingTimer = null;
344
+ audioKeepaliveTimer = null;
345
+ videoKeepaliveTimer = null;
346
+ rtcpTimer = null;
347
+ ringingTimeoutTimer = null;
348
+ startedAtMs = 0;
349
+ activeStartedAtMs = 0;
350
+ callId = 0;
351
+ sessId = "";
352
+ isCaller = true;
353
+ partnerId = "";
354
+ groupId = "";
355
+ groupPartners = [];
356
+ fromUid32 = 0;
357
+ toUid32 = 0;
358
+ callType = CallType.Audio;
359
+ isCameraOn = false;
360
+ isMicMuted = false;
361
+ isHold = false;
362
+ isScreenSharing = false;
363
+ rtpEndpoint = { address: "", host: "127.0.0.1", port: 0 };
364
+ rtcpEndpoint = { address: "", host: "127.0.0.1", port: 0 };
365
+ routingToken = 0;
366
+ udpHandshakeVerified = false;
367
+ hasReceivedPartnerMedia = false;
368
+ hasSentAnswerAck = false;
369
+ localUdpPort = 0;
370
+ zrtpKey;
371
+ codec = DEFAULT_ZALO_AUDIO_CODEC;
372
+ extendData = {};
373
+ serverInfo;
374
+ controlSeq = 1;
375
+ forwardPingSeq = 0;
376
+ manualControlSeq = 1;
377
+ audioSeq = Math.floor(Math.random() * 30000);
378
+ audioTimestamp = Math.floor(Math.random() * 100000);
379
+ audioSsrc = (Math.floor(Math.random() * 0x7fffffff) + 1) >>> 0;
380
+ audioPacketCount = 0;
381
+ audioOctetCount = 0;
382
+ lastExternalAudioSentAtMs = 0;
383
+ videoSeq = Math.floor(Math.random() * 30000);
384
+ videoTimestamp = Math.floor(Math.random() * 100000);
385
+ videoSsrc = (Math.floor(Math.random() * 0x7fffffff) + 1) >>> 0;
386
+ videoPacketCount = 0;
387
+ videoOctetCount = 0;
388
+ lastExternalVideoSentAtMs = 0;
389
+ remoteAudioSsrc = 0;
390
+ remoteAudioMaxSeq = 0;
391
+ remoteAudioSeqCycles = 0;
392
+ lastRemoteAudioSrMiddleNtp = 0;
393
+ lastRemoteAudioSrReceivedAtMs = 0;
394
+ remoteVideoSsrc = 0;
395
+ remoteVideoMaxSeq = 0;
396
+ remoteVideoSeqCycles = 0;
397
+ lastRemoteVideoSrMiddleNtp = 0;
398
+ lastRemoteVideoSrReceivedAtMs = 0;
399
+ localSpsNal = null;
400
+ localPpsNal = null;
401
+ remoteSpsNal = null;
402
+ remotePpsNal = null;
403
+ fuaFragments = [];
404
+ currentAccessUnitNals = [];
405
+ currentAccessUnitTs = -1;
406
+ currentAccessUnitHasIdr = false;
407
+ srtpSession = null;
408
+ options;
409
+ boundCallEventListener;
410
+ constructor(api, options = {}) {
411
+ super();
412
+ this.api = api;
413
+ this.options = {
414
+ packetMode: options.packetMode ?? 1,
415
+ autoKeepaliveMedia: options.autoKeepaliveMedia ?? true,
416
+ signalingOnly: options.signalingOnly ?? false,
417
+ enableSrtp: options.enableSrtp ?? false,
418
+ srtpSuite: options.srtpSuite ?? "AES_CM_128_HMAC_SHA1_80",
419
+ srtpMasterKeyBase64: options.srtpMasterKeyBase64,
420
+ udpHandshakeTimeoutMs: options.udpHandshakeTimeoutMs ?? 1500,
421
+ pingIntervalMs: options.pingIntervalMs ?? 1000,
422
+ autoAttachListener: options.autoAttachListener ?? true,
423
+ localUdpPort: options.localUdpPort ?? 0,
424
+ ringingTimeoutMs: options.ringingTimeoutMs ?? 60000,
425
+ };
426
+ if (this.options.enableSrtp) {
427
+ const keyB64 = this.options.srtpMasterKeyBase64 ?? generateSrtpMasterKeyBase64();
428
+ this.srtpSession = new ZaloSrtpSession(keyB64, this.options.srtpSuite);
429
+ }
430
+ }
431
+ getState() {
432
+ return this.state;
433
+ }
434
+ getSrtpSession() {
435
+ return this.srtpSession;
436
+ }
437
+ getCallDurationSeconds() {
438
+ if (!this.activeStartedAtMs)
439
+ return 0;
440
+ return Math.max(0, Math.floor((Date.now() - this.activeStartedAtMs) / 1000));
441
+ }
442
+ getMediaState() {
443
+ return {
444
+ callId: this.callId,
445
+ callType: this.callType,
446
+ isCameraOn: this.isCameraOn,
447
+ isMicMuted: this.isMicMuted,
448
+ isHold: this.isHold,
449
+ isScreenSharing: this.isScreenSharing,
450
+ durationSeconds: this.getCallDurationSeconds(),
451
+ groupId: this.groupId,
452
+ groupPartners: this.groupPartners,
453
+ };
454
+ }
455
+ getLocalAudioSsrc() {
456
+ return (this.fromUid32 || this.audioSsrc) >>> 0;
457
+ }
458
+ getLocalVideoSsrc() {
459
+ return (this.fromUid32 || this.videoSsrc) >>> 0;
460
+ }
461
+ /**
462
+ * Tạo bộ thông số + các gói tin nhị phân `ZRTPPacket` đã đóng gói sẵn dạng hex
463
+ * để ứng dụng Mobile có thể mở socket UDP kết nối **trực tiếp** tới máy chủ Relay của Zalo (`rtpHost:rtpPort`)
464
+ * mà không cần đẩy luồng dữ liệu âm thanh/hình ảnh qua Worker trung gian.
465
+ */
466
+ getMobileDirectHandoffBundle(routingTokenOverride) {
467
+ const role = this.isCaller ? ZRTCRole.Caller : ZRTCRole.Callee;
468
+ const token = ((routingTokenOverride ?? this.routingToken) || this.callId) >>> 0;
469
+ const fromUid = this.fromUid32 || uidToUint32(this.api.getContext().uid);
470
+ const toUid = this.toUid32 || uidToUint32(this.partnerId);
471
+ const isVideo = this.callType === CallType.Video ||
472
+ this.callType === CallType.GroupVideo ||
473
+ Number(this.extendData?.callType) === 1;
474
+ const initCallPkt = buildInitCallRequest({
475
+ isCaller: this.isCaller,
476
+ seqNum: 0,
477
+ headerCallId: this.callId,
478
+ callId: this.callId,
479
+ fromUid,
480
+ toUid,
481
+ flags: 1,
482
+ subFlags: toUid,
483
+ sessId: this.sessId,
484
+ });
485
+ const relayPingPkt = buildRelayPingRequest({
486
+ seqNum: 0,
487
+ callId: 0,
488
+ fromUid,
489
+ routingToken: token,
490
+ sessId: this.sessId,
491
+ });
492
+ const pingPkt = buildPingRequest({
493
+ seqNum: 1,
494
+ callId: this.callId,
495
+ fromUid,
496
+ toUid,
497
+ elapsedMs: 1000,
498
+ });
499
+ const manualCtrlPayload = Buffer.alloc(20);
500
+ manualCtrlPayload.writeInt32LE(isVideo ? 15 : 0, 0);
501
+ manualCtrlPayload.writeInt32LE(0, 4);
502
+ manualCtrlPayload.writeInt32LE(0, 8);
503
+ manualCtrlPayload.writeInt32LE(50, 12);
504
+ manualCtrlPayload.writeInt32LE(isVideo ? 30 : 0, 16);
505
+ const manualCtrlPkt = buildRequestForwardPacket({
506
+ seqNum: 0,
507
+ seqId: 1,
508
+ fromUid,
509
+ routingToken: token,
510
+ subCmd: ZRTPForwardSubCommand.VideoControl,
511
+ payload: manualCtrlPayload,
512
+ });
513
+ const cameraOnPkt = buildRequestForwardPacket({
514
+ seqNum: 0,
515
+ seqId: 1,
516
+ fromUid,
517
+ routingToken: token,
518
+ subCmd: ZRTPForwardSubCommand.OnCamera,
519
+ payload: "",
520
+ });
521
+ const cameraOffPkt = buildRequestForwardPacket({
522
+ seqNum: 0,
523
+ seqId: 1,
524
+ fromUid,
525
+ routingToken: token,
526
+ subCmd: ZRTPForwardSubCommand.OffCamera,
527
+ payload: "",
528
+ });
529
+ const endCallPkt = buildEndCallRequest({
530
+ seqNum: 0,
531
+ callId: this.callId,
532
+ fromUid,
533
+ toUid,
534
+ routingToken: token,
535
+ flags: 0,
536
+ });
537
+ const audioRtpPrefix = (this.options.packetMode === 1
538
+ ? wrapLegacyMediaPacket(ZRTPLegacyMediaType.AudioRtp, token, Buffer.alloc(0))
539
+ : wrapZrtcMediaPacket(ZRTCMediaSubCommand.AudioRtp, token, Buffer.alloc(0), role)).toString("hex");
540
+ const audioRtcpPrefix = (this.options.packetMode === 1
541
+ ? wrapLegacyMediaPacket(ZRTPLegacyMediaType.AudioRtcp, token, Buffer.alloc(0))
542
+ : wrapZrtcMediaPacket(ZRTCMediaSubCommand.AudioRtcp, token, Buffer.alloc(0), role)).toString("hex");
543
+ const videoRtpPrefix = (this.options.packetMode === 1
544
+ ? wrapLegacyMediaPacket(ZRTPLegacyMediaType.VideoRtp, token, Buffer.alloc(0))
545
+ : wrapZrtcMediaPacket(ZRTCMediaSubCommand.VideoRtp, token, Buffer.alloc(0), role)).toString("hex");
546
+ const videoRtcpPrefix = (this.options.packetMode === 1
547
+ ? wrapLegacyMediaPacket(ZRTPLegacyMediaType.VideoRtcp, token, Buffer.alloc(0))
548
+ : wrapZrtcMediaPacket(ZRTCMediaSubCommand.VideoRtcp, token, Buffer.alloc(0), role)).toString("hex");
549
+ return {
550
+ callId: this.callId,
551
+ sessId: this.sessId,
552
+ isCaller: this.isCaller,
553
+ partnerId: this.partnerId,
554
+ fromUid32: fromUid,
555
+ toUid32: toUid,
556
+ callType: this.callType,
557
+ rtpHost: this.rtpEndpoint.host,
558
+ rtpPort: this.rtpEndpoint.port,
559
+ rtcpHost: this.rtcpEndpoint.host,
560
+ rtcpPort: this.rtcpEndpoint.port,
561
+ packetMode: this.options.packetMode,
562
+ routingToken: token,
563
+ codec: this.codec,
564
+ srtpMasterKeyBase64: this.srtpSession?.masterKeyBase64,
565
+ zrtpKey: this.zrtpKey,
566
+ initCallPacketHex: initCallPkt.toString("hex"),
567
+ relayPingPacketHex: relayPingPkt.toString("hex"),
568
+ pingPacketHex: pingPkt.toString("hex"),
569
+ manualControlPacketHex: manualCtrlPkt.toString("hex"),
570
+ cameraOnPacketHex: cameraOnPkt.toString("hex"),
571
+ cameraOffPacketHex: cameraOffPkt.toString("hex"),
572
+ endCallPacketHex: endCallPkt.toString("hex"),
573
+ audioRtpPrefixHex: audioRtpPrefix,
574
+ audioRtcpPrefixHex: audioRtcpPrefix,
575
+ videoRtpPrefixHex: videoRtpPrefix,
576
+ videoRtcpPrefixHex: videoRtcpPrefix,
577
+ };
578
+ }
579
+ /**
580
+ * Đặt lại toàn bộ trạng thái định tuyến UDP, bộ đếm RTP/RTCP, bộ đệm giải mã H.264 FU-A/SPS/PPS
581
+ * và khóa SRTP trước khi bắt đầu hoặc chấp nhận một cuộc gọi mới trên cùng một instance `ZaloRtcSession`.
582
+ */
583
+ resetSessionStateForNewCall(preserveRingingCallId = 0) {
584
+ const isContinuingSameRingingCall = preserveRingingCallId > 0 &&
585
+ this.callId === preserveRingingCallId &&
586
+ this.state === "ringing";
587
+ this.cleanupResources();
588
+ this.routingToken = 0;
589
+ this.udpHandshakeVerified = false;
590
+ this.hasReceivedPartnerMedia = false;
591
+ this.hasSentAnswerAck = false;
592
+ this.localUdpPort = 0;
593
+ this.startedAtMs = 0;
594
+ this.activeStartedAtMs = 0;
595
+ this.controlSeq = 1;
596
+ this.forwardPingSeq = 0;
597
+ this.manualControlSeq = 1;
598
+ this.audioSeq = Math.floor(Math.random() * 30000);
599
+ this.audioTimestamp = Math.floor(Math.random() * 100000);
600
+ this.audioPacketCount = 0;
601
+ this.audioOctetCount = 0;
602
+ this.lastExternalAudioSentAtMs = 0;
603
+ this.videoSeq = Math.floor(Math.random() * 30000);
604
+ this.videoTimestamp = Math.floor(Math.random() * 100000);
605
+ this.videoPacketCount = 0;
606
+ this.videoOctetCount = 0;
607
+ this.lastExternalVideoSentAtMs = 0;
608
+ this.remoteAudioSsrc = 0;
609
+ this.remoteAudioMaxSeq = 0;
610
+ this.remoteAudioSeqCycles = 0;
611
+ this.lastRemoteAudioSrMiddleNtp = 0;
612
+ this.lastRemoteAudioSrReceivedAtMs = 0;
613
+ this.remoteVideoSsrc = 0;
614
+ this.remoteVideoMaxSeq = 0;
615
+ this.remoteVideoSeqCycles = 0;
616
+ this.lastRemoteVideoSrMiddleNtp = 0;
617
+ this.lastRemoteVideoSrReceivedAtMs = 0;
618
+ this.localSpsNal = null;
619
+ this.localPpsNal = null;
620
+ this.remoteSpsNal = null;
621
+ this.remotePpsNal = null;
622
+ this.clearIncomingVideoAssemblyBuffers();
623
+ if (this.options.enableSrtp) {
624
+ const keyB64 = this.options.srtpMasterKeyBase64 ?? generateSrtpMasterKeyBase64();
625
+ this.srtpSession = new ZaloSrtpSession(keyB64, this.options.srtpSuite);
626
+ }
627
+ else {
628
+ this.srtpSession = null;
629
+ }
630
+ if (!isContinuingSameRingingCall) {
631
+ this.state = "idle";
632
+ }
633
+ }
634
+ clearIncomingVideoAssemblyBuffers() {
635
+ this.fuaFragments = [];
636
+ this.currentAccessUnitNals = [];
637
+ this.currentAccessUnitTs = -1;
638
+ this.currentAccessUnitHasIdr = false;
639
+ }
640
+ /**
641
+ * Tái kích hoạt (Re-activate / Re-bind) kết nối UDP và luồng Media/RTCP cho phiên gọi hiện tại:
642
+ * - Dùng khi chuyển vùng mạng (Wi-Fi <-> 4G/5G), khi socket UDP bị gián đoạn, hoặc khi muốn thu hồi lại
643
+ * quyền xử lý UDP từ Mobile về lại Worker/Desktop sau khi đã gọi `signalingOnly` / `handoffUdpToExternal()`.
644
+ * - Tự động mở cổng UDP mới, gửi lại gói bắt tay `0x01 0x7e` (`cmd=11` Caller hoặc `cmd=12` Callee),
645
+ * khởi động lại vòng lặp UDP Ping + Media Keepalive + RTCP SR/RR, đồng bộ lại trạng thái Mic/Camera
646
+ * và bắn `RTCP PLI` để khôi phục hình ảnh H.264 ngay lập tức.
647
+ */
648
+ async reconnectUdp(options = {}) {
649
+ if (!this.callId || this.rtpEndpoint.port <= 0) {
650
+ throw new ZaloApiError("Cannot reconnect UDP: no active call endpoint");
651
+ }
652
+ if (this.pingTimer) {
653
+ clearInterval(this.pingTimer);
654
+ this.pingTimer = null;
655
+ }
656
+ this.stopMediaKeepaliveLoop();
657
+ if (this.udpSocket) {
658
+ try {
659
+ this.udpSocket.removeAllListeners();
660
+ this.udpSocket.close();
661
+ }
662
+ catch {
663
+ // Ignore
664
+ }
665
+ this.udpSocket = null;
666
+ }
667
+ this.localUdpPort = 0;
668
+ this.clearIncomingVideoAssemblyBuffers();
669
+ await this.ensureUdpSocket();
670
+ if (!options.skipUdpHandshake && this.rtpEndpoint.port > 0) {
671
+ await this.performUdpInitHandshake(this.isCaller);
672
+ }
673
+ else if (!this.routingToken) {
674
+ this.routingToken = this.callId >>> 0;
675
+ }
676
+ this.startUdpPingLoop();
677
+ this.startMediaKeepaliveLoop();
678
+ void this.sendMicState(!this.isMicMuted).catch(() => { });
679
+ if (this.callType === CallType.Video ||
680
+ this.callType === CallType.GroupVideo ||
681
+ Number(this.extendData?.callType) === 1) {
682
+ void this.sendCameraState(this.isCameraOn).catch(() => { });
683
+ void this.sendRtcpPli().catch(() => { });
684
+ }
685
+ const params = this.getConnectionParams();
686
+ this.emit("connection_params", params);
687
+ return params;
688
+ }
689
+ /**
690
+ * Ngắt toàn bộ socket UDP và các vòng lặp Ping/Media cục bộ trên Worker
691
+ * (nhưng vẫn giữ nguyên kết nối Signaling WebSocket để lắng nghe `answer`, `end`, `cancel`, `audio_to_video`),
692
+ * nhằm bàn giao hoàn toàn kênh UDP cho ứng dụng Mobile kết nối trực tiếp tới máy chủ Relay Zalo
693
+ * mà không bị xung đột NAT (NAT flip-flopping).
694
+ */
695
+ handoffUdpToExternal(routingTokenOverride) {
696
+ if (routingTokenOverride !== undefined && routingTokenOverride > 0) {
697
+ this.routingToken = routingTokenOverride >>> 0;
698
+ }
699
+ if (this.pingTimer) {
700
+ clearInterval(this.pingTimer);
701
+ this.pingTimer = null;
702
+ }
703
+ this.stopMediaKeepaliveLoop();
704
+ if (this.udpSocket) {
705
+ try {
706
+ this.udpSocket.removeAllListeners();
707
+ this.udpSocket.close();
708
+ }
709
+ catch {
710
+ // Ignore
711
+ }
712
+ this.udpSocket = null;
713
+ }
714
+ this.localUdpPort = 0;
715
+ return this.getMobileDirectHandoffBundle(routingTokenOverride);
716
+ }
717
+ /**
718
+ * Xuất toàn bộ thông số kết nối cuộc gọi & UDP `ZRTPPacket` sau khi thương lượng xong
719
+ * để truyền về Mobile App hoặc Media Bridge.
720
+ */
721
+ getConnectionParams() {
722
+ const handoff = this.getMobileDirectHandoffBundle();
723
+ return {
724
+ callId: this.callId,
725
+ sessId: this.sessId,
726
+ isCaller: this.isCaller,
727
+ partnerId: this.partnerId,
728
+ fromId: this.fromUid32,
729
+ toId: this.toUid32,
730
+ callType: this.callType,
731
+ rtpAddress: this.rtpEndpoint.address,
732
+ rtpHost: this.rtpEndpoint.host,
733
+ rtpPort: this.rtpEndpoint.port,
734
+ rtcpAddress: this.rtcpEndpoint.address,
735
+ rtcpHost: this.rtcpEndpoint.host,
736
+ rtcpPort: this.rtcpEndpoint.port,
737
+ routingToken: handoff.routingToken,
738
+ udpHandshakeVerified: this.udpHandshakeVerified,
739
+ localUdpPort: this.localUdpPort,
740
+ packetMode: this.options.packetMode,
741
+ codec: this.codec,
742
+ extendData: this.extendData,
743
+ zrtpKey: this.zrtpKey,
744
+ srtpMasterKeyBase64: this.srtpSession?.masterKeyBase64,
745
+ audioZrtcHeaderHex: handoff.audioRtpPrefixHex,
746
+ videoZrtcHeaderHex: handoff.videoRtpPrefixHex,
747
+ serverInfo: this.serverInfo,
748
+ mobileDirectHandoff: handoff,
749
+ };
750
+ }
751
+ /**
752
+ * Khởi tạo cuộc gọi đi (Caller) 1-1 hoàn chỉnh:
753
+ * 1. Gọi `/api/voicecall/requestcall` (401) lấy `sessId`, `rtpaddr`, `rtcpaddr`, `zrtpKey`
754
+ * 2. Mở socket UDP và gửi gói bắt tay `0x01 0x7e` (`cmd=11`) tới `rtpaddr` để nhận `routingToken` (bỏ qua nếu `signalingOnly: true`)
755
+ * 3. Gọi `/api/voicecall/request` (416) kèm codec Opus chuẩn và `extendData` để máy đối phương đổ chuông
756
+ * 4. Duy trì nhịp tim UDP Ping (`0x01 0x7e`, `cmd=2/5/32`) và sẵn sàng truyền/nhận RTP/SRTP (nếu không bật `signalingOnly`)
757
+ */
758
+ async startOutgoingCall(calleeId, options = {}) {
759
+ if (!calleeId)
760
+ throw new ZaloApiError("Missing calleeId");
761
+ this.resetSessionStateForNewCall();
762
+ const isSignalingOnly = Boolean(options.signalingOnly ?? this.options.signalingOnly);
763
+ this.isCaller = true;
764
+ this.partnerId = String(calleeId);
765
+ this.groupId = "";
766
+ this.groupPartners = [];
767
+ this.callType = options.callType ?? CallType.Audio;
768
+ this.isCameraOn =
769
+ (this.callType === CallType.Video || this.callType === CallType.GroupVideo) &&
770
+ !options.offCameraAtStart;
771
+ this.isMicMuted = false;
772
+ this.isHold = false;
773
+ this.isScreenSharing = false;
774
+ this.activeStartedAtMs = 0;
775
+ this.callId = options.callId ?? Math.floor(Math.random() * 2_000_000_000);
776
+ this.codec = options.codec ?? DEFAULT_ZALO_AUDIO_CODEC;
777
+ this.udpHandshakeVerified = false;
778
+ this.hasReceivedPartnerMedia = false;
779
+ this.hasSentAnswerAck = false;
780
+ this.setState("allocating");
781
+ if (this.options.autoAttachListener) {
782
+ this.attachCallEventListener();
783
+ }
784
+ // Bước 1: Xin cấp phát phiên & địa chỉ máy chủ Relay UDP (Signal 401)
785
+ const serverInfo = await this.api.call.requestVoiceCall(this.partnerId, {
786
+ callType: this.callType,
787
+ callId: this.callId,
788
+ codec: this.codec,
789
+ });
790
+ this.applyServerInfo(serverInfo);
791
+ // Bước 2: Mở UDP Socket & thực hiện bắt tay ZRTP InitCall (0x01 0x7e cmd=11)
792
+ // (Nếu chạy chế độ signalingOnly cho Mobile tự kết nối UDP trực tiếp thì bỏ qua bước mở socket tại Worker)
793
+ if (!isSignalingOnly) {
794
+ await this.ensureUdpSocket();
795
+ if (!options.skipUdpHandshake && this.rtpEndpoint.port > 0) {
796
+ this.setState("handshaking_udp");
797
+ await this.performUdpInitHandshake(true);
798
+ }
799
+ else {
800
+ this.routingToken = this.callId >>> 0;
801
+ }
802
+ }
803
+ else {
804
+ this.routingToken = this.callId >>> 0;
805
+ }
806
+ // Bước 3: Gửi tín hiệu đổ chuông 416 kèm codec Opus & ZRTC extendData chuẩn
807
+ const isVideo = this.callType === CallType.Video || this.callType === CallType.GroupVideo;
808
+ const negotiateVideoPipeline = isVideo || Boolean(options.allowVideoUpgrade) || Boolean(options.offCameraAtStart);
809
+ this.extendData = {
810
+ ...buildZrtcExtendData({
811
+ rtpAddress: this.rtpEndpoint.address,
812
+ rtcpAddress: this.rtcpEndpoint.address,
813
+ isVideo: negotiateVideoPipeline,
814
+ packetMode: 2,
815
+ srtpMode: this.srtpSession ? 1 : 0,
816
+ srtpKey: this.srtpSession?.masterKeyBase64,
817
+ }),
818
+ rtcpDiff: 0,
819
+ rtcp: [{ ip: this.rtcpEndpoint.host, port: this.rtcpEndpoint.port }],
820
+ rtp: [{ ip: this.rtpEndpoint.host, port: this.rtpEndpoint.port }],
821
+ status: 0,
822
+ };
823
+ await this.api.call.sendRequestVoiceCall(this.partnerId, {
824
+ callId: this.callId,
825
+ session: this.sessId,
826
+ rtpAddress: this.rtpEndpoint.address,
827
+ rtcpAddress: this.rtcpEndpoint.address,
828
+ codec: this.codec,
829
+ extendData: this.extendData,
830
+ subCommand: 3,
831
+ });
832
+ // Nếu khởi tạo cuộc gọi với Camera tắt ban đầu (gọi Thoại có allowVideoUpgrade hoặc Video tắt Cam),
833
+ // đồng bộ ngay trạng thái OffCamera (cmd=32 subCmd=8 + /api/voicecall/conf isOnCam=0)
834
+ // khớp với ZCallMainWindowVideo_v2::updateIncallState (0x1000410c0).
835
+ if (negotiateVideoPipeline && !this.isCameraOn) {
836
+ if (!isSignalingOnly) {
837
+ void this.sendCameraState(false).catch(() => { });
838
+ }
839
+ void this.api.call
840
+ .updateCallMediaState(this.partnerId, this.callId, {
841
+ isOnCam: 0,
842
+ isOnMic: -1,
843
+ data: "",
844
+ })
845
+ .catch(() => { });
846
+ }
847
+ // Bước 4: Bắt đầu gửi UDP Ping & Media Keepalive định kỳ giữ phiên Relay (nếu không bật signalingOnly)
848
+ if (!isSignalingOnly) {
849
+ this.startUdpPingLoop();
850
+ this.startMediaKeepaliveLoop();
851
+ }
852
+ this.setState("ringing");
853
+ const params = this.getConnectionParams();
854
+ this.emit("connection_params", params);
855
+ return params;
856
+ }
857
+ /**
858
+ * Giải mã sự kiện cuộc gọi đến (`act: "request"` hoặc `"group_request"`) và gửi ngay tín hiệu
859
+ * báo máy đang đổ chuông (`ringRingVoiceCall` `11302` / `ringRingGroupCall` `12439`) tới phía người gọi,
860
+ * đồng thời chuyển phiên sang trạng thái `"ringing"` trong khi chờ người dùng bấm Nghe máy hoặc Từ chối.
861
+ * Khớp với `zrtc_call::ZCallInfo::receiveIncomingCall` (`0x1000ccbbc`).
862
+ */
863
+ async ringIncomingCall(callEvent) {
864
+ const info = parseIncomingCallEvent(callEvent, this.api.getContext().uid);
865
+ if (!info.callerId || !info.callId) {
866
+ throw new ZaloApiError("Invalid incoming call event: missing callerId or callId");
867
+ }
868
+ this.resetSessionStateForNewCall();
869
+ this.isCaller = false;
870
+ this.callId = info.callId;
871
+ this.partnerId = info.isGroupCall ? info.hostCall || info.callerId : info.callerId;
872
+ this.groupId = info.groupId;
873
+ this.groupPartners = info.partners;
874
+ this.fromUid32 = info.fromUid32;
875
+ this.toUid32 = info.toUid32;
876
+ this.sessId = info.sessId;
877
+ this.callType = info.callType;
878
+ this.isCameraOn = info.isVideo;
879
+ this.rtpEndpoint = {
880
+ address: info.rtpAddress,
881
+ host: info.rtpHost,
882
+ port: info.rtpPort,
883
+ };
884
+ this.rtcpEndpoint = {
885
+ address: info.rtcpAddress,
886
+ host: info.rtcpHost,
887
+ port: info.rtcpPort,
888
+ };
889
+ this.codec = info.codec;
890
+ this.zrtpKey = info.zrtpKey;
891
+ if (this.options.autoAttachListener) {
892
+ this.attachCallEventListener();
893
+ }
894
+ if (info.isGroupCall) {
895
+ await this.api.call.ringRingGroupCall(this.callId, {
896
+ hostCall: this.partnerId,
897
+ callType: this.callType,
898
+ partners: this.groupPartners,
899
+ });
900
+ }
901
+ else {
902
+ await this.api.call.ringRingVoiceCall(this.partnerId, this.callId);
903
+ }
904
+ this.setState("ringing");
905
+ this.emit("incoming_call", info);
906
+ return info;
907
+ }
908
+ /**
909
+ * Chấp nhận cuộc gọi đến (Callee) từ sự kiện `call_event` (`act: "request"` hoặc `"group_request"`):
910
+ * - Tự động giải mã chuẩn xác `params.id` (`callId`), `uidN` (`callerId`), `uidFrom` (`toUid32`), `uidTo` (`fromUid32`), `rtpIP`/`rtcpIP`/`servers`
911
+ * - Nếu là cuộc gọi Nhóm (`isGroupCall`), tự động uỷ quyền sang `acceptIncomingGroupCall`
912
+ * - Với cuộc gọi 1-1:
913
+ * 1. Gửi `/api/voicecall/ringring` (407) (nếu chưa gửi)
914
+ * 2. Nếu sự kiện thiếu địa chỉ Relay UDP, tự động gọi dự phòng `requestVoiceCall` (401) như `_trySendAcceptSignal` (`0x1000c2c9c`)
915
+ * 3. Mở socket UDP và gửi gói bắt tay `0x01 0x7e` (`cmd=12`) tới `rtpaddr` (bỏ qua nếu `signalingOnly: true`)
916
+ * 4. Gửi `/api/voicecall/answer` (402) để kết nối âm thanh/hình ảnh 2 chiều
917
+ */
918
+ async acceptIncomingCall(callEvent, options = {}) {
919
+ const info = parseIncomingCallEvent(callEvent, this.api.getContext().uid);
920
+ if (info.isGroupCall) {
921
+ return this.acceptIncomingGroupCall(callEvent, options);
922
+ }
923
+ if (!info.callerId || !info.callId) {
924
+ throw new ZaloApiError("Invalid incoming call event: missing callerId or callId");
925
+ }
926
+ this.resetSessionStateForNewCall(info.callId);
927
+ const isSignalingOnly = Boolean(options.signalingOnly ?? this.options.signalingOnly);
928
+ this.isCaller = false;
929
+ this.partnerId = info.callerId;
930
+ this.groupId = "";
931
+ this.groupPartners = [];
932
+ this.callId = info.callId;
933
+ this.fromUid32 = info.fromUid32;
934
+ this.toUid32 = info.toUid32;
935
+ this.sessId = info.sessId;
936
+ this.codec = options.codec ?? info.codec ?? DEFAULT_ZALO_AUDIO_CODEC;
937
+ this.zrtpKey = info.zrtpKey;
938
+ this.hasReceivedPartnerMedia = false;
939
+ this.hasSentAnswerAck = false;
940
+ this.isScreenSharing = false;
941
+ this.activeStartedAtMs = 0;
942
+ this.rtpEndpoint = {
943
+ address: info.rtpAddress,
944
+ host: info.rtpHost,
945
+ port: info.rtpPort,
946
+ };
947
+ this.rtcpEndpoint = {
948
+ address: info.rtcpAddress,
949
+ host: info.rtcpHost,
950
+ port: info.rtcpPort,
951
+ };
952
+ this.callType = info.callType;
953
+ this.isCameraOn = this.callType === CallType.Video && !options.offCameraAtStart;
954
+ this.isMicMuted = false;
955
+ this.isHold = false;
956
+ if (typeof info.srtpKey === "string" && info.srtpKey.length >= 40) {
957
+ this.srtpSession = new ZaloSrtpSession(info.srtpKey, this.options.srtpSuite);
958
+ }
959
+ else if (info.srtpMode === 0 && !this.options.enableSrtp) {
960
+ this.srtpSession = null;
961
+ }
962
+ if (this.options.autoAttachListener) {
963
+ this.attachCallEventListener();
964
+ }
965
+ // Bước 1: Báo máy đang đổ chuông (Signal 407)
966
+ if (!options.skipRingRing) {
967
+ await this.api.call.ringRingVoiceCall(this.partnerId, this.callId);
968
+ }
969
+ // Dự phòng theo _trySendAcceptSignal (0x1000c2c9c): nếu sự kiện thiếu rtpPort, xin cấp phát qua Signal 401
970
+ if (this.rtpEndpoint.port <= 0) {
971
+ try {
972
+ const fallbackServer = await this.api.call.requestVoiceCall(this.partnerId, {
973
+ callId: this.callId,
974
+ callType: this.callType,
975
+ codec: this.codec,
976
+ });
977
+ this.applyServerInfo(fallbackServer);
978
+ }
979
+ catch {
980
+ // Ignore if mock/offline environment does not require fallback
981
+ }
982
+ }
983
+ // Bước 2: Mở UDP Socket & bắt tay Callee (0x01 0x7e cmd=12)
984
+ if (!isSignalingOnly) {
985
+ await this.ensureUdpSocket();
986
+ if (!options.skipUdpHandshake && this.rtpEndpoint.port > 0) {
987
+ this.setState("handshaking_udp");
988
+ await this.performUdpInitHandshake(false);
989
+ }
990
+ else {
991
+ this.routingToken = this.callId >>> 0;
992
+ }
993
+ }
994
+ else {
995
+ this.routingToken = this.callId >>> 0;
996
+ }
997
+ // Bước 3: Gửi tín hiệu bắt máy (Signal 402)
998
+ const isVideo = this.callType === CallType.Video;
999
+ const negotiateVideoPipeline = isVideo || Boolean(options.allowVideoUpgrade) || Boolean(options.offCameraAtStart);
1000
+ this.extendData = buildZrtcExtendData({
1001
+ rtpAddress: this.rtpEndpoint.address,
1002
+ rtcpAddress: this.rtcpEndpoint.address,
1003
+ isVideo: negotiateVideoPipeline,
1004
+ packetMode: 2,
1005
+ srtpMode: this.srtpSession ? 1 : 0,
1006
+ srtpKey: this.srtpSession?.masterKeyBase64,
1007
+ });
1008
+ if (!options.skipAnswerSignal) {
1009
+ await this.api.call.answerVoiceCall(this.partnerId, this.callId, {
1010
+ sessionId: this.sessId,
1011
+ rtpAddress: this.rtpEndpoint.address,
1012
+ rtcpAddress: this.rtcpEndpoint.address,
1013
+ codec: this.codec,
1014
+ extendData: this.extendData,
1015
+ status: 0,
1016
+ });
1017
+ }
1018
+ if (negotiateVideoPipeline && !this.isCameraOn) {
1019
+ if (!isSignalingOnly) {
1020
+ void this.sendCameraState(false).catch(() => { });
1021
+ }
1022
+ void this.api.call
1023
+ .updateCallMediaState(this.partnerId, this.callId, {
1024
+ isOnCam: 0,
1025
+ isOnMic: -1,
1026
+ data: "",
1027
+ })
1028
+ .catch(() => { });
1029
+ }
1030
+ if (!isSignalingOnly) {
1031
+ this.startUdpPingLoop();
1032
+ this.startMediaKeepaliveLoop();
1033
+ }
1034
+ this.setState("active");
1035
+ const params = this.getConnectionParams();
1036
+ this.emit("connection_params", params);
1037
+ this.emit("active", params);
1038
+ return params;
1039
+ }
1040
+ /**
1041
+ * Từ chối cuộc gọi đến (1-1 hoặc Nhóm) từ sự kiện `call_event` (`act: "request"` / `"group_request"`):
1042
+ * - Với 1-1: gọi `/api/voicecall/cancel` (`11305`) và `/api/voicecall/endcall` (`11306`)
1043
+ * - Với Nhóm: gọi `/api/voicecall/group/cancel` (`12437`) và `/api/voicecall/group/endcall` (`12438`)
1044
+ */
1045
+ async rejectIncomingCall(callEvent, options = {}) {
1046
+ const info = parseIncomingCallEvent(callEvent, this.api.getContext().uid);
1047
+ const callerId = info.callerId || info.hostCall;
1048
+ const callId = info.callId;
1049
+ if (!callerId || !callId) {
1050
+ throw new ZaloApiError("Invalid incoming call event: missing callerId or callId");
1051
+ }
1052
+ this.callId = callId;
1053
+ this.partnerId = callerId;
1054
+ if (info.isGroupCall) {
1055
+ const hostCall = info.hostCall || info.groupId || callerId;
1056
+ this.groupId = hostCall;
1057
+ await this.api.call
1058
+ .cancelGroupCall(callId, {
1059
+ hostCall,
1060
+ callType: info.callType,
1061
+ partners: info.partners,
1062
+ })
1063
+ .catch(() => { });
1064
+ await this.api.call
1065
+ .endGroupCall(callId, {
1066
+ hostCall,
1067
+ callType: info.callType,
1068
+ partners: info.partners,
1069
+ })
1070
+ .catch(() => { });
1071
+ }
1072
+ else {
1073
+ await this.api.call
1074
+ .cancelVoiceCall(callerId, callId, {
1075
+ callType: info.callType,
1076
+ })
1077
+ .catch(() => { });
1078
+ await this.api.call
1079
+ .endVoiceCall(callerId, callId, {
1080
+ status: options.status ?? 3,
1081
+ })
1082
+ .catch(() => { });
1083
+ }
1084
+ this.setState("ended");
1085
+ this.cleanupResources();
1086
+ this.emit("ended", "rejected");
1087
+ }
1088
+ /**
1089
+ * Chấp nhận lời mời gọi Nhóm đến từ sự kiện `call_event` (`act: "group_request"`):
1090
+ * 1. Gửi `/api/voicecall/group/ringring` (`12439`, signal `506`)
1091
+ * 2. Mở socket UDP và bắt tay `InitCallCallee` (`0x01 0x7e`, `cmd=12`) (bỏ qua nếu `signalingOnly: true`)
1092
+ * 3. Gửi `/api/voicecall/group/answer` (`12436`, signal `503`)
1093
+ */
1094
+ async acceptIncomingGroupCall(callEvent, options = {}) {
1095
+ const info = parseIncomingCallEvent(callEvent, this.api.getContext().uid);
1096
+ const hostCall = info.hostCall || info.groupId || info.callerId;
1097
+ const callId = info.callId;
1098
+ if (!hostCall || !callId) {
1099
+ throw new ZaloApiError("Invalid incoming group call event: missing hostCall or callId");
1100
+ }
1101
+ this.resetSessionStateForNewCall(callId);
1102
+ const isSignalingOnly = Boolean(options.signalingOnly ?? this.options.signalingOnly);
1103
+ this.isCaller = false;
1104
+ this.callId = callId;
1105
+ this.groupId = info.groupId || hostCall;
1106
+ this.partnerId = hostCall;
1107
+ this.groupPartners = info.partners;
1108
+ this.fromUid32 = info.fromUid32;
1109
+ this.toUid32 = info.toUid32;
1110
+ this.sessId = info.sessId;
1111
+ this.codec = options.codec ?? info.codec ?? DEFAULT_ZALO_AUDIO_CODEC;
1112
+ this.zrtpKey = info.zrtpKey;
1113
+ this.hasReceivedPartnerMedia = false;
1114
+ this.hasSentAnswerAck = false;
1115
+ this.isScreenSharing = false;
1116
+ this.activeStartedAtMs = 0;
1117
+ this.rtpEndpoint = {
1118
+ address: info.rtpAddress,
1119
+ host: info.rtpHost,
1120
+ port: info.rtpPort,
1121
+ };
1122
+ this.rtcpEndpoint = {
1123
+ address: info.rtcpAddress,
1124
+ host: info.rtcpHost,
1125
+ port: info.rtcpPort,
1126
+ };
1127
+ this.callType =
1128
+ info.callType === CallType.GroupVideo || info.isVideo
1129
+ ? CallType.GroupVideo
1130
+ : CallType.GroupAudio;
1131
+ this.isCameraOn = this.callType === CallType.GroupVideo && !options.offCameraAtStart;
1132
+ this.isMicMuted = false;
1133
+ this.isHold = false;
1134
+ if (this.options.autoAttachListener) {
1135
+ this.attachCallEventListener();
1136
+ }
1137
+ if (!options.skipRingRing) {
1138
+ await this.api.call.ringRingGroupCall(this.callId, {
1139
+ hostCall: this.partnerId,
1140
+ callType: this.callType,
1141
+ partners: this.groupPartners,
1142
+ });
1143
+ }
1144
+ if (!isSignalingOnly) {
1145
+ await this.ensureUdpSocket();
1146
+ if (!options.skipUdpHandshake && this.rtpEndpoint.port > 0) {
1147
+ this.setState("handshaking_udp");
1148
+ await this.performUdpInitHandshake(false);
1149
+ }
1150
+ else {
1151
+ this.routingToken = this.callId >>> 0;
1152
+ }
1153
+ }
1154
+ else {
1155
+ this.routingToken = this.callId >>> 0;
1156
+ }
1157
+ const isVideo = this.callType === CallType.GroupVideo;
1158
+ this.extendData = buildZrtcExtendData({
1159
+ rtpAddress: this.rtpEndpoint.address,
1160
+ rtcpAddress: this.rtcpEndpoint.address,
1161
+ isVideo: isVideo || (options.allowVideoUpgrade ?? true),
1162
+ packetMode: 2,
1163
+ srtpMode: this.srtpSession ? 1 : 0,
1164
+ srtpKey: this.srtpSession?.masterKeyBase64,
1165
+ });
1166
+ if (!options.skipAnswerSignal) {
1167
+ await this.api.call.answerGroupCall(this.callId, {
1168
+ hostCall: this.partnerId,
1169
+ callType: this.callType,
1170
+ partners: this.groupPartners,
1171
+ data: this.extendData,
1172
+ });
1173
+ }
1174
+ if (!isSignalingOnly) {
1175
+ this.startUdpPingLoop();
1176
+ this.startMediaKeepaliveLoop();
1177
+ }
1178
+ this.startGroupPingLoop();
1179
+ this.setState("active");
1180
+ const params = this.getConnectionParams();
1181
+ this.emit("connection_params", params);
1182
+ this.emit("active", params);
1183
+ return params;
1184
+ }
1185
+ /**
1186
+ * Khởi tạo cuộc gọi nhóm (Group Audio `CallType.GroupAudio = 5` hoặc Group Video `CallType.GroupVideo = 6`):
1187
+ * 1. Gọi `/api/voicecall/group/requestcall` (501) xin cấp phát máy chủ Relay UDP
1188
+ * 2. Mở socket UDP và bắt tay `0x01 0x7e` (`cmd=11`) (bỏ qua nếu `signalingOnly: true`)
1189
+ * 3. Gọi `/api/voicecall/group/request` (513) mời danh sách `partners` tham gia
1190
+ * 4. Duy trì nhịp tim UDP + `/api/voicecall/group/ping` (509) và luồng RTP/RTCP
1191
+ */
1192
+ async startGroupCall(groupId, options) {
1193
+ if (!groupId)
1194
+ throw new ZaloApiError("Missing groupId");
1195
+ if (!options?.partners || options.partners.length === 0) {
1196
+ throw new ZaloApiError("Missing group call partners");
1197
+ }
1198
+ this.resetSessionStateForNewCall();
1199
+ const isSignalingOnly = Boolean(options.signalingOnly ?? this.options.signalingOnly);
1200
+ this.isCaller = true;
1201
+ this.groupId = String(groupId);
1202
+ this.partnerId = String(groupId);
1203
+ this.groupPartners = options.partners.map(String);
1204
+ this.callType = options.callType ?? CallType.GroupAudio;
1205
+ this.isCameraOn =
1206
+ (this.callType === CallType.GroupVideo || this.callType === CallType.Video) &&
1207
+ !options.offCameraAtStart;
1208
+ this.isMicMuted = false;
1209
+ this.isHold = false;
1210
+ this.isScreenSharing = false;
1211
+ this.activeStartedAtMs = 0;
1212
+ this.callId = options.callId ?? Math.floor(Math.random() * 2_000_000_000);
1213
+ this.codec = options.codec ?? DEFAULT_ZALO_AUDIO_CODEC;
1214
+ this.udpHandshakeVerified = false;
1215
+ this.hasReceivedPartnerMedia = false;
1216
+ this.hasSentAnswerAck = false;
1217
+ this.setState("allocating");
1218
+ if (this.options.autoAttachListener) {
1219
+ this.attachCallEventListener();
1220
+ }
1221
+ const isVideo = this.callType === CallType.GroupVideo || this.callType === CallType.Video;
1222
+ const reqRes = await this.api.call.requestGroupCall(this.groupId, {
1223
+ callId: this.callId,
1224
+ typeRequest: this.callType,
1225
+ partners: this.groupPartners,
1226
+ data: { codec: this.codec, isVideo: isVideo ? 1 : 0 },
1227
+ });
1228
+ const serverInfo = (typeof reqRes?.data === "string"
1229
+ ? JSON.parse(reqRes.data)
1230
+ : (reqRes?.data ?? reqRes));
1231
+ this.applyServerInfo(serverInfo);
1232
+ if (!isSignalingOnly) {
1233
+ await this.ensureUdpSocket();
1234
+ if (!options.skipUdpHandshake && this.rtpEndpoint.port > 0) {
1235
+ this.setState("handshaking_udp");
1236
+ await this.performUdpInitHandshake(true);
1237
+ }
1238
+ else {
1239
+ this.routingToken = this.callId >>> 0;
1240
+ }
1241
+ }
1242
+ else {
1243
+ this.routingToken = this.callId >>> 0;
1244
+ }
1245
+ this.extendData = {
1246
+ ...buildZrtcExtendData({
1247
+ rtpAddress: this.rtpEndpoint.address,
1248
+ rtcpAddress: this.rtcpEndpoint.address,
1249
+ isVideo,
1250
+ packetMode: 2,
1251
+ srtpMode: this.srtpSession ? 1 : 0,
1252
+ srtpKey: this.srtpSession?.masterKeyBase64,
1253
+ }),
1254
+ rtcpDiff: 0,
1255
+ rtcp: [{ ip: this.rtcpEndpoint.host, port: this.rtcpEndpoint.port }],
1256
+ rtp: [{ ip: this.rtpEndpoint.host, port: this.rtpEndpoint.port }],
1257
+ status: 0,
1258
+ };
1259
+ await this.api.call.sendRequestGroupCall(this.groupId, {
1260
+ callId: this.callId,
1261
+ callType: this.callType,
1262
+ session: this.sessId,
1263
+ partners: this.groupPartners,
1264
+ data: this.extendData,
1265
+ });
1266
+ if (!isSignalingOnly) {
1267
+ this.startUdpPingLoop();
1268
+ this.startMediaKeepaliveLoop();
1269
+ }
1270
+ this.startGroupPingLoop();
1271
+ this.setState("active");
1272
+ const params = this.getConnectionParams();
1273
+ this.emit("connection_params", params);
1274
+ this.emit("active", params);
1275
+ return params;
1276
+ }
1277
+ /**
1278
+ * Tham gia cuộc gọi nhóm đang diễn ra (`/api/voicecall/group/join`, command `12043`).
1279
+ */
1280
+ async joinActiveGroupCall(callId, options) {
1281
+ if (!callId)
1282
+ throw new ZaloApiError("Missing callId");
1283
+ if (!options?.hostCall)
1284
+ throw new ZaloApiError("Missing hostCall");
1285
+ this.resetSessionStateForNewCall();
1286
+ const isSignalingOnly = Boolean(options.signalingOnly ?? this.options.signalingOnly);
1287
+ this.isCaller = false;
1288
+ this.callId = callId;
1289
+ this.groupId = String(options.groupId ?? options.hostCall);
1290
+ this.partnerId = String(options.hostCall);
1291
+ this.groupPartners = (options.partners ?? []).map(String);
1292
+ this.callType = options.callType ?? CallType.GroupAudio;
1293
+ this.isCameraOn = this.callType === CallType.GroupVideo || this.callType === CallType.Video;
1294
+ this.isMicMuted = false;
1295
+ this.isHold = false;
1296
+ this.isScreenSharing = false;
1297
+ this.activeStartedAtMs = 0;
1298
+ this.sessId = options.session ?? `${this.partnerId}_${this.callId}_501`;
1299
+ if (this.options.autoAttachListener) {
1300
+ this.attachCallEventListener();
1301
+ }
1302
+ const isVideo = this.callType === CallType.GroupVideo || this.callType === CallType.Video;
1303
+ const joinRes = await this.api.call.joinGroupCall(this.callId, {
1304
+ hostCall: String(options.hostCall),
1305
+ audioState: this.isMicMuted ? 0 : 1,
1306
+ videoState: isVideo ? 1 : 0,
1307
+ data: { codec: this.codec },
1308
+ });
1309
+ const serverInfo = (typeof joinRes?.data === "string"
1310
+ ? JSON.parse(joinRes.data)
1311
+ : (joinRes?.data ?? joinRes));
1312
+ if (options.rtpAddress) {
1313
+ this.rtpEndpoint = parseEndpoint(options.rtpAddress);
1314
+ this.rtcpEndpoint = parseEndpoint(options.rtcpAddress || options.rtpAddress);
1315
+ }
1316
+ else {
1317
+ this.applyServerInfo(serverInfo);
1318
+ }
1319
+ if (!isSignalingOnly) {
1320
+ await this.ensureUdpSocket();
1321
+ if (!options.skipUdpHandshake && this.rtpEndpoint.port > 0) {
1322
+ this.setState("handshaking_udp");
1323
+ await this.performUdpInitHandshake(false);
1324
+ }
1325
+ else {
1326
+ this.routingToken = this.callId >>> 0;
1327
+ }
1328
+ this.startUdpPingLoop();
1329
+ this.startMediaKeepaliveLoop();
1330
+ }
1331
+ else {
1332
+ this.routingToken = this.callId >>> 0;
1333
+ }
1334
+ this.startGroupPingLoop();
1335
+ this.setState("active");
1336
+ const params = this.getConnectionParams();
1337
+ this.emit("connection_params", params);
1338
+ this.emit("active", params);
1339
+ return params;
1340
+ }
1341
+ /**
1342
+ * Mời thêm thành viên vào cuộc gọi nhóm đang diễn ra (`/api/voicecall/group/adduser`, command `12435`).
1343
+ */
1344
+ async addMembersToGroupCall(partners) {
1345
+ if (!this.callId)
1346
+ throw new ZaloApiError("No active group call");
1347
+ const cleanPartners = partners.map(String).filter(Boolean);
1348
+ for (const p of cleanPartners) {
1349
+ if (!this.groupPartners.includes(p))
1350
+ this.groupPartners.push(p);
1351
+ }
1352
+ return this.api.call.addUserGroupCall(this.callId, {
1353
+ hostCall: this.groupId || this.partnerId,
1354
+ session: this.sessId,
1355
+ callType: this.callType,
1356
+ partners: cleanPartners,
1357
+ data: this.extendData,
1358
+ });
1359
+ }
1360
+ /**
1361
+ * Bật / tắt Camera trong cuộc gọi Video (đồng bộ cả gói UDP `cmd=32, subCmd=7/8` và HTTP `/api/voicecall/conf` hoặc `/api/voicecall/group/broadcast`).
1362
+ */
1363
+ async setCameraEnabled(enabled) {
1364
+ const wasAudioCall = this.callType === CallType.Audio || this.callType === CallType.GroupAudio;
1365
+ this.isCameraOn = enabled;
1366
+ if (enabled && this.callType === CallType.Audio) {
1367
+ this.callType = CallType.Video;
1368
+ }
1369
+ else if (enabled && this.callType === CallType.GroupAudio) {
1370
+ this.callType = CallType.GroupVideo;
1371
+ }
1372
+ if (enabled) {
1373
+ this.startMediaKeepaliveLoop();
1374
+ }
1375
+ await this.sendCameraState(enabled).catch(() => { });
1376
+ if (this.groupId && this.callId > 0) {
1377
+ await this.api.call
1378
+ .broadcastGroupCallState(this.callId, {
1379
+ hostCall: this.groupId,
1380
+ audioState: this.isMicMuted ? 0 : 1,
1381
+ videoState: enabled ? 1 : 0,
1382
+ })
1383
+ .catch(() => { });
1384
+ }
1385
+ else if (this.partnerId && this.callId > 0) {
1386
+ if (enabled && wasAudioCall) {
1387
+ await this.api.call
1388
+ .requestAudioToVideoCall(this.partnerId, this.callId, { status: 1 })
1389
+ .catch(() => { });
1390
+ }
1391
+ await this.api.call
1392
+ .updateCallMediaState(this.partnerId, this.callId, {
1393
+ isOnCam: enabled ? 1 : 0,
1394
+ isOnMic: -1,
1395
+ data: "",
1396
+ })
1397
+ .catch(() => { });
1398
+ }
1399
+ if (enabled) {
1400
+ void this.sendRtcpPli().catch(() => { });
1401
+ }
1402
+ this.emit("media_state_change", {
1403
+ isLocal: true,
1404
+ isCameraOn: this.isCameraOn,
1405
+ isMicMuted: this.isMicMuted,
1406
+ isHold: this.isHold,
1407
+ isScreenSharing: this.isScreenSharing,
1408
+ callType: this.callType,
1409
+ });
1410
+ }
1411
+ /**
1412
+ * Bật / tắt Microphone trong cuộc gọi (đồng bộ cả gói UDP `cmd=32, subCmd=5/6` và HTTP `/api/voicecall/conf` hoặc `/api/voicecall/group/broadcast`).
1413
+ */
1414
+ async setMicMuted(muted) {
1415
+ this.isMicMuted = muted;
1416
+ await this.sendMicState(!muted).catch(() => { });
1417
+ if (this.groupId && this.callId > 0) {
1418
+ await this.api.call
1419
+ .broadcastGroupCallState(this.callId, {
1420
+ hostCall: this.groupId,
1421
+ audioState: muted ? 0 : 1,
1422
+ videoState: this.isCameraOn ? 1 : 0,
1423
+ })
1424
+ .catch(() => { });
1425
+ }
1426
+ else if (this.partnerId && this.callId > 0) {
1427
+ await this.api.call
1428
+ .updateCallMediaState(this.partnerId, this.callId, {
1429
+ isOnCam: -1,
1430
+ isOnMic: muted ? 0 : 1,
1431
+ data: "",
1432
+ })
1433
+ .catch(() => { });
1434
+ }
1435
+ this.emit("media_state_change", {
1436
+ isLocal: true,
1437
+ isCameraOn: this.isCameraOn,
1438
+ isMicMuted: this.isMicMuted,
1439
+ isHold: this.isHold,
1440
+ isScreenSharing: this.isScreenSharing,
1441
+ callType: this.callType,
1442
+ });
1443
+ }
1444
+ /**
1445
+ * Tạm giữ (`hold`) hoặc tiếp tục (`unhold`) cuộc gọi 1-1 (`/api/voicecall/holdreq` & `/api/voicecall/unholdreq`).
1446
+ */
1447
+ async setHoldState(isHold, isResponse = false) {
1448
+ this.isHold = isHold;
1449
+ if (this.partnerId) {
1450
+ if (isHold) {
1451
+ await this.api.call.holdVoiceCall(this.partnerId, { isResponse }).catch(() => { });
1452
+ }
1453
+ else {
1454
+ await this.api.call.unholdVoiceCall(this.partnerId, { isResponse }).catch(() => { });
1455
+ this.clearIncomingVideoAssemblyBuffers();
1456
+ void this.sendMicState(!this.isMicMuted).catch(() => { });
1457
+ void this.sendCameraState(this.isCameraOn).catch(() => { });
1458
+ void this.sendRtcpPli().catch(() => { });
1459
+ }
1460
+ }
1461
+ this.emit("media_state_change", {
1462
+ isLocal: true,
1463
+ isCameraOn: this.isCameraOn,
1464
+ isMicMuted: this.isMicMuted,
1465
+ isHold: this.isHold,
1466
+ isScreenSharing: this.isScreenSharing,
1467
+ callType: this.callType,
1468
+ });
1469
+ }
1470
+ /**
1471
+ * Bật / tắt chia sẻ màn hình trong cuộc gọi 1-1 (`/api/voicecall/requestsharescreen`, command `12064`)
1472
+ * hoặc cuộc gọi Nhóm (`/api/voicecall/group/sharescreen`, command `12044`).
1473
+ */
1474
+ async setScreenShareEnabled(enabled) {
1475
+ this.isScreenSharing = enabled;
1476
+ if (this.groupId && this.callId > 0) {
1477
+ await this.api.call
1478
+ .shareScreenGroupCall(this.callId, {
1479
+ hostCall: this.groupId,
1480
+ status: enabled ? 1 : 0,
1481
+ audioState: this.isMicMuted ? 0 : 1,
1482
+ videoState: this.isCameraOn || enabled ? 1 : 0,
1483
+ })
1484
+ .catch(() => { });
1485
+ }
1486
+ else if (this.partnerId && this.callId > 0) {
1487
+ await this.api.call
1488
+ .requestShareScreenCall(this.partnerId, this.callId, {
1489
+ status: enabled ? 1 : 0,
1490
+ })
1491
+ .catch(() => { });
1492
+ }
1493
+ this.emit("media_state_change", {
1494
+ isLocal: true,
1495
+ isCameraOn: this.isCameraOn,
1496
+ isMicMuted: this.isMicMuted,
1497
+ isHold: this.isHold,
1498
+ isScreenSharing: this.isScreenSharing,
1499
+ callType: this.callType,
1500
+ });
1501
+ }
1502
+ /**
1503
+ * Chuyển đổi cuộc gọi Thoại (Audio) đang diễn ra lên cuộc gọi Video (`/api/voicecall/requestaudio2video`, command `12056`):
1504
+ * - `status = 1`: Gửi yêu cầu bật Video
1505
+ * - `status = 2`: Đồng ý yêu cầu bật Video từ đối phương
1506
+ * Tự động kích hoạt luồng Video RTP/RTCP, tín hiệu UDP `OnCamera` (`cmd=32, subCmd=7`) & HTTP `/api/voicecall/conf` (`isOnCam=1, isOnMic=-1`) ngay lập tức.
1507
+ */
1508
+ async upgradeAudioToVideoCall(status = 1) {
1509
+ this.callType = this.groupId ? CallType.GroupVideo : CallType.Video;
1510
+ this.isCameraOn = true;
1511
+ this.startMediaKeepaliveLoop();
1512
+ await this.sendCameraState(true).catch(() => { });
1513
+ if (Date.now() - this.lastExternalVideoSentAtMs >= 300) {
1514
+ await this.sendH264AnnexBFrame(H264_KEEPALIVE_ANNEXB_FRAME, {
1515
+ timestampStep: 6000,
1516
+ isKeepalive: true,
1517
+ }).catch(() => { });
1518
+ }
1519
+ if (this.partnerId && this.callId > 0 && !this.groupId) {
1520
+ await this.api.call
1521
+ .requestAudioToVideoCall(this.partnerId, this.callId, { status })
1522
+ .catch(() => { });
1523
+ await this.api.call
1524
+ .updateCallMediaState(this.partnerId, this.callId, {
1525
+ isOnCam: 1,
1526
+ isOnMic: -1,
1527
+ data: "",
1528
+ })
1529
+ .catch(() => { });
1530
+ }
1531
+ else if (this.groupId && this.callId > 0) {
1532
+ await this.api.call
1533
+ .broadcastGroupCallState(this.callId, {
1534
+ hostCall: this.groupId,
1535
+ audioState: this.isMicMuted ? 0 : 1,
1536
+ videoState: 1,
1537
+ })
1538
+ .catch(() => { });
1539
+ }
1540
+ this.emit("media_state_change", {
1541
+ isLocal: true,
1542
+ isCameraOn: this.isCameraOn,
1543
+ isMicMuted: this.isMicMuted,
1544
+ isHold: this.isHold,
1545
+ isScreenSharing: this.isScreenSharing,
1546
+ callType: this.callType,
1547
+ });
1548
+ }
1549
+ /**
1550
+ * Gửi trực tiếp một khung âm thanh Opus (mặc định 20ms @ 16kHz = 320 samples)
1551
+ * qua đường truyền UDP của cuộc gọi.
1552
+ * Tự động đóng gói RTP 12-byte header -> (Tuỳ chọn) Mã hoá SRTP -> Bọc `ZRTPPacket` header.
1553
+ */
1554
+ async sendAudioFrame(opusPayload, options = {}) {
1555
+ if (!options.isKeepalive) {
1556
+ this.lastExternalAudioSentAtMs = Date.now();
1557
+ }
1558
+ const seq = options.sequenceNumber ?? (this.audioSeq = (this.audioSeq + 1) & 0xffff);
1559
+ const samples = options.samples ?? 320;
1560
+ const ts = options.timestamp ?? (this.audioTimestamp = (this.audioTimestamp + samples) >>> 0);
1561
+ const ssrc = options.ssrc ?? this.getLocalAudioSsrc();
1562
+ this.audioPacketCount = (this.audioPacketCount + 1) >>> 0;
1563
+ this.audioOctetCount = (this.audioOctetCount + opusPayload.length) >>> 0;
1564
+ const rtpPacket = buildRtpPacket({
1565
+ payloadType: options.payloadType ?? 112,
1566
+ sequenceNumber: seq,
1567
+ timestamp: ts,
1568
+ ssrc,
1569
+ marker: options.marker,
1570
+ payload: opusPayload,
1571
+ });
1572
+ await this.sendAudioRtp(rtpPacket);
1573
+ }
1574
+ /**
1575
+ * Gửi một gói tin chuẩn WebRTC RTP (Audio) qua đường truyền UDP Zalo.
1576
+ */
1577
+ async sendAudioRtp(rtpPacket) {
1578
+ const protectedRtp = this.srtpSession ? this.srtpSession.protectRtp(rtpPacket) : rtpPacket;
1579
+ const token = (this.routingToken || this.callId) >>> 0;
1580
+ const role = this.isCaller ? ZRTCRole.Caller : ZRTCRole.Callee;
1581
+ const framed = this.options.packetMode === 1
1582
+ ? wrapLegacyMediaPacket(ZRTPLegacyMediaType.AudioRtp, token, protectedRtp)
1583
+ : wrapZrtcMediaPacket(ZRTCMediaSubCommand.AudioRtp, token, protectedRtp, role);
1584
+ await this.sendRawUdp(framed, this.rtpEndpoint.host, this.rtpEndpoint.port);
1585
+ }
1586
+ /**
1587
+ * Gửi trực tiếp một khung hình ảnh H.264 NAL / RTP payload (mặc định 90kHz clock, payloadType=97)
1588
+ * qua đường truyền UDP của cuộc gọi Video.
1589
+ */
1590
+ async sendVideoFrame(h264Payload, options = {}) {
1591
+ if (!options.isKeepalive) {
1592
+ this.lastExternalVideoSentAtMs = Date.now();
1593
+ }
1594
+ const seq = options.sequenceNumber ?? (this.videoSeq = (this.videoSeq + 1) & 0xffff);
1595
+ const step = options.timestampStep ?? 3750; // ~24fps @ 90kHz
1596
+ const ts = options.timestamp ?? (this.videoTimestamp = (this.videoTimestamp + step) >>> 0);
1597
+ const ssrc = options.ssrc ?? this.getLocalVideoSsrc();
1598
+ this.videoPacketCount = (this.videoPacketCount + 1) >>> 0;
1599
+ this.videoOctetCount = (this.videoOctetCount + h264Payload.length) >>> 0;
1600
+ const rtpPacket = buildRtpPacket({
1601
+ payloadType: options.payloadType ?? 97,
1602
+ sequenceNumber: seq,
1603
+ timestamp: ts,
1604
+ ssrc,
1605
+ marker: options.marker ?? true,
1606
+ payload: h264Payload,
1607
+ });
1608
+ await this.sendVideoRtp(rtpPacket);
1609
+ }
1610
+ /**
1611
+ * Đóng gói một khung hình H.264 dạng Annex-B (`00 00 00 01 <NAL>`) theo chuẩn RFC 6184:
1612
+ * - Tự động tách các NAL unit, lưu cache SPS (`type=7`) / PPS (`type=8`) và đóng gói STAP-A (`0x78`) trước IDR (`type=5`)
1613
+ * - Không cho phép khung keepalive 16x16 ghi đè `localSpsNal` / `localPpsNal` của camera thực
1614
+ * - Tự động phân mảnh các NAL lớn (> 1150 bytes) thành các gói RTP **FU-A (`type=28`)** cùng `timestamp` 90kHz
1615
+ * - Đặt cờ `marker = true` ở gói RTP cuối cùng của khung hình để bộ giải mã đối phương hiển thị ngay.
1616
+ */
1617
+ async sendH264AnnexBFrame(annexB, options = {}) {
1618
+ const rawNals = splitAnnexBNals(annexB).filter((n) => n.length > 0);
1619
+ if (rawNals.length === 0)
1620
+ return;
1621
+ if (!options.isKeepalive) {
1622
+ this.lastExternalVideoSentAtMs = Date.now();
1623
+ }
1624
+ let inlineSps = null;
1625
+ let inlinePps = null;
1626
+ let hasIdr = false;
1627
+ const vclAndSeiNals = [];
1628
+ for (const nal of rawNals) {
1629
+ const nalType = nal[0] & 0x1f;
1630
+ if (nalType === 7) {
1631
+ inlineSps = Buffer.from(nal);
1632
+ if (!options.isKeepalive || !this.localSpsNal) {
1633
+ this.localSpsNal = inlineSps;
1634
+ }
1635
+ }
1636
+ else if (nalType === 8) {
1637
+ inlinePps = Buffer.from(nal);
1638
+ if (!options.isKeepalive || !this.localPpsNal) {
1639
+ this.localPpsNal = inlinePps;
1640
+ }
1641
+ }
1642
+ else if (nalType === 9) {
1643
+ // Bỏ qua Access Unit Delimiter (AUD, type=9) để tương thích phần cứng VideoToolbox iOS
1644
+ continue;
1645
+ }
1646
+ else {
1647
+ if (nalType === 5)
1648
+ hasIdr = true;
1649
+ vclAndSeiNals.push(nal);
1650
+ }
1651
+ }
1652
+ const spsToUse = inlineSps ?? (hasIdr ? this.localSpsNal : null);
1653
+ const ppsToUse = inlinePps ?? (hasIdr ? this.localPpsNal : null);
1654
+ const step = options.timestampStep ?? 6000; // ~15fps @ 90kHz
1655
+ const frameTs = options.timestamp ?? (this.videoTimestamp = (this.videoTimestamp + step) >>> 0);
1656
+ const maxPayloadSize = 1150;
1657
+ const rtpPayloads = [];
1658
+ // Gộp SPS + PPS vào một gói RFC 6184 STAP-A (type=24, 0x78) duy nhất trước IDR
1659
+ // khớp với RtpPacketizerH264::PacketizeStapA (0x1003db010)
1660
+ if (spsToUse && ppsToUse && spsToUse.length + ppsToUse.length + 5 <= maxPayloadSize) {
1661
+ const stapA = Buffer.allocUnsafe(1 + 2 + spsToUse.length + 2 + ppsToUse.length);
1662
+ stapA[0] = 0x78; // F=0, NRI=3 (0x60), Type=24 (STAP-A)
1663
+ stapA.writeUInt16BE(spsToUse.length, 1);
1664
+ spsToUse.copy(stapA, 3);
1665
+ const ppsOffset = 3 + spsToUse.length;
1666
+ stapA.writeUInt16BE(ppsToUse.length, ppsOffset);
1667
+ ppsToUse.copy(stapA, ppsOffset + 2);
1668
+ rtpPayloads.push(stapA);
1669
+ }
1670
+ else {
1671
+ if (spsToUse)
1672
+ rtpPayloads.push(spsToUse);
1673
+ if (ppsToUse)
1674
+ rtpPayloads.push(ppsToUse);
1675
+ }
1676
+ for (const nal of vclAndSeiNals) {
1677
+ if (nal.length <= maxPayloadSize) {
1678
+ rtpPayloads.push(nal);
1679
+ }
1680
+ else {
1681
+ // RFC 6184 FU-A (type = 28) fragmentation
1682
+ const nalHeader = nal[0];
1683
+ const fnri = nalHeader & 0xe0;
1684
+ const nalType = nalHeader & 0x1f;
1685
+ const fuIndicator = fnri | 28;
1686
+ const body = nal.subarray(1);
1687
+ let offset = 0;
1688
+ while (offset < body.length) {
1689
+ const chunkLen = Math.min(maxPayloadSize, body.length - offset);
1690
+ const isFirst = offset === 0;
1691
+ const isLast = offset + chunkLen >= body.length;
1692
+ const fuHeader = (isFirst ? 0x80 : 0) | (isLast ? 0x40 : 0) | nalType;
1693
+ const frag = Buffer.allocUnsafe(2 + chunkLen);
1694
+ frag[0] = fuIndicator;
1695
+ frag[1] = fuHeader;
1696
+ body.copy(frag, 2, offset, offset + chunkLen);
1697
+ rtpPayloads.push(frag);
1698
+ offset += chunkLen;
1699
+ }
1700
+ }
1701
+ }
1702
+ // Trong RTPSender::SendOutgoingData (0x1003ed76c) & RTPReceiverVideo::ParseRtpPacket (0x1003e8b4c):
1703
+ // Zalo quy định:
1704
+ // - Keyframe H.264 (SPS+PPS+IDR, frameType=3) BẮT BUỘC gửi trên payloadType = 98 (0x62)
1705
+ // - Delta frame H.264 (P-slice, frameType=4) gửi trên payloadType = 97 (0x61)
1706
+ const resolvedPayloadType = options.payloadType ?? (hasIdr ? 98 : 97);
1707
+ for (let i = 0; i < rtpPayloads.length; i++) {
1708
+ const isLastPacket = i === rtpPayloads.length - 1;
1709
+ await this.sendVideoFrame(rtpPayloads[i], {
1710
+ payloadType: resolvedPayloadType,
1711
+ timestamp: frameTs,
1712
+ ssrc: options.ssrc ?? this.getLocalVideoSsrc(),
1713
+ marker: isLastPacket,
1714
+ isKeepalive: options.isKeepalive,
1715
+ });
1716
+ }
1717
+ }
1718
+ /**
1719
+ * Gửi tín hiệu bật/tắt Camera (`cmd = 32, subCmd = 7` `OnCamera` / `subCmd = 8` `OffCamera`)
1720
+ * khớp với `CallController::vidSendSignalOffCamera` (`0x10016fbe4`).
1721
+ * Phát lặp 3 lần (0ms, 60ms, 150ms) để chống mất gói UDP.
1722
+ */
1723
+ async sendCameraState(isCameraOn, reason = 0) {
1724
+ if (!this.udpSocket || this.rtpEndpoint.port <= 0 || this.state === "ended")
1725
+ return;
1726
+ const fromUid = this.fromUid32 || uidToUint32(this.api.getContext().uid);
1727
+ const token = (this.routingToken || this.callId) >>> 0;
1728
+ const payload = Buffer.alloc(4);
1729
+ payload.writeInt32LE(reason | 0, 0);
1730
+ const pkt = buildRequestForwardPacket({
1731
+ seqNum: 0,
1732
+ seqId: 0,
1733
+ fromUid,
1734
+ routingToken: token,
1735
+ subCmd: isCameraOn ? ZRTPForwardSubCommand.OnCamera : ZRTPForwardSubCommand.OffCamera,
1736
+ payload,
1737
+ });
1738
+ await this.sendRawUdp(pkt, this.rtpEndpoint.host, this.rtpEndpoint.port);
1739
+ for (const delayMs of [60, 150]) {
1740
+ const t = setTimeout(() => {
1741
+ if (this.udpSocket && this.rtpEndpoint.port > 0 && this.state !== "ended") {
1742
+ void this.sendRawUdp(pkt, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
1743
+ }
1744
+ }, delayMs);
1745
+ if (typeof t.unref === "function")
1746
+ t.unref();
1747
+ }
1748
+ }
1749
+ /**
1750
+ * Gửi tín hiệu bật/tắt Microphone (`cmd = 32, subCmd = 5` `OnMic` / `subCmd = 6` `OffMic`)
1751
+ * qua máy chủ Relay UDP.
1752
+ */
1753
+ async sendMicState(isMicOn) {
1754
+ if (!this.udpSocket || this.rtpEndpoint.port <= 0 || this.state === "ended")
1755
+ return;
1756
+ const fromUid = this.fromUid32 || uidToUint32(this.api.getContext().uid);
1757
+ const token = (this.routingToken || this.callId) >>> 0;
1758
+ const payload = Buffer.alloc(4);
1759
+ payload.writeInt32LE(0, 0);
1760
+ const pkt = buildRequestForwardPacket({
1761
+ seqNum: 0,
1762
+ seqId: 0,
1763
+ fromUid,
1764
+ routingToken: token,
1765
+ subCmd: isMicOn ? ZRTPForwardSubCommand.OnMic : ZRTPForwardSubCommand.OffMic,
1766
+ payload,
1767
+ });
1768
+ await this.sendRawUdp(pkt, this.rtpEndpoint.host, this.rtpEndpoint.port);
1769
+ for (const delayMs of [60, 150]) {
1770
+ const t = setTimeout(() => {
1771
+ if (this.udpSocket && this.rtpEndpoint.port > 0 && this.state !== "ended") {
1772
+ void this.sendRawUdp(pkt, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
1773
+ }
1774
+ }, delayMs);
1775
+ if (typeof t.unref === "function")
1776
+ t.unref();
1777
+ }
1778
+ }
1779
+ /**
1780
+ * Gửi gói RTCP PLI (Picture Loss Indication — `PT=206, FMT=1`) yêu cầu máy đối phương
1781
+ * gửi ngay khung hình khoá H.264 IDR kèm SPS/PPS.
1782
+ */
1783
+ async sendRtcpPli() {
1784
+ if (!this.remoteVideoSsrc)
1785
+ return;
1786
+ const pli = Buffer.alloc(12);
1787
+ pli.writeUInt8(0x81, 0); // V=2, FMT=1 (PLI)
1788
+ pli.writeUInt8(206, 1); // PT=206 (PSFB)
1789
+ pli.writeUInt16BE(2, 2); // length = 2 words (12 bytes)
1790
+ pli.writeUInt32BE(this.getLocalVideoSsrc(), 4);
1791
+ pli.writeUInt32BE(this.remoteVideoSsrc >>> 0, 8);
1792
+ await this.sendRtcp(pli, true);
1793
+ }
1794
+ /**
1795
+ * Gửi một gói tin chuẩn WebRTC RTP (Video H.264) qua đường truyền UDP Zalo.
1796
+ */
1797
+ async sendVideoRtp(rtpPacket) {
1798
+ const protectedRtp = this.srtpSession ? this.srtpSession.protectRtp(rtpPacket) : rtpPacket;
1799
+ const token = (this.routingToken || this.callId) >>> 0;
1800
+ const role = this.isCaller ? ZRTCRole.Caller : ZRTCRole.Callee;
1801
+ const framed = this.options.packetMode === 1
1802
+ ? wrapLegacyMediaPacket(ZRTPLegacyMediaType.VideoRtp, token, protectedRtp)
1803
+ : wrapZrtcMediaPacket(ZRTCMediaSubCommand.VideoRtp, token, protectedRtp, role);
1804
+ await this.sendRawUdp(framed, this.rtpEndpoint.host, this.rtpEndpoint.port);
1805
+ }
1806
+ /**
1807
+ * Gửi gói tin điều khiển chất lượng RTCP (Audio hoặc Video) qua đường truyền UDP Zalo.
1808
+ */
1809
+ async sendRtcp(rtcpPacket, isVideo = false) {
1810
+ const protectedRtcp = this.srtpSession && Number(this.extendData?.srtcp) === 1
1811
+ ? this.srtpSession.protectRtcp(rtcpPacket)
1812
+ : rtcpPacket;
1813
+ const token = (this.routingToken || this.callId) >>> 0;
1814
+ const role = this.isCaller ? ZRTCRole.Caller : ZRTCRole.Callee;
1815
+ const framed = this.options.packetMode === 1
1816
+ ? wrapLegacyMediaPacket(isVideo ? ZRTPLegacyMediaType.VideoRtcp : ZRTPLegacyMediaType.AudioRtcp, token, protectedRtcp)
1817
+ : wrapZrtcMediaPacket(isVideo ? ZRTCMediaSubCommand.VideoRtcp : ZRTCMediaSubCommand.AudioRtcp, token, protectedRtcp, role);
1818
+ const target = this.rtcpEndpoint.port > 0 ? this.rtcpEndpoint : this.rtpEndpoint;
1819
+ await this.sendRawUdp(framed, target.host, target.port);
1820
+ }
1821
+ /**
1822
+ * Kết thúc cuộc gọi hiện tại:
1823
+ * 1. Gửi gói UDP `RequestEndCall` (`0x01 0x7e`, `cmd=3`, 25 bytes) 3 lần tới máy chủ Relay (`_sendRequestZRTPEndCall` @ `0x10016a880`)
1824
+ * 2. Giải phóng toàn bộ tài nguyên UDP socket & bộ định thời
1825
+ * 3. Gửi tín hiệu HTTP `/api/voicecall/cancel` (`11305`) và `/api/voicecall/endcall` (`11306`) hoặc `/api/voicecall/group/end` (`12046`)
1826
+ * cho mọi thao tác ngắt máy từ phía local (`local_end`, `user_hangup`, `new_call`, ...).
1827
+ */
1828
+ async endCall(reason = "local_end") {
1829
+ if (this.state === "ended")
1830
+ return;
1831
+ const prevState = this.state;
1832
+ this.setState("ended");
1833
+ if (this.udpSocket && this.rtpEndpoint.port > 0 && this.callId > 0) {
1834
+ try {
1835
+ const endPkt = buildEndCallRequest({
1836
+ seqNum: 0,
1837
+ callId: this.callId,
1838
+ fromUid: this.fromUid32 || uidToUint32(this.api.getContext().uid),
1839
+ toUid: this.toUid32 || uidToUint32(this.partnerId),
1840
+ routingToken: (this.routingToken || this.callId) >>> 0,
1841
+ flags: 0,
1842
+ });
1843
+ for (let i = 0; i < 3; i++) {
1844
+ await this.sendRawUdp(endPkt, this.rtpEndpoint.host, this.rtpEndpoint.port);
1845
+ }
1846
+ }
1847
+ catch {
1848
+ // Ignore UDP send error on teardown
1849
+ }
1850
+ }
1851
+ this.cleanupResources();
1852
+ const isRemoteReason = reason === "remote_end" ||
1853
+ reason === "remote_cancel" ||
1854
+ reason === "end" ||
1855
+ reason === "cancel" ||
1856
+ reason === "rejected" ||
1857
+ reason === "busy";
1858
+ if (this.callId > 0 && !isRemoteReason) {
1859
+ try {
1860
+ if (this.groupId ||
1861
+ this.callType === CallType.GroupAudio ||
1862
+ this.callType === CallType.GroupVideo) {
1863
+ if (prevState === "ringing" && !this.hasReceivedPartnerMedia && this.isCaller) {
1864
+ await this.api.call
1865
+ .cancelGroupCall(this.callId, {
1866
+ hostCall: this.groupId || this.partnerId,
1867
+ callType: this.callType,
1868
+ partners: this.groupPartners,
1869
+ })
1870
+ .catch(() => { });
1871
+ }
1872
+ await this.api.call
1873
+ .endGroupCall(this.callId, {
1874
+ hostCall: this.groupId || this.partnerId,
1875
+ callType: this.callType,
1876
+ partners: this.groupPartners,
1877
+ })
1878
+ .catch(() => { });
1879
+ }
1880
+ else if (this.partnerId) {
1881
+ const wasStillRinging = (prevState === "ringing" ||
1882
+ prevState === "handshaking_udp" ||
1883
+ prevState === "allocating") &&
1884
+ !this.hasReceivedPartnerMedia;
1885
+ if (wasStillRinging && this.isCaller) {
1886
+ await this.api.call
1887
+ .cancelVoiceCall(this.partnerId, this.callId, {
1888
+ callType: this.callType,
1889
+ })
1890
+ .catch(() => { });
1891
+ // Dự phòng trường hợp máy đối phương vừa bắt máy nhưng sự kiện WS chưa kịp về:
1892
+ await this.api.call.endVoiceCall(this.partnerId, this.callId).catch(() => { });
1893
+ }
1894
+ else {
1895
+ await this.api.call.endVoiceCall(this.partnerId, this.callId).catch(() => { });
1896
+ }
1897
+ if (this.activeStartedAtMs > 0) {
1898
+ const durationSec = Math.max(1, Math.floor((Date.now() - this.activeStartedAtMs) / 1000));
1899
+ void this.api.call
1900
+ .logEndVoiceCall(this.partnerId, this.callId, {
1901
+ duration: durationSec,
1902
+ status: 0,
1903
+ role: this.isCaller,
1904
+ })
1905
+ .catch(() => { });
1906
+ }
1907
+ }
1908
+ }
1909
+ catch {
1910
+ // Ignore signaling error if already ended
1911
+ }
1912
+ }
1913
+ this.emit("ended", reason);
1914
+ }
1915
+ /**
1916
+ * Xử lý sự kiện `call_event` từ WebSocket (hoặc truyền thủ công).
1917
+ */
1918
+ async handleCallEvent(event) {
1919
+ const rawParams = event.data.params !== undefined
1920
+ ? safeJsonParseRecord(event.data.params)
1921
+ : event.data.data !== undefined
1922
+ ? safeJsonParseRecord(event.data.data)
1923
+ : event.data;
1924
+ const eventCallId = Number(event.data.callId ??
1925
+ event.data.call_id ??
1926
+ event.data.id ??
1927
+ rawParams.callId ??
1928
+ rawParams.call_id ??
1929
+ rawParams.id ??
1930
+ 0);
1931
+ if (this.callId > 0 && eventCallId > 0 && eventCallId !== this.callId) {
1932
+ return;
1933
+ }
1934
+ this.emit("call_event", event);
1935
+ if (event.type === CallEventType.REQUEST ||
1936
+ event.type === CallEventType.GROUP_REQUEST ||
1937
+ event.act === "request" ||
1938
+ event.act === "group_request") {
1939
+ const incomingInfo = parseIncomingCallEvent(event, this.api.getContext().uid);
1940
+ this.emit("incoming_call", incomingInfo);
1941
+ return;
1942
+ }
1943
+ const extendRaw = rawParams.extendData !== undefined
1944
+ ? safeJsonParseRecord(rawParams.extendData)
1945
+ : event.data.extendData !== undefined
1946
+ ? safeJsonParseRecord(event.data.extendData)
1947
+ : {};
1948
+ if (typeof extendRaw.srtpKey === "string" && extendRaw.srtpKey.length >= 40) {
1949
+ this.srtpSession = new ZaloSrtpSession(extendRaw.srtpKey, this.options.srtpSuite);
1950
+ }
1951
+ else if (extendRaw.srtpMode === 0 && !this.options.enableSrtp) {
1952
+ this.srtpSession = null;
1953
+ }
1954
+ if (event.type === CallEventType.RINGRING) {
1955
+ if (this.state === "handshaking_udp" || this.state === "allocating") {
1956
+ this.setState("ringing");
1957
+ }
1958
+ }
1959
+ else if (event.type === CallEventType.ANSWER) {
1960
+ this.hasReceivedPartnerMedia = true;
1961
+ if (this.isCaller && this.partnerId && this.callId > 0 && !this.groupId && !this.hasSentAnswerAck) {
1962
+ this.hasSentAnswerAck = true;
1963
+ try {
1964
+ await this.api.call.answerAckVoiceCall(this.partnerId, this.callId);
1965
+ }
1966
+ catch {
1967
+ // Ignore ack failure
1968
+ }
1969
+ }
1970
+ this.setState("active");
1971
+ if (this.callType === CallType.Video ||
1972
+ this.callType === CallType.GroupVideo ||
1973
+ Number(this.extendData?.callType) === 1) {
1974
+ void this.sendCameraState(this.isCameraOn).catch(() => { });
1975
+ if (!this.isCameraOn && this.partnerId && this.callId > 0 && !this.groupId) {
1976
+ void this.api.call
1977
+ .updateCallMediaState(this.partnerId, this.callId, {
1978
+ isOnCam: 0,
1979
+ isOnMic: -1,
1980
+ data: "",
1981
+ })
1982
+ .catch(() => { });
1983
+ }
1984
+ void this.sendRtcpPli().catch(() => { });
1985
+ }
1986
+ const params = this.getConnectionParams();
1987
+ this.emit("connection_params", params);
1988
+ this.emit("active", params);
1989
+ }
1990
+ else if (event.type === CallEventType.CANCEL || event.type === CallEventType.END) {
1991
+ this.setState("ended");
1992
+ this.cleanupResources();
1993
+ this.emit("ended", event.act);
1994
+ }
1995
+ else if (event.act === "onoff_camera") {
1996
+ const camType = Number(rawParams.type ?? 0);
1997
+ const reason = Number(rawParams.reason ?? 0);
1998
+ if (camType === 1 || camType === 2) {
1999
+ this.clearIncomingVideoAssemblyBuffers();
2000
+ }
2001
+ // Khi đối phương gửi onoff_camera (đặc biệt reason=2/3 báo video loss hoặc bật lại camera), phản hồi ngay trạng thái camera và PLI
2002
+ void this.sendCameraState(this.isCameraOn).catch(() => { });
2003
+ void this.sendRtcpPli().catch(() => { });
2004
+ if (reason === 2 || reason === 3) {
2005
+ this.emit("media_state_change", {
2006
+ isLocal: false,
2007
+ rawAct: event.act,
2008
+ reason,
2009
+ });
2010
+ }
2011
+ else {
2012
+ this.emit("media_state_change", {
2013
+ isLocal: false,
2014
+ isCameraOn: camType === 1 || camType === 2,
2015
+ rawAct: event.act,
2016
+ reason,
2017
+ });
2018
+ }
2019
+ }
2020
+ else if (event.act === "audio_to_video" ||
2021
+ event.act === "requestaudio2video" ||
2022
+ Number(event.data.subCmd) === 12056) {
2023
+ const reqStatus = Number(rawParams.status ?? event.data.status ?? 1);
2024
+ this.callType = this.groupId ? CallType.GroupVideo : CallType.Video;
2025
+ this.isCameraOn = true;
2026
+ this.clearIncomingVideoAssemblyBuffers();
2027
+ this.startMediaKeepaliveLoop();
2028
+ if (reqStatus === 1 && this.partnerId && this.callId > 0) {
2029
+ void this.api.call
2030
+ .requestAudioToVideoCall(this.partnerId, this.callId, { status: 2 })
2031
+ .catch(() => { });
2032
+ }
2033
+ void this.sendCameraState(true).catch(() => { });
2034
+ void this.sendRtcpPli().catch(() => { });
2035
+ this.emit("media_state_change", {
2036
+ isLocal: false,
2037
+ callType: this.callType,
2038
+ isCameraOn: true,
2039
+ rawAct: event.act,
2040
+ });
2041
+ }
2042
+ else if (event.act === "holdreq") {
2043
+ if (this.partnerId) {
2044
+ void this.api.call.holdVoiceCall(this.partnerId, { isResponse: true }).catch(() => { });
2045
+ }
2046
+ this.emit("media_state_change", {
2047
+ isLocal: false,
2048
+ isHold: true,
2049
+ rawAct: event.act,
2050
+ });
2051
+ }
2052
+ else if (event.act === "unholdreq") {
2053
+ if (this.partnerId) {
2054
+ void this.api.call
2055
+ .unholdVoiceCall(this.partnerId, { isResponse: true })
2056
+ .catch(() => { });
2057
+ }
2058
+ this.clearIncomingVideoAssemblyBuffers();
2059
+ void this.sendMicState(!this.isMicMuted).catch(() => { });
2060
+ void this.sendCameraState(this.isCameraOn).catch(() => { });
2061
+ void this.sendRtcpPli().catch(() => { });
2062
+ this.emit("media_state_change", {
2063
+ isLocal: false,
2064
+ isHold: false,
2065
+ rawAct: event.act,
2066
+ });
2067
+ }
2068
+ else if (event.act === "requestsharescreen" ||
2069
+ event.act === "sharescreen" ||
2070
+ event.act === "group_sharescreen") {
2071
+ const shareStatus = Number(rawParams.status ?? event.data.status ?? 1);
2072
+ this.emit("media_state_change", {
2073
+ isLocal: false,
2074
+ isScreenSharing: shareStatus === 1,
2075
+ rawAct: event.act,
2076
+ });
2077
+ }
2078
+ else if (event.act === "conf" || event.act === "mute_audio") {
2079
+ this.emit("media_state_change", {
2080
+ isLocal: false,
2081
+ isCameraOn: rawParams.isOnCam !== undefined && Number(rawParams.isOnCam) !== -1
2082
+ ? Boolean(Number(rawParams.isOnCam))
2083
+ : undefined,
2084
+ isMicMuted: rawParams.isOnMic !== undefined && Number(rawParams.isOnMic) !== -1
2085
+ ? !Boolean(Number(rawParams.isOnMic))
2086
+ : undefined,
2087
+ rawAct: event.act,
2088
+ });
2089
+ }
2090
+ }
2091
+ setState(nextState) {
2092
+ if (this.state !== nextState) {
2093
+ if (nextState === "active" && !this.activeStartedAtMs) {
2094
+ this.activeStartedAtMs = Date.now();
2095
+ }
2096
+ if (nextState === "ringing" && this.options.ringingTimeoutMs > 0) {
2097
+ if (this.ringingTimeoutTimer)
2098
+ clearTimeout(this.ringingTimeoutTimer);
2099
+ this.ringingTimeoutTimer = setTimeout(() => {
2100
+ if (this.state === "ringing") {
2101
+ void this.endCall("ringing_timeout");
2102
+ }
2103
+ }, this.options.ringingTimeoutMs);
2104
+ if (typeof this.ringingTimeoutTimer.unref === "function") {
2105
+ this.ringingTimeoutTimer.unref();
2106
+ }
2107
+ }
2108
+ else if (nextState !== "ringing" && this.ringingTimeoutTimer) {
2109
+ clearTimeout(this.ringingTimeoutTimer);
2110
+ this.ringingTimeoutTimer = null;
2111
+ }
2112
+ this.state = nextState;
2113
+ this.emit("state", nextState);
2114
+ }
2115
+ }
2116
+ applyServerInfo(serverInfo) {
2117
+ this.serverInfo = serverInfo;
2118
+ const rtpServer = serverInfo?.rtpServer ?? serverInfo?.rtpIP ?? serverInfo?.servers?.[0]?.rtpaddr ?? "";
2119
+ const rtcpServer = serverInfo?.rtcpServer ??
2120
+ serverInfo?.rtcpIP ??
2121
+ serverInfo?.servers?.[0]?.rtcpaddr ??
2122
+ rtpServer;
2123
+ this.rtpEndpoint = parseEndpoint(rtpServer);
2124
+ this.rtcpEndpoint = parseEndpoint(rtcpServer || rtpServer);
2125
+ this.sessId = serverInfo?.sessId ?? `${this.partnerId}_${this.callId}_401`;
2126
+ this.zrtpKey = serverInfo?.zrtpKey;
2127
+ this.fromUid32 = Number(serverInfo?.fromId) || uidToUint32(this.api.getContext().uid);
2128
+ this.toUid32 = Number(serverInfo?.toId) || uidToUint32(this.partnerId);
2129
+ if (serverInfo?.codec && serverInfo.codec !== "[]") {
2130
+ this.codec = serverInfo.codec;
2131
+ }
2132
+ }
2133
+ async ensureUdpSocket() {
2134
+ if (this.udpSocket)
2135
+ return this.udpSocket;
2136
+ const sock = dgram.createSocket("udp4");
2137
+ this.udpSocket = sock;
2138
+ sock.on("message", (msg) => {
2139
+ this.handleIncomingUdpPacket(msg);
2140
+ });
2141
+ sock.on("error", (err) => {
2142
+ this.emit("error", err);
2143
+ });
2144
+ await new Promise((resolve, reject) => {
2145
+ const onError = (err) => {
2146
+ sock.removeListener("listening", onListening);
2147
+ reject(err);
2148
+ };
2149
+ const onListening = () => {
2150
+ sock.removeListener("error", onError);
2151
+ this.localUdpPort = sock.address().port;
2152
+ resolve();
2153
+ };
2154
+ sock.once("error", onError);
2155
+ sock.once("listening", onListening);
2156
+ sock.bind(this.options.localUdpPort);
2157
+ });
2158
+ return sock;
2159
+ }
2160
+ async performUdpInitHandshake(isCaller) {
2161
+ const fromUid = this.fromUid32 || uidToUint32(this.api.getContext().uid);
2162
+ const toUid = this.toUid32 || uidToUint32(this.partnerId);
2163
+ const initPacket = buildInitCallRequest({
2164
+ isCaller,
2165
+ seqNum: 0,
2166
+ headerCallId: this.callId,
2167
+ callId: this.callId,
2168
+ fromUid,
2169
+ toUid,
2170
+ flags: 1,
2171
+ subFlags: toUid,
2172
+ sessId: this.sessId,
2173
+ });
2174
+ const timeoutMs = this.options.udpHandshakeTimeoutMs;
2175
+ await new Promise((resolve) => {
2176
+ let settled = false;
2177
+ const retryInterval = setInterval(() => {
2178
+ if (!settled && this.udpSocket) {
2179
+ void this.sendRawUdp(initPacket, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
2180
+ }
2181
+ }, 350);
2182
+ const timer = setTimeout(() => {
2183
+ if (settled)
2184
+ return;
2185
+ settled = true;
2186
+ clearInterval(retryInterval);
2187
+ this.removeListener("zrtp_control", onControl);
2188
+ if (!this.routingToken) {
2189
+ this.routingToken = this.callId >>> 0;
2190
+ }
2191
+ resolve();
2192
+ }, timeoutMs);
2193
+ const onControl = (pkt) => {
2194
+ if (pkt.isResponse &&
2195
+ (pkt.cmd === ZRTPControlCommand.InitCallCaller ||
2196
+ pkt.cmd === ZRTPControlCommand.InitCallCallee) &&
2197
+ pkt.errorCode === 0 &&
2198
+ pkt.routingToken) {
2199
+ if (settled)
2200
+ return;
2201
+ settled = true;
2202
+ clearTimeout(timer);
2203
+ clearInterval(retryInterval);
2204
+ this.removeListener("zrtp_control", onControl);
2205
+ this.routingToken = pkt.routingToken >>> 0;
2206
+ this.udpHandshakeVerified = true;
2207
+ resolve();
2208
+ }
2209
+ };
2210
+ this.on("zrtp_control", onControl);
2211
+ void this.sendRawUdp(initPacket, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
2212
+ });
2213
+ }
2214
+ startUdpPingLoop() {
2215
+ if (this.pingTimer)
2216
+ clearInterval(this.pingTimer);
2217
+ this.startedAtMs = Date.now();
2218
+ const sendPing = () => {
2219
+ if (!this.udpSocket || this.rtpEndpoint.port <= 0 || this.state === "ended")
2220
+ return;
2221
+ const elapsed = Math.max(1, Date.now() - this.startedAtMs);
2222
+ const fromUid = this.fromUid32 || uidToUint32(this.api.getContext().uid);
2223
+ const toUid = this.toUid32 || uidToUint32(this.partnerId);
2224
+ const token = (this.routingToken || this.callId) >>> 0;
2225
+ // 1. Gói RelayPing (cmd=2) tới máy chủ Relay kèm sessId
2226
+ if (this.sessId) {
2227
+ const relayPing = buildRelayPingRequest({
2228
+ seqNum: 0,
2229
+ callId: 0,
2230
+ fromUid,
2231
+ routingToken: token,
2232
+ sessId: this.sessId,
2233
+ });
2234
+ void this.sendRawUdp(relayPing, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
2235
+ }
2236
+ // 2. Gói Ping (cmd=5) giữ NAT
2237
+ const pkt = buildPingRequest({
2238
+ seqNum: this.controlSeq++,
2239
+ callId: this.callId,
2240
+ fromUid,
2241
+ toUid,
2242
+ elapsedMs: elapsed,
2243
+ });
2244
+ void this.sendRawUdp(pkt, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
2245
+ // 3. Gói RequestForward Ping (cmd=32, subCmd=11) qua Relay tới máy đối phương
2246
+ const fwdPing = buildRequestForwardPacket({
2247
+ seqNum: 0,
2248
+ seqId: this.forwardPingSeq,
2249
+ fromUid,
2250
+ routingToken: token,
2251
+ subCmd: ZRTPForwardSubCommand.Ping,
2252
+ payload: String(elapsed),
2253
+ });
2254
+ void this.sendRawUdp(fwdPing, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
2255
+ // 4. Gói RequestForward ManualControl / VideoControl (cmd=32, subCmd=10, 20-byte payload)
2256
+ // Cập nhật partnerRenderFps (15fps), partnerAudioRecvPktPerSec (50), partnerVideoRecvPktPerSec (30)
2257
+ // khớp với CallController::_sendPacketManualControl (0x100184664) & isPartnerRenderFpsLow (0x1001895dc)
2258
+ const isVideo = this.callType === CallType.Video ||
2259
+ this.callType === CallType.GroupVideo ||
2260
+ Number(this.extendData?.callType) === 1;
2261
+ const manualCtrlPayload = Buffer.alloc(20);
2262
+ manualCtrlPayload.writeInt32LE(isVideo ? 15 : 0, 0); // renderFps >= 3
2263
+ manualCtrlPayload.writeInt32LE(0, 4); // isReadyForHevcDec
2264
+ manualCtrlPayload.writeInt32LE(0, 8); // deviceOrientation
2265
+ manualCtrlPayload.writeInt32LE(50, 12); // audioRecvPktCountPerSec > 6
2266
+ manualCtrlPayload.writeInt32LE(isVideo ? 30 : 0, 16); // videoRecvPktCountPerSec
2267
+ const manualCtrlPkt = buildRequestForwardPacket({
2268
+ seqNum: 0,
2269
+ seqId: this.manualControlSeq++,
2270
+ fromUid,
2271
+ routingToken: token,
2272
+ subCmd: ZRTPForwardSubCommand.VideoControl,
2273
+ payload: manualCtrlPayload,
2274
+ });
2275
+ void this.sendRawUdp(manualCtrlPkt, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
2276
+ };
2277
+ sendPing();
2278
+ this.pingTimer = setInterval(sendPing, this.options.pingIntervalMs);
2279
+ if (typeof this.pingTimer.unref === "function") {
2280
+ this.pingTimer.unref();
2281
+ }
2282
+ }
2283
+ startGroupPingLoop() {
2284
+ if (this.groupPingTimer) {
2285
+ clearInterval(this.groupPingTimer);
2286
+ this.groupPingTimer = null;
2287
+ }
2288
+ if (!this.groupId || !this.callId)
2289
+ return;
2290
+ this.groupPingTimer = setInterval(() => {
2291
+ if (this.state === "ended" || !this.groupId || !this.callId)
2292
+ return;
2293
+ void this.api.call
2294
+ .pingGroupCall(this.callId, {
2295
+ hostCall: this.groupId,
2296
+ callState: 1,
2297
+ audioState: this.isMicMuted ? 0 : 1,
2298
+ videoState: this.isCameraOn ? 1 : 0,
2299
+ })
2300
+ .catch(() => { });
2301
+ }, 10000);
2302
+ if (typeof this.groupPingTimer.unref === "function") {
2303
+ this.groupPingTimer.unref();
2304
+ }
2305
+ }
2306
+ startMediaKeepaliveLoop() {
2307
+ this.stopMediaKeepaliveLoop();
2308
+ if (!this.options.autoKeepaliveMedia)
2309
+ return;
2310
+ // 1. Luồng Audio RTP 20ms (50 gói/giây) thoả mãn audio_recheck_sock (checkPkgRec >= 14 / 2s)
2311
+ this.audioKeepaliveTimer = setInterval(() => {
2312
+ if (!this.udpSocket || this.rtpEndpoint.port <= 0 || this.state === "ended")
2313
+ return;
2314
+ if (Date.now() - this.lastExternalAudioSentAtMs >= 18) {
2315
+ void this.sendAudioFrame(OPUS_SILENCE_20MS_FRAME, { isKeepalive: true }).catch(() => { });
2316
+ }
2317
+ }, 20);
2318
+ if (typeof this.audioKeepaliveTimer.unref === "function") {
2319
+ this.audioKeepaliveTimer.unref();
2320
+ }
2321
+ // 2. Nếu là cuộc gọi Video: chỉ gửi H264_KEEPALIVE_ANNEXB_FRAME khi KHÔNG có camera ngoài gửi frame trong >= 1000ms!
2322
+ // Tuyệt đối không chèn khung 16x16 vào giữa luồng camera 480x640 đang chạy (tránh làm hỏng SPS/PPS trên VideoToolbox iOS).
2323
+ const isVideo = this.callType === CallType.Video ||
2324
+ this.callType === CallType.GroupVideo ||
2325
+ Number(this.extendData?.callType) === 1;
2326
+ if (isVideo) {
2327
+ this.videoKeepaliveTimer = setInterval(() => {
2328
+ if (!this.udpSocket || this.rtpEndpoint.port <= 0 || this.state === "ended")
2329
+ return;
2330
+ if (!this.isCameraOn)
2331
+ return;
2332
+ if (Date.now() - this.lastExternalVideoSentAtMs >= 1000) {
2333
+ void this.sendH264AnnexBFrame(H264_KEEPALIVE_ANNEXB_FRAME, {
2334
+ timestampStep: 6000,
2335
+ isKeepalive: true,
2336
+ }).catch(() => { });
2337
+ }
2338
+ }, 66);
2339
+ if (typeof this.videoKeepaliveTimer.unref === "function") {
2340
+ this.videoKeepaliveTimer.unref();
2341
+ }
2342
+ }
2343
+ // 3. Báo cáo RTCP Sender Report + Receiver Report + SDES mỗi 500ms (thoả mãn durationCheckNoReportSec: 4)
2344
+ const sendPeriodicRtcp = () => {
2345
+ if (!this.udpSocket || this.rtpEndpoint.port <= 0 || this.state === "ended")
2346
+ return;
2347
+ const audioRtcp = this.buildCompoundRtcpReport(false);
2348
+ void this.sendRtcp(audioRtcp, false).catch(() => { });
2349
+ const activeVideo = this.callType === CallType.Video ||
2350
+ this.callType === CallType.GroupVideo ||
2351
+ Number(this.extendData?.callType) === 1;
2352
+ if (activeVideo) {
2353
+ const videoRtcp = this.buildCompoundRtcpReport(true);
2354
+ void this.sendRtcp(videoRtcp, true).catch(() => { });
2355
+ if (this.remoteVideoSsrc !== 0 && !this.remoteSpsNal) {
2356
+ void this.sendRtcpPli().catch(() => { });
2357
+ }
2358
+ }
2359
+ };
2360
+ sendPeriodicRtcp();
2361
+ this.rtcpTimer = setInterval(sendPeriodicRtcp, 500);
2362
+ if (typeof this.rtcpTimer.unref === "function") {
2363
+ this.rtcpTimer.unref();
2364
+ }
2365
+ }
2366
+ stopMediaKeepaliveLoop() {
2367
+ if (this.audioKeepaliveTimer) {
2368
+ clearInterval(this.audioKeepaliveTimer);
2369
+ this.audioKeepaliveTimer = null;
2370
+ }
2371
+ if (this.videoKeepaliveTimer) {
2372
+ clearInterval(this.videoKeepaliveTimer);
2373
+ this.videoKeepaliveTimer = null;
2374
+ }
2375
+ if (this.rtcpTimer) {
2376
+ clearInterval(this.rtcpTimer);
2377
+ this.rtcpTimer = null;
2378
+ }
2379
+ }
2380
+ buildCompoundRtcpReport(isVideo) {
2381
+ const nowMs = Date.now();
2382
+ const ntpSec = (Math.floor(nowMs / 1000) + 2208988800) >>> 0;
2383
+ const ntpFrac = Math.floor(((nowMs % 1000) / 1000) * 0x100000000) >>> 0;
2384
+ const localSsrc = isVideo ? this.getLocalVideoSsrc() : this.getLocalAudioSsrc();
2385
+ const rtpTs = isVideo ? this.videoTimestamp : this.audioTimestamp;
2386
+ const pktCount = isVideo ? this.videoPacketCount : this.audioPacketCount;
2387
+ const octCount = isVideo ? this.videoOctetCount : this.audioOctetCount;
2388
+ const remoteSsrc = isVideo ? this.remoteVideoSsrc : this.remoteAudioSsrc;
2389
+ const remoteMaxSeq = isVideo ? this.remoteVideoMaxSeq : this.remoteAudioMaxSeq;
2390
+ const remoteCycles = isVideo ? this.remoteVideoSeqCycles : this.remoteAudioSeqCycles;
2391
+ const lastSrMiddleNtp = isVideo
2392
+ ? this.lastRemoteVideoSrMiddleNtp
2393
+ : this.lastRemoteAudioSrMiddleNtp;
2394
+ const lastSrAtMs = isVideo
2395
+ ? this.lastRemoteVideoSrReceivedAtMs
2396
+ : this.lastRemoteAudioSrReceivedAtMs;
2397
+ const hasReportBlock = remoteSsrc !== 0;
2398
+ const srLenBytes = hasReportBlock ? 52 : 28;
2399
+ const sdesLenBytes = 16;
2400
+ const rembLenBytes = isVideo && hasReportBlock ? 24 : 0;
2401
+ const buf = Buffer.alloc(srLenBytes + sdesLenBytes + rembLenBytes);
2402
+ // RTCP Sender Report (PT = 200)
2403
+ buf.writeUInt8(hasReportBlock ? 0x81 : 0x80, 0);
2404
+ buf.writeUInt8(200, 1);
2405
+ buf.writeUInt16BE(srLenBytes / 4 - 1, 2);
2406
+ buf.writeUInt32BE(localSsrc >>> 0, 4);
2407
+ buf.writeUInt32BE(ntpSec, 8);
2408
+ buf.writeUInt32BE(ntpFrac, 12);
2409
+ buf.writeUInt32BE(rtpTs >>> 0, 16);
2410
+ buf.writeUInt32BE(pktCount >>> 0, 20);
2411
+ buf.writeUInt32BE(octCount >>> 0, 24);
2412
+ if (hasReportBlock) {
2413
+ const extMaxSeq = (((remoteCycles & 0xffff) << 16) | (remoteMaxSeq & 0xffff)) >>> 0;
2414
+ const nowMiddleNtp = (((ntpSec & 0xffff) << 16) | ((ntpFrac >>> 16) & 0xffff)) >>> 0;
2415
+ const lsr = lastSrMiddleNtp !== 0
2416
+ ? lastSrMiddleNtp >>> 0
2417
+ : ((nowMiddleNtp - 1638) >>> 0); // Fallback 25ms ago
2418
+ const dlsr = lastSrAtMs > 0 && lastSrMiddleNtp !== 0
2419
+ ? (Math.max(65, Math.floor(((nowMs - lastSrAtMs) / 1000) * 65536)) >>> 0)
2420
+ : 1638; // 25ms in 1/65536s units so RTT is ~25ms (1 <= rtt < 9999)
2421
+ buf.writeUInt32BE(remoteSsrc >>> 0, 28);
2422
+ buf.writeUInt32BE(0, 32); // fractionLost = 0, cumulativeLost = 0
2423
+ buf.writeUInt32BE(extMaxSeq, 36);
2424
+ buf.writeUInt32BE(10, 40); // interarrival jitter
2425
+ buf.writeUInt32BE(lsr, 44);
2426
+ buf.writeUInt32BE(dlsr, 48);
2427
+ }
2428
+ // RTCP SDES (PT = 202)
2429
+ const sdesOffset = srLenBytes;
2430
+ buf.writeUInt8(0x81, sdesOffset);
2431
+ buf.writeUInt8(202, sdesOffset + 1);
2432
+ buf.writeUInt16BE(3, sdesOffset + 2);
2433
+ buf.writeUInt32BE(localSsrc >>> 0, sdesOffset + 4);
2434
+ buf.writeUInt8(1, sdesOffset + 8); // CNAME
2435
+ buf.writeUInt8(5, sdesOffset + 9);
2436
+ buf.write("zcajs", sdesOffset + 10, "ascii");
2437
+ buf.writeUInt8(0, sdesOffset + 15);
2438
+ // RTCP REMB (PT = 206, FMT = 15, 'R' 'E' 'M' 'B', ~1.46 Mbps) cho Video BWE
2439
+ if (rembLenBytes > 0) {
2440
+ const rembOffset = srLenBytes + sdesLenBytes;
2441
+ buf.writeUInt8(0x8f, rembOffset); // V=2, P=0, FMT=15
2442
+ buf.writeUInt8(206, rembOffset + 1); // PT=206 (PSFB)
2443
+ buf.writeUInt16BE(5, rembOffset + 2); // 5 words (24 bytes)
2444
+ buf.writeUInt32BE(localSsrc >>> 0, rembOffset + 4);
2445
+ buf.writeUInt32BE(0, rembOffset + 8);
2446
+ buf.write("REMB", rembOffset + 12, "ascii");
2447
+ buf.writeUInt8(1, rembOffset + 16); // numSsrcs = 1
2448
+ // brExp = 3, brMantissa = 183105 (0x2cb41) => 183105 << 3 = 1,464,840 bps
2449
+ buf.writeUInt8(0x0e, rembOffset + 17);
2450
+ buf.writeUInt8(0xcb, rembOffset + 18);
2451
+ buf.writeUInt8(0x41, rembOffset + 19);
2452
+ buf.writeUInt32BE(remoteSsrc >>> 0, rembOffset + 20);
2453
+ }
2454
+ return buf;
2455
+ }
2456
+ markCallActiveFromPartnerTraffic() {
2457
+ this.hasReceivedPartnerMedia = true;
2458
+ if (this.state === "ringing" || this.state === "handshaking_udp") {
2459
+ this.setState("active");
2460
+ if (this.isCaller &&
2461
+ this.partnerId &&
2462
+ this.callId > 0 &&
2463
+ !this.groupId &&
2464
+ !this.hasSentAnswerAck) {
2465
+ this.hasSentAnswerAck = true;
2466
+ void this.api.call.answerAckVoiceCall(this.partnerId, this.callId).catch(() => { });
2467
+ }
2468
+ if (this.callType === CallType.Video ||
2469
+ this.callType === CallType.GroupVideo ||
2470
+ Number(this.extendData?.callType) === 1) {
2471
+ void this.sendCameraState(this.isCameraOn).catch(() => { });
2472
+ if (!this.isCameraOn && this.partnerId && this.callId > 0 && !this.groupId) {
2473
+ void this.api.call
2474
+ .updateCallMediaState(this.partnerId, this.callId, {
2475
+ isOnCam: 0,
2476
+ isOnMic: -1,
2477
+ data: "",
2478
+ })
2479
+ .catch(() => { });
2480
+ }
2481
+ void this.sendRtcpPli().catch(() => { });
2482
+ }
2483
+ const params = this.getConnectionParams();
2484
+ this.emit("connection_params", params);
2485
+ this.emit("active", params);
2486
+ }
2487
+ }
2488
+ handleIncomingUdpPacket(msg) {
2489
+ const parsed = parseZRTPPacket(msg);
2490
+ this.emit("zrtp_packet", parsed);
2491
+ if (parsed.family === "control") {
2492
+ if (parsed.isResponse &&
2493
+ (parsed.cmd === ZRTPControlCommand.InitCallCaller ||
2494
+ parsed.cmd === ZRTPControlCommand.InitCallCallee) &&
2495
+ parsed.routingToken) {
2496
+ this.routingToken = parsed.routingToken >>> 0;
2497
+ }
2498
+ else if (parsed.cmd === ZRTPControlCommand.EndCall &&
2499
+ (this.state === "ringing" || this.state === "active")) {
2500
+ this.emit("zrtp_control", parsed);
2501
+ void this.endCall("remote_end");
2502
+ return;
2503
+ }
2504
+ else if (!parsed.isResponse &&
2505
+ parsed.cmd === ZRTPControlCommand.Ping &&
2506
+ this.udpSocket &&
2507
+ this.rtpEndpoint.port > 0) {
2508
+ const resp = buildPingResponse({
2509
+ seqNum: parsed.seqNum,
2510
+ callId: parsed.headerCallId || this.callId,
2511
+ fromUid: parsed.toUid,
2512
+ toUid: parsed.fromUid,
2513
+ elapsedMs: parsed.pingString ?? "0",
2514
+ });
2515
+ void this.sendRawUdp(resp, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
2516
+ }
2517
+ else if (!parsed.isResponse &&
2518
+ parsed.cmd === ZRTPControlCommand.RequestForward &&
2519
+ this.udpSocket &&
2520
+ this.rtpEndpoint.port > 0) {
2521
+ this.markCallActiveFromPartnerTraffic();
2522
+ if (parsed.flags === ZRTPForwardSubCommand.Ping) {
2523
+ const pongPkt = buildRequestForwardPacket({
2524
+ seqNum: 0,
2525
+ seqId: parsed.headerCallId,
2526
+ fromUid: this.fromUid32 || uidToUint32(this.api.getContext().uid),
2527
+ routingToken: (this.routingToken || this.callId) >>> 0,
2528
+ subCmd: ZRTPForwardSubCommand.Pong,
2529
+ payload: parsed.forwardPayload ?? Buffer.alloc(0),
2530
+ });
2531
+ void this.sendRawUdp(pongPkt, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
2532
+ }
2533
+ else if (parsed.flags === ZRTPForwardSubCommand.Pong) {
2534
+ if (parsed.headerCallId === this.forwardPingSeq) {
2535
+ this.forwardPingSeq = (this.forwardPingSeq + 1) >>> 0;
2536
+ }
2537
+ }
2538
+ else if (parsed.flags === ZRTPForwardSubCommand.OnCamera ||
2539
+ parsed.flags === ZRTPForwardSubCommand.OffCamera ||
2540
+ parsed.flags === ZRTPForwardSubCommand.OffCameraCallKit ||
2541
+ parsed.flags === ZRTPForwardSubCommand.PartnerSuspendVideo) {
2542
+ const isCamOn = parsed.flags === ZRTPForwardSubCommand.OnCamera;
2543
+ this.clearIncomingVideoAssemblyBuffers();
2544
+ if (isCamOn) {
2545
+ if (this.callType === CallType.Audio) {
2546
+ this.callType = CallType.Video;
2547
+ this.startMediaKeepaliveLoop();
2548
+ }
2549
+ else if (this.callType === CallType.GroupAudio) {
2550
+ this.callType = CallType.GroupVideo;
2551
+ this.startMediaKeepaliveLoop();
2552
+ }
2553
+ void this.sendRtcpPli().catch(() => { });
2554
+ const t = setTimeout(() => {
2555
+ if (this.state === "active") {
2556
+ void this.sendRtcpPli().catch(() => { });
2557
+ }
2558
+ }, 250);
2559
+ if (typeof t.unref === "function")
2560
+ t.unref();
2561
+ }
2562
+ this.emit("media_state_change", {
2563
+ isLocal: false,
2564
+ isCameraOn: isCamOn,
2565
+ callType: this.callType,
2566
+ rawAct: isCamOn
2567
+ ? "udp_on_camera"
2568
+ : parsed.flags === ZRTPForwardSubCommand.OffCameraCallKit
2569
+ ? "udp_off_camera_callkit"
2570
+ : parsed.flags === ZRTPForwardSubCommand.PartnerSuspendVideo
2571
+ ? "udp_partner_suspend_video"
2572
+ : "udp_off_camera",
2573
+ });
2574
+ }
2575
+ else if (parsed.flags === ZRTPForwardSubCommand.OnMic ||
2576
+ parsed.flags === ZRTPForwardSubCommand.OffMic) {
2577
+ this.emit("media_state_change", {
2578
+ isLocal: false,
2579
+ isMicMuted: parsed.flags === ZRTPForwardSubCommand.OffMic,
2580
+ rawAct: parsed.flags === ZRTPForwardSubCommand.OnMic
2581
+ ? "udp_on_mic"
2582
+ : "udp_off_mic",
2583
+ });
2584
+ }
2585
+ }
2586
+ this.emit("zrtp_control", parsed);
2587
+ return;
2588
+ }
2589
+ if (parsed.family === "legacy_media" || parsed.family === "zrtc_media") {
2590
+ this.markCallActiveFromPartnerTraffic();
2591
+ try {
2592
+ if (parsed.isRtcp) {
2593
+ let rawRtcp = parsed.rtpPayload;
2594
+ if (this.srtpSession && Number(this.extendData?.srtcp) === 1) {
2595
+ try {
2596
+ rawRtcp = this.srtpSession.unprotectRtcp(parsed.rtpPayload);
2597
+ }
2598
+ catch {
2599
+ rawRtcp = parsed.rtpPayload;
2600
+ }
2601
+ }
2602
+ // Quét toàn bộ các sub-packet trong gói Compound RTCP để tìm Sender Report (PT=200)
2603
+ let rtcpOffset = 0;
2604
+ while (rtcpOffset + 8 <= rawRtcp.length) {
2605
+ const pt = rawRtcp.readUInt8(rtcpOffset + 1);
2606
+ const lenWords = rawRtcp.readUInt16BE(rtcpOffset + 2);
2607
+ const pktBytes = (lenWords + 1) * 4;
2608
+ if (pt === 200 && rtcpOffset + 28 <= rawRtcp.length) {
2609
+ const srSsrc = rawRtcp.readUInt32BE(rtcpOffset + 4);
2610
+ const middleNtp = rawRtcp.readUInt32BE(rtcpOffset + 10);
2611
+ if (parsed.isVideo) {
2612
+ if (!this.remoteVideoSsrc)
2613
+ this.remoteVideoSsrc = srSsrc;
2614
+ this.lastRemoteVideoSrMiddleNtp = middleNtp;
2615
+ this.lastRemoteVideoSrReceivedAtMs = Date.now();
2616
+ }
2617
+ else {
2618
+ if (!this.remoteAudioSsrc)
2619
+ this.remoteAudioSsrc = srSsrc;
2620
+ this.lastRemoteAudioSrMiddleNtp = middleNtp;
2621
+ this.lastRemoteAudioSrReceivedAtMs = Date.now();
2622
+ }
2623
+ }
2624
+ if (pktBytes <= 0)
2625
+ break;
2626
+ rtcpOffset += pktBytes;
2627
+ }
2628
+ this.emit("rtcp_packet", {
2629
+ isVideo: parsed.isVideo,
2630
+ rtcpPacket: rawRtcp,
2631
+ zrtpPacket: parsed,
2632
+ });
2633
+ }
2634
+ else {
2635
+ let rawRtp = parsed.rtpPayload;
2636
+ if (this.srtpSession) {
2637
+ try {
2638
+ rawRtp = this.srtpSession.unprotectRtp(parsed.rtpPayload);
2639
+ }
2640
+ catch {
2641
+ rawRtp = parsed.rtpPayload;
2642
+ }
2643
+ }
2644
+ const parsedRtp = parseRtpPacket(rawRtp);
2645
+ if (parsed.isVideo) {
2646
+ if (isZaloVideoPayloadType(parsedRtp.payloadType)) {
2647
+ this.remoteVideoSsrc = parsedRtp.ssrc;
2648
+ if (parsedRtp.sequenceNumber < this.remoteVideoMaxSeq && this.remoteVideoMaxSeq - parsedRtp.sequenceNumber > 30000) {
2649
+ this.remoteVideoSeqCycles++;
2650
+ }
2651
+ this.remoteVideoMaxSeq = parsedRtp.sequenceNumber;
2652
+ }
2653
+ }
2654
+ else {
2655
+ this.remoteAudioSsrc = parsedRtp.ssrc;
2656
+ if (parsedRtp.sequenceNumber < this.remoteAudioMaxSeq && this.remoteAudioMaxSeq - parsedRtp.sequenceNumber > 30000) {
2657
+ this.remoteAudioSeqCycles++;
2658
+ }
2659
+ this.remoteAudioMaxSeq = parsedRtp.sequenceNumber;
2660
+ }
2661
+ this.emit("rtp_packet", {
2662
+ isVideo: parsed.isVideo,
2663
+ isFec: parsed.isFec,
2664
+ rtpPacket: rawRtp,
2665
+ parsedRtp,
2666
+ zrtpPacket: parsed,
2667
+ });
2668
+ if (!parsed.isVideo && !parsed.isFec) {
2669
+ this.emit("audio_frame", {
2670
+ payload: parsedRtp.payload,
2671
+ sequenceNumber: parsedRtp.sequenceNumber,
2672
+ timestamp: parsedRtp.timestamp,
2673
+ ssrc: parsedRtp.ssrc,
2674
+ payloadType: parsedRtp.payloadType,
2675
+ marker: parsedRtp.marker,
2676
+ });
2677
+ }
2678
+ else if (parsed.isVideo &&
2679
+ !parsed.isFec &&
2680
+ isZaloVideoPayloadType(parsedRtp.payloadType)) {
2681
+ this.emit("video_frame", {
2682
+ payload: parsedRtp.payload,
2683
+ sequenceNumber: parsedRtp.sequenceNumber,
2684
+ timestamp: parsedRtp.timestamp,
2685
+ ssrc: parsedRtp.ssrc,
2686
+ payloadType: parsedRtp.payloadType,
2687
+ marker: parsedRtp.marker,
2688
+ });
2689
+ this.handleIncomingVideoRtpPayload(parsedRtp);
2690
+ }
2691
+ }
2692
+ }
2693
+ catch (err) {
2694
+ if (this.listenerCount("error") > 0) {
2695
+ this.emit("error", err);
2696
+ }
2697
+ }
2698
+ }
2699
+ }
2700
+ handleIncomingVideoRtpPayload(parsedRtp) {
2701
+ const payload = parsedRtp.payload;
2702
+ // Bỏ qua ngay các gói RTP padding rỗng (P=1) để không làm flush nhầm frame đang ghép
2703
+ if (!payload || payload.length < 1)
2704
+ return;
2705
+ if (this.currentAccessUnitTs !== -1 && parsedRtp.timestamp !== this.currentAccessUnitTs) {
2706
+ this.flushIncomingAccessUnit(this.currentAccessUnitTs, parsedRtp.ssrc);
2707
+ }
2708
+ this.currentAccessUnitTs = parsedRtp.timestamp;
2709
+ if (isZaloKeyframePayloadType(parsedRtp.payloadType)) {
2710
+ this.currentAccessUnitHasIdr = true;
2711
+ }
2712
+ // Trường hợp 1: Payload đã bắt đầu trực tiếp bằng Annex-B start code (00 00 00 01 hoặc 00 00 01)
2713
+ if (startsWithAnnexBStartCode(payload)) {
2714
+ for (const nal of splitAnnexBNals(payload)) {
2715
+ this.recordIncomingNal(nal);
2716
+ }
2717
+ if (parsedRtp.marker) {
2718
+ this.flushIncomingAccessUnit(parsedRtp.timestamp, parsedRtp.ssrc);
2719
+ }
2720
+ return;
2721
+ }
2722
+ const nalHeader = payload[0];
2723
+ const nalType = nalHeader & 0x1f;
2724
+ if (nalType === 0) {
2725
+ // Khi g_nalHeaderSize == 0 trên iOS, NextAggregatePacket (0x1003db3a4) ghi trực tiếp
2726
+ // [uint16BE len1][00 00 00 01 SPS][uint16BE len2][00 00 00 01 PPS] tại offset 0
2727
+ const agg = tryParseAggregatedNals(payload, 0);
2728
+ if (agg) {
2729
+ for (const chunk of agg) {
2730
+ for (const nal of splitAnnexBNals(chunk)) {
2731
+ this.recordIncomingNal(nal);
2732
+ }
2733
+ }
2734
+ }
2735
+ else {
2736
+ for (const nal of splitAnnexBNals(payload)) {
2737
+ this.recordIncomingNal(nal);
2738
+ }
2739
+ }
2740
+ }
2741
+ else if (nalType >= 1 && nalType <= 23) {
2742
+ for (const nal of splitAnnexBNals(payload)) {
2743
+ this.recordIncomingNal(nal);
2744
+ }
2745
+ }
2746
+ else if (nalType === 24) {
2747
+ // RFC 6184 STAP-A (Single-Time Aggregation Packet)
2748
+ const agg = tryParseAggregatedNals(payload, 1);
2749
+ if (agg) {
2750
+ for (const chunk of agg) {
2751
+ for (const nal of splitAnnexBNals(chunk)) {
2752
+ this.recordIncomingNal(nal);
2753
+ }
2754
+ }
2755
+ }
2756
+ else {
2757
+ let offset = 1;
2758
+ while (offset + 2 <= payload.length) {
2759
+ const nalSize = payload.readUInt16BE(offset);
2760
+ offset += 2;
2761
+ if (nalSize > 0 && offset + nalSize <= payload.length) {
2762
+ for (const nal of splitAnnexBNals(payload.subarray(offset, offset + nalSize))) {
2763
+ this.recordIncomingNal(nal);
2764
+ }
2765
+ }
2766
+ offset += nalSize;
2767
+ }
2768
+ }
2769
+ }
2770
+ else if ((nalType === 28 || nalType === 29) && payload.length >= 2) {
2771
+ // RFC 6184 FU-A (28) / FU-B (29) + Zalo ZRTP FU-A (origNalType = 0 với Annex-B bên trong)
2772
+ const fuIndicator = payload[0];
2773
+ const fuHeader = payload[1];
2774
+ const isStart = (fuHeader & 0x80) !== 0;
2775
+ const isEnd = (fuHeader & 0x40) !== 0;
2776
+ const origNalType = fuHeader & 0x1f;
2777
+ const reconstructedNalHeader = (fuIndicator & 0xe0) | origNalType;
2778
+ const headerSize = nalType === 29 ? 4 : 2;
2779
+ if (payload.length > headerSize) {
2780
+ const body = payload.subarray(headerSize);
2781
+ if (isStart) {
2782
+ // Nếu origNalType === 0 hoặc body đã bắt đầu bằng Annex-B start code (00 00 00 01 / 00 00 01),
2783
+ // TUYỆT ĐỐI KHÔNG chèn thêm byte 0x00 vào đầu để không biến 00 00 00 01 thành 5 byte!
2784
+ if (origNalType === 0 || startsWithAnnexBStartCode(body)) {
2785
+ this.fuaFragments = [body];
2786
+ }
2787
+ else {
2788
+ this.fuaFragments = [Buffer.from([reconstructedNalHeader]), body];
2789
+ }
2790
+ }
2791
+ else if (this.fuaFragments.length > 0) {
2792
+ if (this.fuaFragments.length >= 512) {
2793
+ this.fuaFragments = [];
2794
+ }
2795
+ else {
2796
+ this.fuaFragments.push(body);
2797
+ }
2798
+ }
2799
+ if (isEnd && this.fuaFragments.length > 0) {
2800
+ const fullNal = Buffer.concat(this.fuaFragments);
2801
+ this.fuaFragments = [];
2802
+ for (const nal of splitAnnexBNals(fullNal)) {
2803
+ this.recordIncomingNal(nal);
2804
+ }
2805
+ }
2806
+ }
2807
+ }
2808
+ if (parsedRtp.marker) {
2809
+ this.flushIncomingAccessUnit(parsedRtp.timestamp, parsedRtp.ssrc);
2810
+ }
2811
+ }
2812
+ recordIncomingNal(rawNal) {
2813
+ if (!rawNal || rawNal.length === 0)
2814
+ return;
2815
+ const nal = stripLeadingAnnexBStartCode(rawNal);
2816
+ if (!nal || nal.length === 0)
2817
+ return;
2818
+ const nalType = nal[0] & 0x1f;
2819
+ // Bỏ qua NAL rỗng (type=0) hoặc Access Unit Delimiter (AUD, type=9)
2820
+ if (nalType === 0 || nalType === 9)
2821
+ return;
2822
+ if (nalType === 7) {
2823
+ this.remoteSpsNal = Buffer.from(nal);
2824
+ }
2825
+ else if (nalType === 8) {
2826
+ this.remotePpsNal = Buffer.from(nal);
2827
+ }
2828
+ else if (nalType === 5) {
2829
+ this.currentAccessUnitHasIdr = true;
2830
+ }
2831
+ if (this.currentAccessUnitNals.length >= 256) {
2832
+ this.currentAccessUnitNals = [];
2833
+ }
2834
+ this.currentAccessUnitNals.push(Buffer.from(nal));
2835
+ }
2836
+ flushIncomingAccessUnit(timestamp, ssrc) {
2837
+ if (this.currentAccessUnitNals.length === 0)
2838
+ return;
2839
+ const startCode = Buffer.from([0x00, 0x00, 0x00, 0x01]);
2840
+ const chunks = [];
2841
+ const nalTypes = this.currentAccessUnitNals.map((n) => n[0] & 0x1f);
2842
+ const hasInlineSps = nalTypes.includes(7);
2843
+ const hasInlinePps = nalTypes.includes(8);
2844
+ const hasVclSlice = nalTypes.some((t) => t === 1 || t === 5);
2845
+ // Nếu chỉ mới nhận SPS/PPS mà chưa có slice hình ảnh (IDR/P-slice), giữ lại cho gói tiếp theo
2846
+ if (!hasVclSlice) {
2847
+ return;
2848
+ }
2849
+ if (this.currentAccessUnitHasIdr) {
2850
+ if (!hasInlineSps && this.remoteSpsNal) {
2851
+ chunks.push(startCode, this.remoteSpsNal);
2852
+ }
2853
+ if (!hasInlinePps && this.remotePpsNal) {
2854
+ chunks.push(startCode, this.remotePpsNal);
2855
+ }
2856
+ }
2857
+ for (const nal of this.currentAccessUnitNals) {
2858
+ chunks.push(startCode, nal);
2859
+ }
2860
+ const annexB = Buffer.concat(chunks);
2861
+ const isKeyframe = this.currentAccessUnitHasIdr;
2862
+ this.currentAccessUnitNals = [];
2863
+ this.currentAccessUnitHasIdr = false;
2864
+ this.currentAccessUnitTs = -1;
2865
+ this.emit("video_annexb_frame", {
2866
+ annexB,
2867
+ isKeyframe,
2868
+ timestamp,
2869
+ ssrc,
2870
+ sps: this.remoteSpsNal ?? undefined,
2871
+ pps: this.remotePpsNal ?? undefined,
2872
+ nalTypes,
2873
+ });
2874
+ }
2875
+ attachCallEventListener() {
2876
+ if (this.boundCallEventListener)
2877
+ return;
2878
+ this.boundCallEventListener = (event) => {
2879
+ void this.handleCallEvent(event);
2880
+ };
2881
+ try {
2882
+ this.api.listener.on("call_event", this.boundCallEventListener);
2883
+ }
2884
+ catch {
2885
+ // Listener might not be initialized if cookie/userAgent is missing in unit test mocks
2886
+ }
2887
+ }
2888
+ detachCallEventListener() {
2889
+ if (!this.boundCallEventListener)
2890
+ return;
2891
+ try {
2892
+ this.api.listener.removeListener("call_event", this.boundCallEventListener);
2893
+ }
2894
+ catch {
2895
+ // Ignore
2896
+ }
2897
+ this.boundCallEventListener = undefined;
2898
+ }
2899
+ cleanupResources() {
2900
+ if (this.ringingTimeoutTimer) {
2901
+ clearTimeout(this.ringingTimeoutTimer);
2902
+ this.ringingTimeoutTimer = null;
2903
+ }
2904
+ if (this.pingTimer) {
2905
+ clearInterval(this.pingTimer);
2906
+ this.pingTimer = null;
2907
+ }
2908
+ if (this.groupPingTimer) {
2909
+ clearInterval(this.groupPingTimer);
2910
+ this.groupPingTimer = null;
2911
+ }
2912
+ this.stopMediaKeepaliveLoop();
2913
+ this.detachCallEventListener();
2914
+ if (this.udpSocket) {
2915
+ try {
2916
+ this.udpSocket.removeAllListeners();
2917
+ this.udpSocket.close();
2918
+ }
2919
+ catch {
2920
+ // Ignore
2921
+ }
2922
+ this.udpSocket = null;
2923
+ }
2924
+ }
2925
+ sendRawUdp(buf, host, port) {
2926
+ return new Promise((resolve, reject) => {
2927
+ if (!this.udpSocket) {
2928
+ reject(new ZaloApiError("UDP socket is not open"));
2929
+ return;
2930
+ }
2931
+ this.udpSocket.send(buf, port, host, (err) => {
2932
+ if (err)
2933
+ reject(err);
2934
+ else
2935
+ resolve();
2936
+ });
2937
+ });
2938
+ }
2939
+ }