Calls: Upgrade library (#6916)
This commit is contained in:
parent
f2b3eed845
commit
4586064410
@ -32,7 +32,6 @@ export default defineConfig(
|
||||
'src/lib/gramjs/tl/',
|
||||
'src/lib/lovely-chart/**',
|
||||
'src/lib/music-metadata-browser',
|
||||
'src/lib/secret-sauce/',
|
||||
'src/lib/fastBlur.js',
|
||||
'src/types/language.d.ts',
|
||||
'dist/',
|
||||
|
||||
@ -6,11 +6,30 @@ import type {
|
||||
GroupCallParticipant,
|
||||
GroupCallParticipantVideo,
|
||||
SsrcGroup,
|
||||
} from '../../../lib/secret-sauce';
|
||||
} from '../../../lib/vibecalls';
|
||||
import type { ApiGroupCall, ApiPhoneCall } from '../../types';
|
||||
|
||||
import { CALL_PROTOCOL_LIBRARY_VERSIONS } from '../../../config';
|
||||
import { sanitizePrimitiveRecord } from '../../../util/primitives/primitiveRecord';
|
||||
import { getApiChatIdFromMtpPeer, isMtpPeerUser } from './peers';
|
||||
|
||||
function parseCallParameters(data?: GramJs.TypeDataJSON) {
|
||||
if (!data?.data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data.data);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return sanitizePrimitiveRecord(parsed as Record<string, unknown>);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildApiGroupCallParticipant(participant: GramJs.GroupCallParticipant): GroupCallParticipant {
|
||||
const {
|
||||
self, min, about, date, versioned, canSelfUnmute, justJoined, left, muted, mutedByYou, source, volume,
|
||||
@ -134,7 +153,7 @@ export function buildPhoneCall(call: GramJs.TypePhoneCall): ApiPhoneCall {
|
||||
|
||||
if (call instanceof GramJs.PhoneCall) {
|
||||
const {
|
||||
p2pAllowed, gAOrB, keyFingerprint, connections, startDate,
|
||||
p2pAllowed, gAOrB, keyFingerprint, connections, startDate, customParameters,
|
||||
} = call;
|
||||
|
||||
phoneCall = {
|
||||
@ -145,6 +164,7 @@ export function buildPhoneCall(call: GramJs.TypePhoneCall): ApiPhoneCall {
|
||||
startDate,
|
||||
isP2pAllowed: Boolean(p2pAllowed),
|
||||
connections: connections.map(buildApiCallConnection).filter(Boolean),
|
||||
customParameters: parseCallParameters(customParameters),
|
||||
};
|
||||
}
|
||||
|
||||
@ -234,8 +254,9 @@ export function buildApiCallProtocol(protocol: GramJs.PhoneCallProtocol): ApiCal
|
||||
|
||||
export function buildCallProtocol() {
|
||||
return new GramJs.PhoneCallProtocol({
|
||||
libraryVersions: ['4.0.0'],
|
||||
minLayer: 92,
|
||||
libraryVersions: CALL_PROTOCOL_LIBRARY_VERSIONS,
|
||||
// Hardcoded values according to the docs
|
||||
minLayer: 65,
|
||||
maxLayer: 92,
|
||||
udpReflector: true,
|
||||
udpP2p: true,
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Api as GramJs } from '../../../lib/gramjs';
|
||||
import { generateRandomInt32 } from '../../../lib/gramjs/Helpers';
|
||||
|
||||
import type { JoinGroupCallPayload } from '../../../lib/secret-sauce';
|
||||
import type { JoinGroupCallPayload } from '../../../lib/vibecalls';
|
||||
import type {
|
||||
ApiChat, ApiGroupCall, ApiPhoneCall, ApiUser,
|
||||
ApiChat, ApiGroupCall, ApiPeer, ApiPhoneCall, ApiUser,
|
||||
} from '../../types';
|
||||
|
||||
import { GROUP_CALL_PARTICIPANTS_LIMIT } from '../../../limits';
|
||||
@ -18,6 +18,14 @@ import {
|
||||
import { sendApiUpdate } from '../updates/apiUpdateEmitter';
|
||||
import { invokeRequest, invokeRequestBeacon } from './client';
|
||||
|
||||
const MAX_SIGNED_INT64 = (1n << 63n) - 1n;
|
||||
const UINT64_MOD = 1n << 64n;
|
||||
|
||||
function buildSignedLong(value: string) {
|
||||
const parsed = BigInt(value);
|
||||
return parsed > MAX_SIGNED_INT64 ? parsed - UINT64_MOD : parsed;
|
||||
}
|
||||
|
||||
export async function getGroupCall({
|
||||
call,
|
||||
}: {
|
||||
@ -127,13 +135,13 @@ export async function fetchGroupCallParticipants({
|
||||
}
|
||||
|
||||
export function leaveGroupCall({
|
||||
call, isPageUnload,
|
||||
call, isPageUnload, source,
|
||||
}: {
|
||||
call: ApiGroupCall; isPageUnload?: boolean;
|
||||
call: ApiGroupCall; isPageUnload?: boolean; source?: number;
|
||||
}) {
|
||||
const request = new GramJs.phone.LeaveGroupCall({
|
||||
call: buildInputGroupCall(call),
|
||||
source: DEFAULT_PRIMITIVES.INT,
|
||||
source: source ?? DEFAULT_PRIMITIVES.INT,
|
||||
});
|
||||
|
||||
if (isPageUnload) {
|
||||
@ -141,19 +149,19 @@ export function leaveGroupCall({
|
||||
return;
|
||||
}
|
||||
|
||||
invokeRequest(request, {
|
||||
return invokeRequest(request, {
|
||||
shouldReturnTrue: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function joinGroupCall({
|
||||
call, inviteHash, params,
|
||||
call, inviteHash, params, joinAs,
|
||||
}: {
|
||||
call: ApiGroupCall; inviteHash?: string; params: JoinGroupCallPayload;
|
||||
call: ApiGroupCall; inviteHash?: string; params: JoinGroupCallPayload; joinAs?: ApiPeer;
|
||||
}) {
|
||||
const result = await invokeRequest(new GramJs.phone.JoinGroupCall({
|
||||
call: buildInputGroupCall(call),
|
||||
joinAs: new GramJs.InputPeerSelf(),
|
||||
joinAs: joinAs ? buildInputPeer(joinAs.id, joinAs.accessHash) : new GramJs.InputPeerSelf(),
|
||||
muted: true,
|
||||
videoStopped: true,
|
||||
params: new GramJs.DataJSON({
|
||||
@ -287,7 +295,7 @@ export async function requestCall({
|
||||
randomId: generateRandomInt32(),
|
||||
userId: buildInputUser(user.id, user.accessHash),
|
||||
gAHash: Buffer.from(gAHash),
|
||||
...(isVideo && { video: true }),
|
||||
video: isVideo ? true : undefined,
|
||||
protocol: buildCallProtocol(),
|
||||
}));
|
||||
|
||||
@ -362,7 +370,7 @@ export async function confirmCall({
|
||||
const result = await invokeRequest(new GramJs.phone.ConfirmCall({
|
||||
peer: buildInputPhoneCall(call),
|
||||
gA: Buffer.from(gA),
|
||||
keyFingerprint: BigInt(keyFingerprint),
|
||||
keyFingerprint: buildSignedLong(keyFingerprint),
|
||||
protocol: buildCallProtocol(),
|
||||
}));
|
||||
|
||||
@ -390,3 +398,18 @@ export function sendSignalingData({
|
||||
peer: buildInputPhoneCall(call),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchCallConfig() {
|
||||
const result = await invokeRequest(new GramJs.phone.GetCallConfig());
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(result.data);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as Record<string, unknown> : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { AuthKey } from '../../../lib/gramjs/crypto/AuthKey';
|
||||
import { Logger } from '../../../lib/gramjs/extensions';
|
||||
import { gunzipSync, gzipSync } from 'fflate';
|
||||
import { CTR } from '../../../lib/gramjs/crypto/CTR';
|
||||
import {
|
||||
convertToLittle, getByteArray, modExp, readBigIntFromBuffer, sha1, sha256,
|
||||
getByteArray, modExp, readBigIntFromBuffer, readBufferFromBigInt, sha1, sha256,
|
||||
} from '../../../lib/gramjs/Helpers';
|
||||
import MTProtoState from '../../../lib/gramjs/network/MTProtoState';
|
||||
|
||||
import { isSctpPacket, SctpSignaling } from './sctpSignaling';
|
||||
|
||||
type DhConfig = {
|
||||
p: number[];
|
||||
@ -14,10 +15,16 @@ type DhConfig = {
|
||||
let currentPhoneCallState: PhoneCallState | undefined;
|
||||
|
||||
class PhoneCallState {
|
||||
private state?: MTProtoState;
|
||||
private authKey?: Buffer;
|
||||
|
||||
private sctp = new SctpSignaling();
|
||||
|
||||
private seq = 0;
|
||||
|
||||
private maxInboundSeq = 0;
|
||||
|
||||
private inboundSeqs = new Set<number>();
|
||||
|
||||
private gA?: bigint;
|
||||
|
||||
private gB?: bigint;
|
||||
@ -30,14 +37,27 @@ class PhoneCallState {
|
||||
|
||||
private resolveState?: VoidFunction;
|
||||
|
||||
private isDestroyed = false;
|
||||
|
||||
constructor(
|
||||
private isOutgoing: boolean,
|
||||
private shouldUseSctp = true,
|
||||
) {
|
||||
this.waitForState = new Promise<void>((resolve) => {
|
||||
this.resolveState = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.isDestroyed = true;
|
||||
this.resolveState?.();
|
||||
this.resolveState = undefined;
|
||||
}
|
||||
|
||||
setShouldUseSctp(shouldUseSctp: boolean) {
|
||||
this.shouldUseSctp = shouldUseSctp;
|
||||
}
|
||||
|
||||
async requestCall({ p, g, random }: DhConfig) {
|
||||
const pBN = readBigIntFromBuffer(Buffer.from(p), false);
|
||||
const randomBN = readBigIntFromBuffer(Buffer.from(random), false);
|
||||
@ -80,7 +100,7 @@ class PhoneCallState {
|
||||
this.p,
|
||||
);
|
||||
const fingerprint: Buffer = await sha1(getByteArray(authKey));
|
||||
const keyFingerprint = readBigIntFromBuffer(fingerprint.slice(-8).reverse(), false);
|
||||
const keyFingerprint = readBigIntFromBuffer(fingerprint.slice(-8), true, true);
|
||||
|
||||
const emojis = await generateEmojiFingerprint(
|
||||
getByteArray(authKey),
|
||||
@ -89,35 +109,142 @@ class PhoneCallState {
|
||||
emojiOffsets,
|
||||
);
|
||||
|
||||
const key = new AuthKey();
|
||||
await key.setKey(getByteArray(authKey));
|
||||
this.state = new MTProtoState(key, new Logger(), true, this.isOutgoing);
|
||||
this.resolveState!();
|
||||
this.authKey = readBufferFromBigInt(authKey, 256, false);
|
||||
this.resolveState?.();
|
||||
this.resolveState = undefined;
|
||||
|
||||
return { gA: Array.from(getByteArray(this.gA!)), keyFingerprint: keyFingerprint.toString(), emojis };
|
||||
}
|
||||
|
||||
async encode(data: string) {
|
||||
if (!this.state) return undefined;
|
||||
private async calcKey(msgKey: Buffer, isClient: boolean) {
|
||||
if (!this.authKey) {
|
||||
throw new Error('Auth key unset');
|
||||
}
|
||||
|
||||
const seqArray = new Uint32Array(1);
|
||||
seqArray[0] = this.seq++;
|
||||
const encodedData = await this.state.encryptMessageData(
|
||||
Buffer.concat([convertToLittle(seqArray), Buffer.from(data)]),
|
||||
);
|
||||
return Array.from(encodedData);
|
||||
const x = 128 + (this.isOutgoing !== isClient ? 8 : 0);
|
||||
const [sha256a, sha256b] = await Promise.all([
|
||||
sha256(Buffer.concat([msgKey, this.authKey.slice(x, x + 36)])),
|
||||
sha256(Buffer.concat([this.authKey.slice(x + 40, x + 76), msgKey])),
|
||||
]);
|
||||
|
||||
return {
|
||||
key: Buffer.concat([sha256a.slice(0, 8), sha256b.slice(8, 24), sha256a.slice(24, 32)]),
|
||||
iv: Buffer.concat([sha256b.slice(0, 4), sha256a.slice(8, 16), sha256b.slice(24, 28)]),
|
||||
};
|
||||
}
|
||||
|
||||
async encode(data: unknown) {
|
||||
if (!this.authKey) return undefined;
|
||||
|
||||
const message = Buffer.from(gzipSync(Buffer.from(JSON.stringify(data))));
|
||||
const packet = Buffer.alloc(4 + message.length);
|
||||
packet.writeUInt32BE(++this.seq, 0);
|
||||
message.copy(packet, 4);
|
||||
|
||||
const x = 128 + (this.isOutgoing ? 0 : 8);
|
||||
const msgKeyLarge = await sha256(Buffer.concat([this.authKey.slice(88 + x, 88 + x + 32), packet]));
|
||||
const msgKey = msgKeyLarge.slice(8, 24);
|
||||
const { key, iv } = await this.calcKey(msgKey, true);
|
||||
const encrypted = new CTR(key, iv).encrypt(packet);
|
||||
const body = Buffer.concat([msgKey, encrypted]);
|
||||
|
||||
return this.shouldUseSctp ? this.sctp.wrapPayload(body) : Array.from(body);
|
||||
}
|
||||
|
||||
async decode(data: number[]): Promise<any> {
|
||||
if (!this.state) {
|
||||
return this.waitForState.then(() => {
|
||||
return this.decode(data);
|
||||
});
|
||||
if (this.isDestroyed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const message = await this.state.decryptMessageData(Buffer.from(data)) as Buffer;
|
||||
if (!this.authKey) {
|
||||
await this.waitForState;
|
||||
if (this.isDestroyed || !this.authKey) {
|
||||
return undefined;
|
||||
}
|
||||
return this.decode(data);
|
||||
}
|
||||
|
||||
return JSON.parse(message.toString());
|
||||
const incoming = Buffer.from(data);
|
||||
const payloads = isSctpPacket(incoming) ? this.sctp.receive(incoming) : [];
|
||||
const bodies = payloads.length ? payloads : [incoming];
|
||||
const messages = [];
|
||||
for (const body of bodies) {
|
||||
const message = await this.decodeBody(body);
|
||||
if (message) {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length > 1) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages[0];
|
||||
}
|
||||
|
||||
private async decodeBody(body: Buffer): Promise<any> {
|
||||
if (body.length < 21) {
|
||||
return undefined;
|
||||
}
|
||||
const authKey = this.authKey;
|
||||
if (!authKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const msgKey = body.slice(0, 16);
|
||||
const encryptedData = body.slice(16);
|
||||
const { key, iv } = await this.calcKey(msgKey, false);
|
||||
const decrypted = new CTR(key, iv).decrypt(encryptedData);
|
||||
|
||||
const x = 128 + (this.isOutgoing ? 8 : 0);
|
||||
const msgKeyLarge = await sha256(Buffer.concat([authKey.slice(88 + x, 88 + x + 32), decrypted]));
|
||||
if (!msgKey.equals(msgKeyLarge.slice(8, 24))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (decrypted.length < 4) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const inboundSeq = decrypted.readUInt32BE(0);
|
||||
if (!this.shouldAcceptInboundSeq(inboundSeq)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const message = decrypted.slice(4);
|
||||
try {
|
||||
const payload = message[0] === 0x1F && message[1] === 0x8B ? Buffer.from(gunzipSync(message)) : message;
|
||||
this.markInboundSeq(inboundSeq);
|
||||
return JSON.parse(payload.toString());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private shouldAcceptInboundSeq(seq: number) {
|
||||
return Boolean(seq && seq > this.maxInboundSeq - 64 && !this.inboundSeqs.has(seq));
|
||||
}
|
||||
|
||||
private markInboundSeq(seq: number) {
|
||||
this.inboundSeqs.add(seq);
|
||||
if (seq > this.maxInboundSeq) {
|
||||
this.maxInboundSeq = seq;
|
||||
}
|
||||
|
||||
const minSeq = this.maxInboundSeq - 64;
|
||||
this.inboundSeqs.forEach((item) => {
|
||||
if (item <= minSeq) {
|
||||
this.inboundSeqs.delete(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
drainSignalingData() {
|
||||
if (!this.shouldUseSctp) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.sctp.drainPackets();
|
||||
}
|
||||
}
|
||||
|
||||
@ -150,11 +277,22 @@ async function generateEmojiFingerprint(
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
export function createPhoneCallState(params: ConstructorParameters<typeof PhoneCallState>) {
|
||||
currentPhoneCallState = new PhoneCallState(...params);
|
||||
export function createPhoneCallState({
|
||||
isOutgoing,
|
||||
shouldUseSctp = true,
|
||||
}: {
|
||||
isOutgoing: boolean;
|
||||
shouldUseSctp?: boolean;
|
||||
}) {
|
||||
currentPhoneCallState = new PhoneCallState(isOutgoing, shouldUseSctp);
|
||||
}
|
||||
|
||||
export function setPhoneCallSctpEnabled(shouldUseSctp: boolean) {
|
||||
currentPhoneCallState?.setShouldUseSctp(shouldUseSctp);
|
||||
}
|
||||
|
||||
export function destroyPhoneCallState() {
|
||||
currentPhoneCallState?.destroy();
|
||||
currentPhoneCallState = undefined;
|
||||
}
|
||||
|
||||
@ -179,6 +317,10 @@ export async function decodePhoneCallData(params: ParamsOf<'decode'>) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function drainPhoneCallSignalingData() {
|
||||
return currentPhoneCallState?.drainSignalingData() || [];
|
||||
}
|
||||
|
||||
export function confirmPhoneCall(params: ParamsOf<'confirmCall'>): ReturnTypeOf<'confirmCall'> {
|
||||
return currentPhoneCallState!.confirmCall(...params);
|
||||
}
|
||||
|
||||
640
src/api/gramjs/methods/sctpSignaling.ts
Normal file
640
src/api/gramjs/methods/sctpSignaling.ts
Normal file
@ -0,0 +1,640 @@
|
||||
import { DEBUG_CALLS } from '../../../config';
|
||||
|
||||
type SctpChunk = {
|
||||
type: number;
|
||||
flags: number;
|
||||
body: Buffer;
|
||||
};
|
||||
|
||||
type SctpDataChunk = {
|
||||
flags: number;
|
||||
body: Buffer;
|
||||
};
|
||||
|
||||
const SCTP_PORT = 5000;
|
||||
const SCTP_RECEIVE_WINDOW = 0x500000;
|
||||
const SCTP_MAX_PENDING_PEER_DATA_CHUNKS = 256;
|
||||
const SCTP_MAX_PENDING_PEER_DATA_BYTES = 0x100000;
|
||||
const SCTP_MAX_PENDING_PEER_TSN_GAP = 1024;
|
||||
const SCTP_INIT_RETRY_DELAY = 1000;
|
||||
const SCTP_MAX_INIT_RETRY_DELAY = 8000;
|
||||
const SCTP_STREAM_ID = 0;
|
||||
const SCTP_BINARY_PPID = 53;
|
||||
const SCTP_INIT = 1;
|
||||
const SCTP_INIT_ACK = 2;
|
||||
const SCTP_SACK = 3;
|
||||
const SCTP_HEARTBEAT = 4;
|
||||
const SCTP_HEARTBEAT_ACK = 5;
|
||||
const SCTP_ABORT = 6;
|
||||
const SCTP_STATE_COOKIE = 7;
|
||||
const SCTP_COOKIE_ECHO = 10;
|
||||
const SCTP_COOKIE_ACK = 11;
|
||||
const SCTP_DATA = 0;
|
||||
|
||||
const CRC32C_TABLE = createCrc32cTable();
|
||||
|
||||
export class SctpSignaling {
|
||||
private localTag = generateUint32();
|
||||
|
||||
private localTsn = generateUint32();
|
||||
|
||||
private localSsn = 0;
|
||||
|
||||
private peerTag?: number;
|
||||
|
||||
private peerInitialTsn?: number;
|
||||
|
||||
private peerCumulativeTsn?: number;
|
||||
|
||||
private initSent = false;
|
||||
|
||||
private initSentAt = 0;
|
||||
|
||||
private initRetryCount = 0;
|
||||
|
||||
private isEstablished = false;
|
||||
|
||||
private cookie = Buffer.alloc(0);
|
||||
|
||||
private pendingPayloads: Buffer[] = [];
|
||||
|
||||
private pendingPackets: number[][] = [];
|
||||
|
||||
private pendingPeerData = new Map<number, SctpDataChunk>();
|
||||
|
||||
private pendingPeerDataSize = 0;
|
||||
|
||||
private reassembly?: Buffer[];
|
||||
|
||||
wrapPayload(payload: Buffer) {
|
||||
if (this.isEstablished && this.peerTag !== undefined) {
|
||||
return Array.from(this.createDataPacket(payload));
|
||||
}
|
||||
|
||||
this.pendingPayloads.push(payload);
|
||||
if (!this.initSent) {
|
||||
return this.createInitRetryPacket();
|
||||
}
|
||||
|
||||
if (this.shouldRetryInit()) {
|
||||
logSctp('INIT retrying', {
|
||||
retryCount: this.initRetryCount,
|
||||
pendingPayloadCount: this.pendingPayloads.length,
|
||||
});
|
||||
return this.createInitRetryPacket();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private shouldRetryInit() {
|
||||
if (!this.initSentAt || this.peerTag !== undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Date.now() - this.initSentAt >= this.getInitRetryDelay();
|
||||
}
|
||||
|
||||
private getInitRetryDelay() {
|
||||
if (!this.initRetryCount) {
|
||||
return SCTP_INIT_RETRY_DELAY;
|
||||
}
|
||||
|
||||
return Math.min(SCTP_INIT_RETRY_DELAY * 2 ** (this.initRetryCount - 1), SCTP_MAX_INIT_RETRY_DELAY);
|
||||
}
|
||||
|
||||
private createInitRetryPacket() {
|
||||
this.initSent = true;
|
||||
this.initSentAt = Date.now();
|
||||
this.initRetryCount++;
|
||||
return Array.from(this.createInitPacket());
|
||||
}
|
||||
|
||||
drainPackets() {
|
||||
const result = this.pendingPackets;
|
||||
this.pendingPackets = [];
|
||||
return result;
|
||||
}
|
||||
|
||||
receive(packet: Buffer) {
|
||||
const payloads: Buffer[] = [];
|
||||
if (packet.length < 12) {
|
||||
logSctp('packet dropped: too short', {
|
||||
length: packet.length,
|
||||
});
|
||||
return payloads;
|
||||
}
|
||||
|
||||
if (!hasValidSctpChecksum(packet)) {
|
||||
logSctp('packet dropped: invalid CRC32C', {
|
||||
length: packet.length,
|
||||
sourcePort: packet.readUInt16BE(0),
|
||||
destinationPort: packet.readUInt16BE(2),
|
||||
verificationTag: packet.readUInt32BE(4),
|
||||
});
|
||||
return payloads;
|
||||
}
|
||||
|
||||
const chunks = parseSctpChunks(packet);
|
||||
chunks.forEach((chunk) => {
|
||||
if (!this.validateVerificationTag(packet, chunk.type)) {
|
||||
logSctp('chunk dropped: invalid verification tag', {
|
||||
chunkType: chunk.type,
|
||||
verificationTag: packet.readUInt32BE(4),
|
||||
expectedVerificationTag: chunk.type === SCTP_INIT ? 0 : this.localTag,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (chunk.type === SCTP_INIT) {
|
||||
this.handleInit(chunk.body);
|
||||
} else if (chunk.type === SCTP_INIT_ACK) {
|
||||
this.handleInitAck(chunk.body);
|
||||
} else if (chunk.type === SCTP_COOKIE_ECHO) {
|
||||
if (!this.validateCookieEcho(chunk.body)) {
|
||||
logSctp('COOKIE_ECHO ignored: invalid cookie', {
|
||||
cookieLength: chunk.body.length,
|
||||
expectedCookieLength: this.cookie.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.pendingPackets.push(Array.from(this.createPacket(SCTP_COOKIE_ACK, 0, Buffer.alloc(0))));
|
||||
this.markEstablished();
|
||||
} else if (chunk.type === SCTP_COOKIE_ACK) {
|
||||
this.markEstablished();
|
||||
} else if (chunk.type === SCTP_DATA) {
|
||||
payloads.push(...this.handleData(chunk.flags, chunk.body));
|
||||
} else if (chunk.type === SCTP_SACK) {
|
||||
this.handleSack(chunk.body);
|
||||
} else if (chunk.type === SCTP_HEARTBEAT) {
|
||||
this.pendingPackets.push(Array.from(this.createPacket(SCTP_HEARTBEAT_ACK, 0, chunk.body)));
|
||||
} else if (chunk.type === SCTP_HEARTBEAT_ACK) {
|
||||
// Nothing to do; accepting the chunk prevents Firefox/native peers from being logged as unsupported.
|
||||
} else if (chunk.type === SCTP_ABORT) {
|
||||
logSctp('ABORT received; resetting association', {
|
||||
bodyLength: chunk.body.length,
|
||||
});
|
||||
this.resetAssociation();
|
||||
} else {
|
||||
logSctp('chunk ignored: unsupported type', {
|
||||
chunkType: chunk.type,
|
||||
flags: chunk.flags,
|
||||
bodyLength: chunk.body.length,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return payloads;
|
||||
}
|
||||
|
||||
private validateCookieEcho(cookie: Buffer) {
|
||||
return Boolean(this.cookie.length && cookie.length === this.cookie.length && cookie.equals(this.cookie));
|
||||
}
|
||||
|
||||
private validateVerificationTag(packet: Buffer, chunkType: number) {
|
||||
const verificationTag = packet.readUInt32BE(4);
|
||||
if (chunkType === SCTP_INIT) {
|
||||
return verificationTag === 0;
|
||||
}
|
||||
|
||||
return verificationTag === this.localTag;
|
||||
}
|
||||
|
||||
private handleInit(body: Buffer) {
|
||||
if (body.length < 16) {
|
||||
logSctp('INIT ignored: body too short', {
|
||||
bodyLength: body.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.peerTag = body.readUInt32BE(0);
|
||||
this.peerInitialTsn = body.readUInt32BE(12);
|
||||
this.peerCumulativeTsn = (this.peerInitialTsn - 1) >>> 0;
|
||||
this.initSent = true;
|
||||
this.cookie = this.createCookie();
|
||||
this.pendingPackets.push(Array.from(this.createInitAckPacket()));
|
||||
}
|
||||
|
||||
private handleInitAck(body: Buffer) {
|
||||
if (body.length < 16) {
|
||||
logSctp('INIT_ACK ignored: body too short', {
|
||||
bodyLength: body.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.peerTag = body.readUInt32BE(0);
|
||||
this.peerInitialTsn = body.readUInt32BE(12);
|
||||
this.peerCumulativeTsn = (this.peerInitialTsn - 1) >>> 0;
|
||||
|
||||
const cookie = findSctpParameter(body.slice(16), SCTP_STATE_COOKIE);
|
||||
if (cookie) {
|
||||
this.pendingPackets.push(Array.from(this.createPacket(SCTP_COOKIE_ECHO, 0, cookie)));
|
||||
} else {
|
||||
logSctp('INIT_ACK ignored: missing state cookie', {
|
||||
bodyLength: body.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleData(flags: number, body: Buffer) {
|
||||
if (body.length < 12) {
|
||||
logSctp('DATA ignored: body too short', {
|
||||
bodyLength: body.length,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const tsn = body.readUInt32BE(0);
|
||||
const streamId = body.readUInt16BE(4);
|
||||
const ppid = body.readUInt32BE(8);
|
||||
if (streamId !== SCTP_STREAM_ID || ppid !== SCTP_BINARY_PPID) {
|
||||
logSctp('DATA ignored: unsupported stream or PPID', {
|
||||
streamId,
|
||||
ppid,
|
||||
expectedStreamId: SCTP_STREAM_ID,
|
||||
expectedPpid: SCTP_BINARY_PPID,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const expectedTsn = this.getNextPeerTsn();
|
||||
if (expectedTsn !== undefined && tsn !== expectedTsn) {
|
||||
if (isTsnAfter(tsn, expectedTsn)) {
|
||||
this.bufferPendingPeerData(tsn, flags, body, expectedTsn);
|
||||
} else {
|
||||
logSctp('DATA ignored: duplicate TSN', {
|
||||
tsn,
|
||||
peerCumulativeTsn: this.peerCumulativeTsn,
|
||||
expectedTsn,
|
||||
});
|
||||
}
|
||||
this.pendingPackets.push(Array.from(this.createSackPacket()));
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.acceptData(tsn, flags, body);
|
||||
}
|
||||
|
||||
private acceptData(tsn: number, flags: number, body: Buffer) {
|
||||
const payloads: Buffer[] = [];
|
||||
let currentTsn = tsn;
|
||||
let currentFlags = flags;
|
||||
let currentBody = body;
|
||||
|
||||
while (true) {
|
||||
const payload = this.readDataPayload(currentTsn, currentFlags, currentBody);
|
||||
if (payload) {
|
||||
payloads.push(payload);
|
||||
}
|
||||
|
||||
const nextTsn = this.getNextPeerTsn();
|
||||
const nextChunk = nextTsn === undefined ? undefined : this.pendingPeerData.get(nextTsn);
|
||||
if (nextTsn === undefined || !nextChunk) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.deletePendingPeerData(nextTsn);
|
||||
currentTsn = nextTsn;
|
||||
currentFlags = nextChunk.flags;
|
||||
currentBody = nextChunk.body;
|
||||
}
|
||||
|
||||
this.pendingPackets.push(Array.from(this.createSackPacket()));
|
||||
return payloads;
|
||||
}
|
||||
|
||||
private readDataPayload(tsn: number, flags: number, body: Buffer) {
|
||||
this.markEstablished();
|
||||
this.peerCumulativeTsn = tsn;
|
||||
|
||||
const userData = body.slice(12);
|
||||
const isBegin = Boolean(flags & 0x02);
|
||||
const isEnd = Boolean(flags & 0x01);
|
||||
if (isBegin && isEnd) {
|
||||
return userData;
|
||||
}
|
||||
|
||||
if (isBegin) {
|
||||
this.reassembly = [userData];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!this.reassembly) {
|
||||
logSctp('DATA ignored: missing reassembly start', {
|
||||
flags,
|
||||
tsn,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.reassembly.push(userData);
|
||||
if (!isEnd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = Buffer.concat(this.reassembly);
|
||||
this.reassembly = undefined;
|
||||
return result;
|
||||
}
|
||||
|
||||
private handleSack(body: Buffer) {
|
||||
if (body.length < 12) {
|
||||
logSctp('SACK ignored: body too short', {
|
||||
bodyLength: body.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.markEstablished();
|
||||
}
|
||||
|
||||
private getNextPeerTsn() {
|
||||
if (this.peerCumulativeTsn === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (this.peerCumulativeTsn + 1) >>> 0;
|
||||
}
|
||||
|
||||
private bufferPendingPeerData(tsn: number, flags: number, body: Buffer, expectedTsn: number) {
|
||||
if (this.pendingPeerData.has(tsn)) {
|
||||
logSctp('DATA ignored: already buffered TSN', {
|
||||
tsn,
|
||||
expectedTsn,
|
||||
pendingCount: this.pendingPeerData.size,
|
||||
pendingBytes: this.pendingPeerDataSize,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tsnGap = getForwardTsnGap(tsn, expectedTsn);
|
||||
if (
|
||||
tsnGap > SCTP_MAX_PENDING_PEER_TSN_GAP
|
||||
|| this.pendingPeerData.size >= SCTP_MAX_PENDING_PEER_DATA_CHUNKS
|
||||
|| this.pendingPeerDataSize + body.length > SCTP_MAX_PENDING_PEER_DATA_BYTES
|
||||
) {
|
||||
logSctp('DATA ignored: TSN gap buffer full', {
|
||||
tsn,
|
||||
peerCumulativeTsn: this.peerCumulativeTsn,
|
||||
expectedTsn,
|
||||
tsnGap,
|
||||
pendingCount: this.pendingPeerData.size,
|
||||
pendingBytes: this.pendingPeerDataSize,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingPeerData.set(tsn, { flags, body });
|
||||
this.pendingPeerDataSize += body.length;
|
||||
logSctp('DATA buffered: TSN gap', {
|
||||
tsn,
|
||||
peerCumulativeTsn: this.peerCumulativeTsn,
|
||||
expectedTsn,
|
||||
tsnGap,
|
||||
pendingCount: this.pendingPeerData.size,
|
||||
pendingBytes: this.pendingPeerDataSize,
|
||||
});
|
||||
}
|
||||
|
||||
private deletePendingPeerData(tsn: number) {
|
||||
const chunk = this.pendingPeerData.get(tsn);
|
||||
if (!chunk) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingPeerDataSize -= chunk.body.length;
|
||||
this.pendingPeerData.delete(tsn);
|
||||
}
|
||||
|
||||
private clearPendingPeerData() {
|
||||
this.pendingPeerData.clear();
|
||||
this.pendingPeerDataSize = 0;
|
||||
}
|
||||
|
||||
private flushPendingPayloads() {
|
||||
if (this.peerTag === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloads = this.pendingPayloads;
|
||||
this.pendingPayloads = [];
|
||||
payloads.forEach((payload) => {
|
||||
this.pendingPackets.push(Array.from(this.createDataPacket(payload)));
|
||||
});
|
||||
}
|
||||
|
||||
private markEstablished() {
|
||||
if (this.isEstablished) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isEstablished = true;
|
||||
this.flushPendingPayloads();
|
||||
}
|
||||
|
||||
private resetAssociation() {
|
||||
this.localTag = generateUint32();
|
||||
this.localTsn = generateUint32();
|
||||
this.localSsn = 0;
|
||||
this.peerTag = undefined;
|
||||
this.peerInitialTsn = undefined;
|
||||
this.peerCumulativeTsn = undefined;
|
||||
this.initSent = false;
|
||||
this.initSentAt = 0;
|
||||
this.initRetryCount = 0;
|
||||
this.isEstablished = false;
|
||||
this.cookie = Buffer.alloc(0);
|
||||
this.pendingPayloads = [];
|
||||
this.pendingPackets = [];
|
||||
this.clearPendingPeerData();
|
||||
this.reassembly = undefined;
|
||||
}
|
||||
|
||||
private createInitPacket() {
|
||||
const body = Buffer.alloc(16);
|
||||
body.writeUInt32BE(this.localTag, 0);
|
||||
body.writeUInt32BE(SCTP_RECEIVE_WINDOW, 4);
|
||||
body.writeUInt16BE(0xFFFF, 8);
|
||||
body.writeUInt16BE(0xFFFF, 10);
|
||||
body.writeUInt32BE(this.localTsn, 12);
|
||||
return this.createPacket(SCTP_INIT, 0, body, 0);
|
||||
}
|
||||
|
||||
private createInitAckPacket() {
|
||||
const body = Buffer.alloc(16);
|
||||
body.writeUInt32BE(this.localTag, 0);
|
||||
body.writeUInt32BE(SCTP_RECEIVE_WINDOW, 4);
|
||||
body.writeUInt16BE(0xFFFF, 8);
|
||||
body.writeUInt16BE(0xFFFF, 10);
|
||||
body.writeUInt32BE(this.localTsn, 12);
|
||||
|
||||
return this.createPacket(SCTP_INIT_ACK, 0, Buffer.concat([
|
||||
body,
|
||||
createSctpParameter(SCTP_STATE_COOKIE, this.cookie),
|
||||
]));
|
||||
}
|
||||
|
||||
private createDataPacket(payload: Buffer) {
|
||||
const body = Buffer.alloc(12 + payload.length);
|
||||
body.writeUInt32BE(this.localTsn, 0);
|
||||
this.localTsn = (this.localTsn + 1) >>> 0;
|
||||
body.writeUInt16BE(SCTP_STREAM_ID, 4);
|
||||
body.writeUInt16BE(this.localSsn, 6);
|
||||
this.localSsn = (this.localSsn + 1) & 0xFFFF;
|
||||
body.writeUInt32BE(SCTP_BINARY_PPID, 8);
|
||||
payload.copy(body, 12);
|
||||
return this.createPacket(SCTP_DATA, 0x03, body);
|
||||
}
|
||||
|
||||
private createSackPacket() {
|
||||
const body = Buffer.alloc(12);
|
||||
body.writeUInt32BE(this.peerCumulativeTsn || 0, 0);
|
||||
body.writeUInt32BE(SCTP_RECEIVE_WINDOW, 4);
|
||||
body.writeUInt16BE(0, 8);
|
||||
body.writeUInt16BE(0, 10);
|
||||
return this.createPacket(SCTP_SACK, 0, body);
|
||||
}
|
||||
|
||||
private createPacket(type: number, flags: number, body: Buffer, verificationTag = this.peerTag || 0) {
|
||||
const chunk = createSctpChunk(type, flags, body);
|
||||
const packet = Buffer.alloc(12 + chunk.length);
|
||||
packet.writeUInt16BE(SCTP_PORT, 0);
|
||||
packet.writeUInt16BE(SCTP_PORT, 2);
|
||||
packet.writeUInt32BE(verificationTag, 4);
|
||||
chunk.copy(packet, 12);
|
||||
packet.writeUInt32LE(crc32c(packet), 8);
|
||||
return packet;
|
||||
}
|
||||
|
||||
private createCookie() {
|
||||
const cookie = Buffer.alloc(16);
|
||||
cookie.writeUInt32BE(this.localTag, 0);
|
||||
cookie.writeUInt32BE(this.peerTag || 0, 4);
|
||||
cookie.writeUInt32BE(this.localTsn, 8);
|
||||
cookie.writeUInt32BE(this.peerInitialTsn || 0, 12);
|
||||
return cookie;
|
||||
}
|
||||
}
|
||||
|
||||
function createSctpChunk(type: number, flags: number, body: Buffer) {
|
||||
const length = 4 + body.length;
|
||||
const paddedLength = align4(length);
|
||||
const chunk = Buffer.alloc(paddedLength);
|
||||
chunk[0] = type;
|
||||
chunk[1] = flags;
|
||||
chunk.writeUInt16BE(length, 2);
|
||||
body.copy(chunk, 4);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function createSctpParameter(type: number, value: Buffer) {
|
||||
const length = 4 + value.length;
|
||||
const parameter = Buffer.alloc(align4(length));
|
||||
parameter.writeUInt16BE(type, 0);
|
||||
parameter.writeUInt16BE(length, 2);
|
||||
value.copy(parameter, 4);
|
||||
return parameter;
|
||||
}
|
||||
|
||||
function findSctpParameter(parameters: Buffer, type: number) {
|
||||
let offset = 0;
|
||||
while (offset + 4 <= parameters.length) {
|
||||
const parameterType = parameters.readUInt16BE(offset);
|
||||
const length = parameters.readUInt16BE(offset + 2);
|
||||
if (length < 4 || offset + length > parameters.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (parameterType === type) {
|
||||
return parameters.slice(offset + 4, offset + length);
|
||||
}
|
||||
|
||||
offset += align4(length);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseSctpChunks(packet: Buffer) {
|
||||
const chunks: SctpChunk[] = [];
|
||||
let offset = 12;
|
||||
while (offset + 4 <= packet.length) {
|
||||
const type = packet[offset];
|
||||
const flags = packet[offset + 1];
|
||||
const length = packet.readUInt16BE(offset + 2);
|
||||
if (length < 4 || offset + length > packet.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
chunks.push({
|
||||
type,
|
||||
flags,
|
||||
body: packet.slice(offset + 4, offset + length),
|
||||
});
|
||||
offset += align4(length);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function isSctpPacket(packet: Buffer) {
|
||||
return packet.length >= 12
|
||||
&& packet.readUInt16BE(0) === SCTP_PORT
|
||||
&& packet.readUInt16BE(2) === SCTP_PORT;
|
||||
}
|
||||
|
||||
function hasValidSctpChecksum(packet: Buffer) {
|
||||
return isSctpPacket(packet) && packet.readUInt32LE(8) === crc32c(packet);
|
||||
}
|
||||
|
||||
function align4(value: number) {
|
||||
return (value + 3) & ~3;
|
||||
}
|
||||
|
||||
function generateUint32() {
|
||||
const values = new Uint32Array(1);
|
||||
crypto.getRandomValues(values);
|
||||
|
||||
return values[0] >>> 0;
|
||||
}
|
||||
|
||||
function isTsnAfter(tsn: number, expectedTsn: number) {
|
||||
return ((tsn - expectedTsn) >>> 0) < 0x80000000;
|
||||
}
|
||||
|
||||
function getForwardTsnGap(tsn: number, expectedTsn: number) {
|
||||
return (tsn - expectedTsn) >>> 0;
|
||||
}
|
||||
|
||||
function createCrc32cTable() {
|
||||
const table: number[] = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let value = i;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
value = value & 1 ? (0x82F63B78 ^ (value >>> 1)) : value >>> 1;
|
||||
}
|
||||
table[i] = value >>> 0;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
function crc32c(data: Buffer) {
|
||||
const buffer = Buffer.from(data);
|
||||
buffer.writeUInt32LE(0, 8);
|
||||
let crc = 0xFFFFFFFF;
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
crc = CRC32C_TABLE[(crc ^ buffer[i]) & 0xFF] ^ (crc >>> 8);
|
||||
}
|
||||
return (~crc) >>> 0;
|
||||
}
|
||||
|
||||
function logSctp(message: string, data: Record<string, unknown> = {}) {
|
||||
if (!DEBUG_CALLS) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`[PhoneCall][SCTP] ${message}`, data);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { Api as GramJs, type Update } from '../../../lib/gramjs';
|
||||
import { UpdateConnectionState, UpdateServerTimeOffset } from '../../../lib/gramjs/network';
|
||||
|
||||
import type { GroupCallConnectionData } from '../../../lib/secret-sauce';
|
||||
import type { GroupCallConnectionData } from '../../../lib/vibecalls';
|
||||
import {
|
||||
type ApiMessage,
|
||||
type ApiMessagePoll,
|
||||
|
||||
@ -4,7 +4,10 @@ import type {
|
||||
GroupCallParticipant,
|
||||
VideoRotation,
|
||||
VideoState,
|
||||
} from '../../lib/secret-sauce';
|
||||
} from '../../lib/vibecalls';
|
||||
import type { PrimitiveRecord } from '../../util/primitives/primitiveRecord';
|
||||
|
||||
export type ApiPhoneCallCustomParameters = PrimitiveRecord;
|
||||
|
||||
export interface ApiGroupCall {
|
||||
chatId?: string;
|
||||
@ -26,6 +29,8 @@ export interface ApiGroupCall {
|
||||
inviteHash?: string;
|
||||
|
||||
nextOffset?: string;
|
||||
localSource?: number;
|
||||
localJoinAsId?: string;
|
||||
participants: Record<string, GroupCallParticipant>;
|
||||
connectionState: GroupCallConnectionState;
|
||||
isSpeakerDisabled?: boolean;
|
||||
@ -50,6 +55,7 @@ export interface ApiPhoneCall {
|
||||
needDebug?: boolean;
|
||||
reason?: 'missed' | 'disconnect' | 'hangup' | 'busy';
|
||||
duration?: number;
|
||||
customParameters?: ApiPhoneCallCustomParameters;
|
||||
|
||||
emojis?: string;
|
||||
gA?: number[];
|
||||
|
||||
@ -5,7 +5,7 @@ import type {
|
||||
GroupCallParticipant,
|
||||
VideoRotation,
|
||||
VideoState,
|
||||
} from '../../lib/secret-sauce';
|
||||
} from '../../lib/vibecalls';
|
||||
import type { ThreadId, ThreadReadState, TranslationTone } from '../../types';
|
||||
import type { RegularLangFnParameters } from '../../util/localization';
|
||||
import type { ApiBotCommand, ApiBotMenuButton } from './bots';
|
||||
|
||||
@ -8,10 +8,10 @@ import { getActions, withGlobal } from '../../../global';
|
||||
|
||||
import type {
|
||||
GroupCallConnectionState, GroupCallParticipant as TypeGroupCallParticipant,
|
||||
} from '../../../lib/secret-sauce';
|
||||
} from '../../../lib/vibecalls';
|
||||
import type { VideoParticipant } from './hooks/useGroupCallVideoLayout';
|
||||
|
||||
import { IS_SCREENSHARE_SUPPORTED } from '../../../lib/secret-sauce';
|
||||
import { IS_SCREENSHARE_SUPPORTED } from '../../../lib/vibecalls';
|
||||
import { selectChat, selectTabState } from '../../../global/selectors';
|
||||
import {
|
||||
selectCanInviteToActiveGroupCall,
|
||||
|
||||
@ -5,10 +5,10 @@ import {
|
||||
import { withGlobal } from '../../../global';
|
||||
|
||||
import type { ApiPeer } from '../../../api/types';
|
||||
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
|
||||
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/vibecalls';
|
||||
|
||||
import { GROUP_CALL_DEFAULT_VOLUME } from '../../../config';
|
||||
import { THRESHOLD } from '../../../lib/secret-sauce';
|
||||
import { THRESHOLD } from '../../../lib/vibecalls';
|
||||
import { selectChat, selectUser } from '../../../global/selectors';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import renderText from '../../common/helpers/renderText';
|
||||
|
||||
@ -2,7 +2,7 @@ import type { FC } from '../../../lib/teact/teact';
|
||||
import { memo, useMemo } from '../../../lib/teact/teact';
|
||||
import { getActions, withGlobal } from '../../../global';
|
||||
|
||||
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
|
||||
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/vibecalls';
|
||||
|
||||
import { selectActiveGroupCall } from '../../../global/selectors/calls';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
@ -3,7 +3,7 @@ import type React from '../../../lib/teact/teact';
|
||||
import { memo, useEffect, useState } from '../../../lib/teact/teact';
|
||||
import { getActions, withGlobal } from '../../../global';
|
||||
|
||||
import type { GroupCallParticipant } from '../../../lib/secret-sauce';
|
||||
import type { GroupCallParticipant } from '../../../lib/vibecalls';
|
||||
import type { MenuPositionOptions } from '../../ui/Menu';
|
||||
|
||||
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
|
||||
|
||||
@ -5,13 +5,13 @@ import {
|
||||
import { withGlobal } from '../../../global';
|
||||
|
||||
import type { ApiChat, ApiUser } from '../../../api/types';
|
||||
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
|
||||
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/vibecalls';
|
||||
import type { VideoLayout, VideoParticipant } from './hooks/useGroupCallVideoLayout';
|
||||
|
||||
import { GROUP_CALL_DEFAULT_VOLUME } from '../../../config';
|
||||
import fastBlur from '../../../lib/fastBlur';
|
||||
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
|
||||
import { getUserStreams, THRESHOLD } from '../../../lib/secret-sauce';
|
||||
import { getUserStreams, THRESHOLD } from '../../../lib/vibecalls';
|
||||
import { selectChat, selectUser } from '../../../global/selectors';
|
||||
import { animate } from '../../../util/animation';
|
||||
import { IS_CANVAS_FILTER_SUPPORTED } from '../../../util/browser/windowEnvironment';
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
} from '../../../lib/teact/teact';
|
||||
import { getActions, withGlobal } from '../../../global';
|
||||
|
||||
import type { GroupCallConnectionState } from '../../../lib/secret-sauce';
|
||||
import type { GroupCallConnectionState } from '../../../lib/vibecalls';
|
||||
|
||||
import { selectActiveGroupCall, selectGroupCallParticipant } from '../../../global/selectors/calls';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { FC } from '../../../lib/teact/teact';
|
||||
import { memo, useMemo } from '../../../lib/teact/teact';
|
||||
|
||||
import type { GroupCallParticipant } from '../../../lib/secret-sauce';
|
||||
import type { GroupCallParticipant } from '../../../lib/vibecalls';
|
||||
|
||||
import { THRESHOLD } from '../../../lib/secret-sauce';
|
||||
import { THRESHOLD } from '../../../lib/vibecalls';
|
||||
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
|
||||
|
||||
import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated';
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { GroupCallParticipant } from '../../../../lib/secret-sauce';
|
||||
import type { GroupCallParticipant } from '../../../../lib/vibecalls';
|
||||
|
||||
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../../config';
|
||||
|
||||
|
||||
@ -48,6 +48,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.fullscreenDialog {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: absolute;
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ import type { ApiPhoneCall, ApiUser } from '../../../api/types';
|
||||
|
||||
import {
|
||||
getStreams, IS_SCREENSHARE_SUPPORTED, switchCameraInputP2p, toggleStreamP2p,
|
||||
} from '../../../lib/secret-sauce';
|
||||
} from '../../../lib/vibecalls';
|
||||
import { selectTabState } from '../../../global/selectors';
|
||||
import { selectPhoneCallUser } from '../../../global/selectors/calls';
|
||||
import {
|
||||
@ -59,42 +59,56 @@ const PhoneCall = ({
|
||||
const [isFullscreen, openFullscreen, closeFullscreen] = useFlag();
|
||||
const { isMobile } = useAppLayout();
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (isFullscreen) {
|
||||
closeFullscreen();
|
||||
const isOpen = Boolean(phoneCall && phoneCall.state !== 'discarded' && !isCallPanelVisible);
|
||||
|
||||
const exitFullscreenIfNeeded = useCallback(() => {
|
||||
if (document.fullscreenElement === containerRef.current) {
|
||||
void document.exitFullscreen().catch(() => undefined).then(closeFullscreen);
|
||||
} else {
|
||||
openFullscreen();
|
||||
closeFullscreen();
|
||||
}
|
||||
}, [closeFullscreen, isFullscreen, openFullscreen]);
|
||||
}, [closeFullscreen]);
|
||||
|
||||
const handleToggleFullscreen = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (isFullscreen) {
|
||||
document.exitFullscreen().then(closeFullscreen);
|
||||
exitFullscreenIfNeeded();
|
||||
} else {
|
||||
containerRef.current.requestFullscreen().then(openFullscreen);
|
||||
void containerRef.current.requestFullscreen().then(openFullscreen).catch(() => undefined);
|
||||
}
|
||||
}, [closeFullscreen, isFullscreen, openFullscreen]);
|
||||
}, [exitFullscreenIfNeeded, isFullscreen, openFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_REQUEST_FULLSCREEN_SUPPORTED) return undefined;
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
|
||||
container.addEventListener('fullscreenchange', toggleFullscreen);
|
||||
const handleFullscreenChange = () => {
|
||||
if (document.fullscreenElement === containerRef.current) {
|
||||
openFullscreen();
|
||||
} else {
|
||||
closeFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('fullscreenchange', toggleFullscreen);
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
};
|
||||
}, [toggleFullscreen]);
|
||||
}, [closeFullscreen, openFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen || !isFullscreen) return;
|
||||
|
||||
exitFullscreenIfNeeded();
|
||||
}, [exitFullscreenIfNeeded, isFullscreen, isOpen]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
toggleGroupCallPanel();
|
||||
if (isFullscreen) {
|
||||
closeFullscreen();
|
||||
exitFullscreenIfNeeded();
|
||||
}
|
||||
}, [closeFullscreen, isFullscreen, toggleGroupCallPanel]);
|
||||
}, [exitFullscreenIfNeeded, isFullscreen, toggleGroupCallPanel]);
|
||||
|
||||
const isDiscarded = phoneCall?.state === 'discarded';
|
||||
const isBusy = phoneCall?.reason === 'busy';
|
||||
@ -161,9 +175,9 @@ const PhoneCall = ({
|
||||
const hasPresentation = phoneCall?.screencastState === 'active';
|
||||
|
||||
const streams = getStreams();
|
||||
const hasOwnAudio = streams?.ownAudio?.getTracks()[0].enabled;
|
||||
const hasOwnPresentation = streams?.ownPresentation?.getTracks()[0].enabled;
|
||||
const hasOwnVideo = streams?.ownVideo?.getTracks()[0].enabled;
|
||||
const hasOwnAudio = streams?.ownAudio?.getTracks()?.[0]?.enabled ?? false;
|
||||
const hasOwnPresentation = streams?.ownPresentation?.getTracks()?.[0]?.enabled ?? false;
|
||||
const hasOwnVideo = streams?.ownVideo?.getTracks()?.[0]?.enabled ?? false;
|
||||
|
||||
const [isHidingPresentation, startHidingPresentation, stopHidingPresentation] = useFlag();
|
||||
const [isHidingVideo, startHidingVideo, stopHidingVideo] = useFlag();
|
||||
@ -228,12 +242,13 @@ const PhoneCall = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={phoneCall && phoneCall?.state !== 'discarded' && !isCallPanelVisible}
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
className={buildClassName(
|
||||
styles.root,
|
||||
isMobile && styles.singleColumn,
|
||||
)}
|
||||
dialogClassName={buildClassName(isFullscreen && styles.fullscreenDialog)}
|
||||
dialogRef={containerRef}
|
||||
>
|
||||
<Avatar
|
||||
@ -249,23 +264,23 @@ const PhoneCall = ({
|
||||
className={buildClassName(
|
||||
styles.secondVideo,
|
||||
!isHidingPresentation && hasOwnPresentation && styles.visible,
|
||||
isFullscreen && styles.fullscreen,
|
||||
hasOwnPresentation && isFullscreen && styles.fullscreen,
|
||||
)}
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
srcObject={streams?.ownPresentation}
|
||||
srcObject={hasOwnPresentation ? streams?.ownPresentation : undefined}
|
||||
/>
|
||||
<video
|
||||
className={buildClassName(
|
||||
styles.secondVideo,
|
||||
!isHidingVideo && hasOwnVideo && styles.visible,
|
||||
isFullscreen && styles.fullscreen,
|
||||
hasOwnVideo && isFullscreen && styles.fullscreen,
|
||||
)}
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
srcObject={streams?.ownVideo}
|
||||
srcObject={hasOwnVideo ? streams?.ownVideo : undefined}
|
||||
/>
|
||||
<div className={styles.header}>
|
||||
{IS_REQUEST_FULLSCREEN_SUPPORTED && (
|
||||
|
||||
@ -24,6 +24,7 @@ export const PAID_MESSAGES_PURPOSE = 'paid_messages';
|
||||
|
||||
export const DEBUG = process.env.APP_ENV !== 'production';
|
||||
export const DEBUG_MORE = false;
|
||||
export const DEBUG_CALLS = false;
|
||||
export const DEBUG_LOG_FILENAME = 'tt-log.json';
|
||||
export const STRICTERDOM_ENABLED = DEBUG;
|
||||
export const FORCE_FALLBACK_LANG = DEBUG;
|
||||
@ -378,6 +379,8 @@ export const FRAGMENT_PHONE_CODE = '888';
|
||||
export const FRAGMENT_PHONE_LENGTH = 11;
|
||||
export const BOT_VERIFICATION_PEERS_LIMIT = 20;
|
||||
|
||||
export const CALL_PROTOCOL_LIBRARY_VERSIONS = ['13.0.0'];
|
||||
|
||||
export const LIGHT_THEME_BG_COLOR = '#99BA92';
|
||||
export const DARK_THEME_BG_COLOR = '#000000';
|
||||
export const DEFAULT_PATTERN_COLOR = '#4A8E3A8C';
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ActionReturnType } from '../../types';
|
||||
|
||||
import { GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
|
||||
import { DEBUG_CALLS, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
|
||||
import {
|
||||
isStreamEnabled,
|
||||
joinGroupCall,
|
||||
@ -8,7 +8,8 @@ import {
|
||||
setVolume, startSharingScreen,
|
||||
stopPhoneCall,
|
||||
toggleStream,
|
||||
} from '../../../lib/secret-sauce';
|
||||
} from '../../../lib/vibecalls';
|
||||
import { logDebugMessage } from '../../../util/debugConsole';
|
||||
import { getCurrentTabId } from '../../../util/establishMultitabRole';
|
||||
import { callApi } from '../../../api/gramjs';
|
||||
import { addActionHandler, getGlobal, setGlobal } from '../../index';
|
||||
@ -17,7 +18,9 @@ import {
|
||||
updateActiveGroupCall,
|
||||
} from '../../reducers/calls';
|
||||
import { updateTabState } from '../../reducers/tabs';
|
||||
import { selectChat, selectTabState, selectUser } from '../../selectors';
|
||||
import {
|
||||
selectChat, selectPeer, selectTabState, selectUser,
|
||||
} from '../../selectors';
|
||||
import {
|
||||
selectActiveGroupCall, selectPhoneCallUser,
|
||||
} from '../../selectors/calls';
|
||||
@ -47,8 +50,11 @@ addActionHandler('leaveGroupCall', async (global, actions, payload): Promise<voi
|
||||
};
|
||||
setGlobal(global);
|
||||
|
||||
const localParticipantId = groupCall.localJoinAsId ?? global.currentUserId;
|
||||
const source = groupCall.localSource
|
||||
?? (localParticipantId ? groupCall.participants[localParticipantId]?.source : undefined);
|
||||
await callApi('leaveGroupCall', {
|
||||
call: groupCall, isPageUnload,
|
||||
call: groupCall, isPageUnload, source,
|
||||
});
|
||||
await callApi('abortRequestGroup', 'call');
|
||||
|
||||
@ -204,18 +210,30 @@ addActionHandler('connectToActiveGroupCall', async (global, actions, payload): P
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
currentUserId,
|
||||
} = global;
|
||||
const { currentUserId } = global;
|
||||
|
||||
if (!currentUserId) return;
|
||||
|
||||
const params = await joinGroupCall(currentUserId, audioContext, audioElement, actions.apiUpdate);
|
||||
const localParticipantId = groupCall.localJoinAsId ?? currentUserId;
|
||||
const joinAs = groupCall.localJoinAsId ? selectPeer(global, groupCall.localJoinAsId) : undefined;
|
||||
if (groupCall.localJoinAsId && !joinAs) return;
|
||||
|
||||
const params = await joinGroupCall(localParticipantId, audioContext, audioElement, actions.apiUpdate);
|
||||
if (!params) {
|
||||
actions.showNotification({
|
||||
// TODO[lang] Localize error message
|
||||
message: 'Failed to join voice chat',
|
||||
tabId,
|
||||
});
|
||||
actions.leaveGroupCall({ tabId });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await callApi('joinGroupCall', {
|
||||
call: groupCall,
|
||||
params,
|
||||
inviteHash: groupCall.inviteHash,
|
||||
joinAs,
|
||||
});
|
||||
|
||||
global = getGlobal();
|
||||
@ -230,6 +248,11 @@ addActionHandler('connectToActiveGroupCall', async (global, actions, payload): P
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.ssrc !== undefined) {
|
||||
global = updateActiveGroupCall(global, { localSource: params.ssrc });
|
||||
setGlobal(global);
|
||||
}
|
||||
|
||||
actions.loadMoreGroupCallParticipants();
|
||||
|
||||
if (groupCall.chatId) {
|
||||
@ -252,7 +275,10 @@ addActionHandler('connectToActivePhoneCall', async (global, actions): Promise<vo
|
||||
|
||||
if (!dhConfig) return;
|
||||
|
||||
await callApi('createPhoneCallState', [true]);
|
||||
await callApi('createPhoneCallState', {
|
||||
isOutgoing: true,
|
||||
shouldUseSctp: !phoneCall.customParameters?.network_signaling_nosctp,
|
||||
});
|
||||
|
||||
const gAHash = await callApi('requestPhoneCall', [dhConfig]);
|
||||
|
||||
@ -271,7 +297,10 @@ addActionHandler('acceptCall', async (global): Promise<void> => {
|
||||
const dhConfig = await callApi('getDhConfig');
|
||||
if (!dhConfig) return;
|
||||
|
||||
await callApi('createPhoneCallState', [false]);
|
||||
await callApi('createPhoneCallState', {
|
||||
isOutgoing: false,
|
||||
shouldUseSctp: !phoneCall.customParameters?.network_signaling_nosctp,
|
||||
});
|
||||
|
||||
const gB = await callApi('acceptPhoneCall', [dhConfig]);
|
||||
await callApi('acceptCall', { call: phoneCall, gB });
|
||||
@ -283,17 +312,42 @@ addActionHandler('sendSignalingData', (global, actions, payload): ActionReturnTy
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.stringify(payload);
|
||||
|
||||
(async () => {
|
||||
const encodedData = await callApi('encodePhoneCallData', [data]);
|
||||
try {
|
||||
const encodedData = await callApi('encodePhoneCallData', [payload]);
|
||||
|
||||
if (!encodedData) return;
|
||||
if (!encodedData) {
|
||||
return;
|
||||
}
|
||||
|
||||
callApi('sendSignalingData', { data: encodedData, call: phoneCall });
|
||||
await callApi('sendSignalingData', { data: encodedData, call: phoneCall });
|
||||
const pendingPackets = await callApi('drainPhoneCallSignalingData');
|
||||
if (!pendingPackets) return;
|
||||
|
||||
for (const data of pendingPackets) {
|
||||
await callApi('sendSignalingData', { data, call: phoneCall });
|
||||
}
|
||||
} catch (error) {
|
||||
logPhoneCallDebug('Failed to send phone call signaling data', {
|
||||
error: summarizeError(error),
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
function logPhoneCallDebug(message: string, data: Record<string, unknown>) {
|
||||
if (!DEBUG_CALLS) return;
|
||||
|
||||
logDebugMessage('warn', `[PhoneCall] ${message}`, data);
|
||||
}
|
||||
|
||||
function summarizeError(error: unknown) {
|
||||
return error instanceof Error ? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
} : String(error);
|
||||
}
|
||||
|
||||
addActionHandler('closeCallRatingModal', (global, actions, payload): ActionReturnType => {
|
||||
const { tabId = getCurrentTabId() } = payload || {};
|
||||
return updateTabState(global, {
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import type { ApiPhoneCall } from '../../../api/types';
|
||||
import type { ApiCallProtocol } from '../../../lib/secret-sauce';
|
||||
import type { ApiPhoneCall, ApiPhoneCallCustomParameters } from '../../../api/types';
|
||||
import type { ApiCallProtocol } from '../../../lib/vibecalls';
|
||||
import type { ActionReturnType } from '../../types';
|
||||
|
||||
import { CALL_PROTOCOL_LIBRARY_VERSIONS, DEBUG_CALLS } from '../../../config';
|
||||
import {
|
||||
handleUpdateGroupCallConnection,
|
||||
handleUpdateGroupCallParticipants,
|
||||
joinPhoneCall, processSignalingMessage,
|
||||
} from '../../../lib/secret-sauce';
|
||||
joinPhoneCall, processSignalingMessage, sanitizePrimitiveRecord,
|
||||
} from '../../../lib/vibecalls';
|
||||
import { ARE_CALLS_SUPPORTED } from '../../../util/browser/windowEnvironment';
|
||||
import { logDebugMessage } from '../../../util/debugConsole';
|
||||
import { getCurrentTabId } from '../../../util/establishMultitabRole';
|
||||
import { omit } from '../../../util/iteratees';
|
||||
import * as langProvider from '../../../util/oldLangProvider';
|
||||
@ -18,6 +20,25 @@ import { updateGroupCall, updateGroupCallParticipant } from '../../reducers/call
|
||||
import { updateTabState } from '../../reducers/tabs';
|
||||
import { selectActiveGroupCall, selectGroupCallParticipant, selectPhoneCallUser } from '../../selectors/calls';
|
||||
|
||||
let phoneCallSignalingDataPromise = Promise.resolve();
|
||||
let groupCallNegotiationPromise = Promise.resolve();
|
||||
|
||||
type QueuedPhoneCallSignalingData = {
|
||||
callId?: string;
|
||||
data: number[];
|
||||
};
|
||||
|
||||
function enqueueGroupCallNegotiation(callback: () => Promise<void>) {
|
||||
groupCallNegotiationPromise = groupCallNegotiationPromise
|
||||
.catch(() => undefined)
|
||||
.then(callback)
|
||||
.catch((err) => {
|
||||
logPhoneCallDebug('Failed to process group call negotiation update', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
|
||||
const { activeGroupCallId } = global.groupCalls;
|
||||
|
||||
@ -48,7 +69,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
|
||||
case 'updateGroupCallParticipants': {
|
||||
const { groupCallId, participants } = update;
|
||||
if (activeGroupCallId === groupCallId) {
|
||||
void handleUpdateGroupCallParticipants(participants);
|
||||
enqueueGroupCallNegotiation(() => handleUpdateGroupCallParticipants(participants));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -58,12 +79,15 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
|
||||
if ('leaveGroupCall' in actions) actions.leaveGroupCall({ tabId: getCurrentTabId() });
|
||||
break;
|
||||
}
|
||||
void handleUpdateGroupCallConnection(update.data, update.presentation);
|
||||
enqueueGroupCallNegotiation(async () => {
|
||||
await handleUpdateGroupCallConnection(update.data, update.presentation);
|
||||
|
||||
const groupCall = selectActiveGroupCall(global);
|
||||
if (groupCall?.participants && Object.keys(groupCall.participants).length > 0) {
|
||||
void handleUpdateGroupCallParticipants(Object.values(groupCall.participants));
|
||||
}
|
||||
global = getGlobal();
|
||||
const groupCall = selectActiveGroupCall(global);
|
||||
if (groupCall?.participants && Object.keys(groupCall.participants).length > 0) {
|
||||
await handleUpdateGroupCallParticipants(Object.values(groupCall.participants));
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'updatePhoneCallMediaState':
|
||||
@ -128,51 +152,138 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
|
||||
}, getCurrentTabId());
|
||||
} else if (state === 'accepted' && accessHash && gB) {
|
||||
(async () => {
|
||||
const { gA, keyFingerprint, emojis } = await callApi('confirmPhoneCall', [gB, EMOJI_DATA, EMOJI_OFFSETS]);
|
||||
|
||||
global = getGlobal();
|
||||
const newCall = {
|
||||
...global.phoneCall,
|
||||
emojis,
|
||||
} as ApiPhoneCall;
|
||||
|
||||
global = {
|
||||
...global,
|
||||
phoneCall: newCall,
|
||||
};
|
||||
setGlobal(global);
|
||||
|
||||
callApi('confirmCall', {
|
||||
call, gA, keyFingerprint,
|
||||
});
|
||||
})();
|
||||
} else if (state === 'active' && connections && phoneCall?.state !== 'active') {
|
||||
if (!isOutgoing) {
|
||||
callApi('receivedCall', { call });
|
||||
(async () => {
|
||||
const { emojis } = await callApi('confirmPhoneCall', [call.gAOrB!, EMOJI_DATA, EMOJI_OFFSETS]);
|
||||
try {
|
||||
const activeCallId = call.id;
|
||||
const result = await callApi('confirmPhoneCall', [gB, EMOJI_DATA, EMOJI_OFFSETS]);
|
||||
if (!result) {
|
||||
logPhoneCallDebug('Failed to confirm accepted phone call', {
|
||||
callId: activeCallId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { gA, keyFingerprint, emojis } = result;
|
||||
|
||||
global = getGlobal();
|
||||
if (global.phoneCall?.id !== activeCallId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await callApi('confirmCall', {
|
||||
call, gA, keyFingerprint,
|
||||
});
|
||||
|
||||
global = getGlobal();
|
||||
if (global.phoneCall?.id !== activeCallId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newCall = {
|
||||
...global.phoneCall,
|
||||
emojis,
|
||||
} as ApiPhoneCall;
|
||||
};
|
||||
|
||||
global = {
|
||||
...global,
|
||||
phoneCall: newCall,
|
||||
};
|
||||
setGlobal(global);
|
||||
})();
|
||||
}
|
||||
void joinPhoneCall(
|
||||
connections,
|
||||
actions.sendSignalingData,
|
||||
isOutgoing,
|
||||
Boolean(call?.isVideo),
|
||||
Boolean(call.isP2pAllowed),
|
||||
actions.apiUpdate,
|
||||
);
|
||||
} catch (err) {
|
||||
logPhoneCallDebug('Failed to confirm accepted phone call', {
|
||||
callId: call.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
})();
|
||||
} else if (state === 'active' && connections && phoneCall?.state !== 'active') {
|
||||
(async () => {
|
||||
try {
|
||||
const activeCallId = call.id;
|
||||
let callConfigResult: Record<string, unknown> | undefined;
|
||||
try {
|
||||
callConfigResult = await callApi('fetchCallConfig');
|
||||
} catch (err) {
|
||||
logPhoneCallDebug('Failed to fetch phone call config', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
const callConfig = sanitizePrimitiveRecord(callConfigResult) || {};
|
||||
const customParameters: ApiPhoneCallCustomParameters = Object.assign(
|
||||
{},
|
||||
callConfig,
|
||||
call.customParameters,
|
||||
);
|
||||
call.customParameters = customParameters;
|
||||
global = getGlobal();
|
||||
if (global.phoneCall?.id === call.id) {
|
||||
global = {
|
||||
...global,
|
||||
phoneCall: {
|
||||
...global.phoneCall,
|
||||
customParameters,
|
||||
},
|
||||
};
|
||||
setGlobal(global);
|
||||
}
|
||||
|
||||
global = getGlobal();
|
||||
if (global.phoneCall?.id === call.id) {
|
||||
await callApi('setPhoneCallSctpEnabled', !customParameters.network_signaling_nosctp);
|
||||
}
|
||||
|
||||
if (!isOutgoing) {
|
||||
await callApi('receivedCall', { call });
|
||||
global = getGlobal();
|
||||
if (global.phoneCall?.id !== activeCallId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await callApi('confirmPhoneCall', [call.gAOrB!, EMOJI_DATA, EMOJI_OFFSETS]);
|
||||
if (!result) {
|
||||
logPhoneCallDebug('Failed to confirm phone call', {
|
||||
callId: activeCallId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { emojis } = result;
|
||||
|
||||
global = getGlobal();
|
||||
if (global.phoneCall?.id !== activeCallId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newCall = {
|
||||
...global.phoneCall,
|
||||
emojis,
|
||||
};
|
||||
|
||||
global = {
|
||||
...global,
|
||||
phoneCall: newCall,
|
||||
};
|
||||
setGlobal(global);
|
||||
}
|
||||
|
||||
global = getGlobal();
|
||||
if (global.phoneCall?.id !== activeCallId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await joinPhoneCall(
|
||||
connections,
|
||||
actions.sendSignalingData,
|
||||
isOutgoing,
|
||||
Boolean(call?.isVideo),
|
||||
Boolean(call.isP2pAllowed),
|
||||
actions.apiUpdate,
|
||||
);
|
||||
} catch (err) {
|
||||
logPhoneCallDebug('Failed to start phone call', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
callId: call.id,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return global;
|
||||
@ -202,7 +313,20 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
|
||||
break;
|
||||
}
|
||||
|
||||
callApi('decodePhoneCallData', [update.data])?.then(processSignalingMessage);
|
||||
const queued: QueuedPhoneCallSignalingData = {
|
||||
callId: phoneCall.id,
|
||||
data: update.data,
|
||||
};
|
||||
|
||||
phoneCallSignalingDataPromise = phoneCallSignalingDataPromise
|
||||
.then(() => processPhoneCallSignalingData(queued))
|
||||
.catch((err) => {
|
||||
logPhoneCallDebug('Failed to process phone call signaling data', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
isSctp: isSctpSignalingData(queued.data),
|
||||
length: queued.data.length,
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -211,7 +335,79 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
|
||||
});
|
||||
|
||||
function verifyPhoneCallProtocol(protocol?: ApiCallProtocol) {
|
||||
return protocol?.libraryVersions.some((version) => {
|
||||
return version === '4.0.0' || version === '4.0.1';
|
||||
});
|
||||
return Boolean(
|
||||
protocol
|
||||
&& CALL_PROTOCOL_LIBRARY_VERSIONS.some((version) => protocol.libraryVersions.includes(version)),
|
||||
);
|
||||
}
|
||||
|
||||
async function processPhoneCallSignalingData(queued: QueuedPhoneCallSignalingData) {
|
||||
const { data } = queued;
|
||||
let global = getGlobal();
|
||||
if (global.phoneCall?.id !== queued.callId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let message;
|
||||
try {
|
||||
message = await callApi('decodePhoneCallData', [data]);
|
||||
} catch (err) {
|
||||
logPhoneCallDebug('Failed to decode phone call signaling data', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
isSctp: isSctpSignalingData(data),
|
||||
length: data.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
global = getGlobal();
|
||||
const activeCall = global.phoneCall;
|
||||
if (activeCall?.id !== queued.callId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let packetCount = 0;
|
||||
if (activeCall) {
|
||||
try {
|
||||
const packets = await callApi('drainPhoneCallSignalingData');
|
||||
packetCount = packets?.length || 0;
|
||||
if (packets) {
|
||||
for (const packetData of packets) {
|
||||
await callApi('sendSignalingData', { data: packetData, call: activeCall });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logPhoneCallDebug('Failed to drain phone call signaling data', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
isSctp: isSctpSignalingData(data),
|
||||
length: data.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
for (const item of message) {
|
||||
await processSignalingMessage(item);
|
||||
}
|
||||
} else if (message) {
|
||||
await processSignalingMessage(message);
|
||||
} else if (!packetCount && !isSctpSignalingData(data)) {
|
||||
logPhoneCallDebug('Failed to decode phone call signaling data', {
|
||||
length: data.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function logPhoneCallDebug(message: string, data: Record<string, unknown>) {
|
||||
if (!DEBUG_CALLS) return;
|
||||
|
||||
logDebugMessage('warn', `[PhoneCall] ${message}`, data);
|
||||
}
|
||||
|
||||
function isSctpSignalingData(data: number[]) {
|
||||
return data.length >= 12
|
||||
&& data[0] === 0x13
|
||||
&& data[1] === 0x88
|
||||
&& data[2] === 0x13
|
||||
&& data[3] === 0x88;
|
||||
}
|
||||
|
||||
@ -54,7 +54,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
|
||||
const { groupCallId, participants, nextOffset } = update;
|
||||
const { currentUserId } = global;
|
||||
|
||||
// `secret-sauce` should disconnect if the participant is us but from another device
|
||||
// `vibecalls` should disconnect if the participant is us but from another device
|
||||
global = getGlobal();
|
||||
participants.forEach((participant) => {
|
||||
if (participant.id) {
|
||||
|
||||
@ -301,6 +301,7 @@ addActionHandler('joinGroupCall', async (global, actions, payload): Promise<void
|
||||
{
|
||||
...groupCall,
|
||||
inviteHash,
|
||||
localJoinAsId: undefined,
|
||||
},
|
||||
undefined,
|
||||
groupCall.participantsCount + 1,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ApiGroupCall } from '../../api/types';
|
||||
import type { GroupCallParticipant } from '../../lib/secret-sauce';
|
||||
import type { GroupCallParticipant } from '../../lib/vibecalls';
|
||||
import type { GlobalState } from '../types';
|
||||
|
||||
import { omit } from '../../util/iteratees';
|
||||
|
||||
@ -70,7 +70,7 @@ import type {
|
||||
import type { ApiCredentials } from '../../components/payment/PaymentModal';
|
||||
import type { FoldersActions } from '../../hooks/reducers/useFoldersReducer';
|
||||
import type { ReducerAction } from '../../hooks/useReducer';
|
||||
import type { P2pMessage } from '../../lib/secret-sauce';
|
||||
import type { P2pMessage } from '../../lib/vibecalls';
|
||||
import type {
|
||||
AccountSettings,
|
||||
AttachmentCompression,
|
||||
|
||||
@ -436,9 +436,11 @@ export default class MTProtoSender {
|
||||
const encryptedData = await this._state.encryptMessageData(data);
|
||||
|
||||
postMessage({
|
||||
type: 'sendBeacon',
|
||||
data: encryptedData,
|
||||
url: this._fallbackConnection.href,
|
||||
payloads: [{
|
||||
type: 'sendBeacon',
|
||||
data: encryptedData,
|
||||
url: this._fallbackConnection.href,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1933,6 +1933,7 @@ payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id
|
||||
payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes;
|
||||
payments.getCraftStarGifts#fd05dd00 gift_id:long offset:string limit:int = payments.SavedStarGifts;
|
||||
payments.craftStarGift#b0f9684f stargift:Vector<InputSavedStarGift> = Updates;
|
||||
phone.getCallConfig#55451fa9 = DataJSON;
|
||||
phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall;
|
||||
phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall;
|
||||
phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall;
|
||||
|
||||
@ -387,6 +387,7 @@
|
||||
"phone.toggleGroupCallStartSubscription",
|
||||
"phone.joinGroupCallPresentation",
|
||||
"phone.leaveGroupCallPresentation",
|
||||
"phone.getCallConfig",
|
||||
"phone.requestCall",
|
||||
"phone.acceptCall",
|
||||
"phone.confirmCall",
|
||||
|
||||
@ -1,506 +0,0 @@
|
||||
import { black, silence } from './blacksilence';
|
||||
import type { ApiPhoneCallConnection, P2pParsedSdp } from './types';
|
||||
import parseSdp from './parseSdp';
|
||||
import type { MediaContent, MediaStateMessage, P2pMessage } from './p2pMessage';
|
||||
import {
|
||||
fromTelegramSource,
|
||||
IS_ECHO_CANCELLATION_SUPPORTED,
|
||||
IS_NOISE_SUPPRESSION_SUPPORTED,
|
||||
p2pPayloadTypeToConference,
|
||||
} from './utils';
|
||||
import buildSdp, { Conference } from './buildSdp';
|
||||
import { StreamType } from './secretsauce';
|
||||
|
||||
type P2pState = {
|
||||
connection: RTCPeerConnection;
|
||||
dataChannel: RTCDataChannel;
|
||||
emitSignalingData: (data: P2pMessage) => void;
|
||||
onUpdate: (...args: any[]) => void;
|
||||
conference?: Partial<Conference>;
|
||||
isOutgoing: boolean;
|
||||
pendingCandidates: string[];
|
||||
streams: {
|
||||
video?: MediaStream;
|
||||
audio?: MediaStream;
|
||||
presentation?: MediaStream;
|
||||
ownAudio?: MediaStream;
|
||||
ownVideo?: MediaStream;
|
||||
ownPresentation?: MediaStream;
|
||||
};
|
||||
silence: MediaStream;
|
||||
blackVideo: MediaStream;
|
||||
blackPresentation: MediaStream;
|
||||
mediaState: Omit<MediaStateMessage, '@type'>;
|
||||
audio: HTMLAudioElement;
|
||||
gotInitialSetup?: boolean;
|
||||
facingMode?: VideoFacingModeEnum;
|
||||
};
|
||||
|
||||
let state: P2pState | undefined;
|
||||
|
||||
const ICE_CANDIDATE_POOL_SIZE = 10;
|
||||
|
||||
export function getStreams() {
|
||||
return state?.streams;
|
||||
}
|
||||
|
||||
function updateStreams() {
|
||||
state?.onUpdate({
|
||||
...state.mediaState,
|
||||
'@type': 'updatePhoneCallMediaState',
|
||||
});
|
||||
}
|
||||
|
||||
function getUserStream(streamType: StreamType, facing: VideoFacingModeEnum = 'user') {
|
||||
if (streamType === 'presentation') {
|
||||
return (navigator.mediaDevices as any).getDisplayMedia({
|
||||
audio: false,
|
||||
video: true,
|
||||
});
|
||||
}
|
||||
|
||||
return navigator.mediaDevices.getUserMedia({
|
||||
audio: streamType === 'audio' ? {
|
||||
...(IS_ECHO_CANCELLATION_SUPPORTED && { echoCancellation: true }),
|
||||
...(IS_NOISE_SUPPRESSION_SUPPORTED && { noiseSuppression: true }),
|
||||
} : false,
|
||||
video: streamType === 'video' ? {
|
||||
facingMode: facing,
|
||||
} : false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function switchCameraInputP2p() {
|
||||
if (!state || !state.facingMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = state.streams.ownVideo;
|
||||
|
||||
if (!stream) return;
|
||||
|
||||
const track = stream.getTracks()[0];
|
||||
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sender = state.connection.getSenders().find((l) => track.id === l.track?.id);
|
||||
|
||||
if (!sender) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.facingMode = state.facingMode === 'environment' ? 'user' : 'environment';
|
||||
try {
|
||||
const newStream = await getUserStream('video', state.facingMode);
|
||||
|
||||
await sender.replaceTrack(newStream.getTracks()[0]);
|
||||
state.streams.ownVideo = newStream;
|
||||
updateStreams();
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleStreamP2p(streamType: StreamType, value: boolean | undefined = undefined) {
|
||||
if (!state) return;
|
||||
const stream = streamType === 'audio' ? state.streams.ownAudio
|
||||
: (streamType === 'video' ? state.streams.ownVideo : state.streams.ownPresentation);
|
||||
|
||||
if (!stream) return;
|
||||
const track = stream.getTracks()[0];
|
||||
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sender = state.connection.getSenders().find((l) => track.id === l.track?.id);
|
||||
|
||||
if (!sender) {
|
||||
return;
|
||||
}
|
||||
|
||||
value = value === undefined ? !track.enabled : value;
|
||||
|
||||
try {
|
||||
if (value && !track.enabled) {
|
||||
const newStream = await getUserStream(streamType);
|
||||
newStream.getTracks()[0].onended = () => {
|
||||
toggleStreamP2p(streamType, false);
|
||||
};
|
||||
await sender.replaceTrack(newStream.getTracks()[0]);
|
||||
if (streamType === 'audio') {
|
||||
state.streams.ownAudio = newStream;
|
||||
} else if (streamType === 'video') {
|
||||
state.streams.ownVideo = newStream;
|
||||
state.facingMode = 'user';
|
||||
} else {
|
||||
state.streams.ownPresentation = newStream;
|
||||
}
|
||||
if (streamType === 'video' || streamType === 'presentation') {
|
||||
toggleStreamP2p(streamType === 'video' ? 'presentation' : 'video', false);
|
||||
}
|
||||
// if (streamType === 'video') {
|
||||
// state.facingMode = 'user';
|
||||
// }
|
||||
} else if (!value && track.enabled) {
|
||||
track.stop();
|
||||
const newStream = streamType === 'audio' ? state.silence
|
||||
: (streamType === 'video' ? state.blackVideo : state.blackPresentation);
|
||||
if (!newStream) return;
|
||||
|
||||
await sender.replaceTrack(newStream.getTracks()[0]);
|
||||
|
||||
if (streamType === 'audio') {
|
||||
state.streams.ownAudio = newStream;
|
||||
} else if (streamType === 'video') {
|
||||
state.streams.ownVideo = newStream;
|
||||
} else {
|
||||
state.streams.ownPresentation = newStream;
|
||||
}
|
||||
// if (streamType === 'video') {
|
||||
// state.facingMode = undefined;
|
||||
// }
|
||||
}
|
||||
updateStreams();
|
||||
sendMediaState();
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function joinPhoneCall(
|
||||
connections: ApiPhoneCallConnection[],
|
||||
emitSignalingData: (data: P2pMessage) => void,
|
||||
isOutgoing: boolean,
|
||||
shouldStartVideo: boolean,
|
||||
isP2p: boolean,
|
||||
onUpdate: (...args: any[]) => void,
|
||||
) {
|
||||
const conn = new RTCPeerConnection({
|
||||
iceServers: connections.map((connection) => (
|
||||
{
|
||||
urls: [
|
||||
connection.isTurn && `turn:${connection.ip}:${connection.port}`,
|
||||
connection.isStun && `stun:${connection.ip}:${connection.port}`,
|
||||
].filter(Boolean),
|
||||
username: connection.username,
|
||||
credentialType: 'password',
|
||||
credential: connection.password,
|
||||
}
|
||||
)),
|
||||
iceTransportPolicy: isP2p ? 'all' : 'relay',
|
||||
bundlePolicy: 'max-bundle',
|
||||
iceCandidatePoolSize: ICE_CANDIDATE_POOL_SIZE,
|
||||
});
|
||||
|
||||
conn.onicecandidate = (e) => {
|
||||
if (!e.candidate) {
|
||||
return;
|
||||
};
|
||||
emitSignalingData({
|
||||
'@type': 'Candidates',
|
||||
candidates: [{
|
||||
sdpString: e.candidate.candidate,
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
conn.onconnectionstatechange = () => {
|
||||
onUpdate({
|
||||
'@type': 'updatePhoneCallConnectionState',
|
||||
connectionState: conn.connectionState,
|
||||
});
|
||||
};
|
||||
|
||||
conn.ontrack = (e) => {
|
||||
if (!state) return;
|
||||
|
||||
const stream = e.streams[0];
|
||||
if (e.track.kind === 'audio') {
|
||||
state.audio.srcObject = stream;
|
||||
state.audio.play().catch();
|
||||
state.streams.audio = stream;
|
||||
} else if (e.transceiver.mid === '1') {
|
||||
state.streams.video = stream;
|
||||
} else {
|
||||
state.streams.presentation = stream;
|
||||
}
|
||||
|
||||
updateStreams();
|
||||
};
|
||||
|
||||
conn.oniceconnectionstatechange = async (e) => {
|
||||
switch(conn.iceConnectionState) {
|
||||
case 'disconnected':
|
||||
case 'failed':
|
||||
if (isOutgoing) {
|
||||
await createOffer(conn, {
|
||||
offerToReceiveAudio: true,
|
||||
offerToReceiveVideo: true,
|
||||
iceRestart: true,
|
||||
});
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const slnc = silence(new AudioContext());
|
||||
const video = black({ width: 640, height: 480 });
|
||||
const screenshare = black({ width: 640, height: 480 });
|
||||
conn.addTrack(slnc.getTracks()[0], slnc);
|
||||
conn.addTrack(video.getTracks()[0], video);
|
||||
conn.addTrack(screenshare.getTracks()[0], screenshare);
|
||||
|
||||
const dc = conn.createDataChannel('data', {
|
||||
id: 0,
|
||||
negotiated: true,
|
||||
});
|
||||
|
||||
dc.onmessage = (e) => {
|
||||
processSignalingMessage(JSON.parse(e.data));
|
||||
};
|
||||
|
||||
const audio = new Audio();
|
||||
|
||||
state = {
|
||||
audio,
|
||||
connection: conn,
|
||||
emitSignalingData,
|
||||
isOutgoing,
|
||||
pendingCandidates: [],
|
||||
onUpdate,
|
||||
streams: {
|
||||
ownVideo: video,
|
||||
ownAudio: slnc,
|
||||
ownPresentation: screenshare,
|
||||
},
|
||||
mediaState: {
|
||||
isBatteryLow: false,
|
||||
screencastState: 'inactive',
|
||||
videoState: 'inactive',
|
||||
videoRotation: 0,
|
||||
isMuted: true,
|
||||
},
|
||||
blackVideo: video,
|
||||
blackPresentation: screenshare,
|
||||
silence: slnc,
|
||||
dataChannel: dc,
|
||||
};
|
||||
|
||||
try {
|
||||
toggleStreamP2p('audio', true);
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
|
||||
if (isOutgoing) {
|
||||
await createOffer(conn, {
|
||||
offerToReceiveAudio: true,
|
||||
offerToReceiveVideo: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function stopPhoneCall() {
|
||||
if (!state) return;
|
||||
|
||||
state.streams.ownVideo?.getTracks().forEach((track) => track.stop());
|
||||
state.streams.ownPresentation?.getTracks().forEach((track) => track.stop());
|
||||
state.streams.ownAudio?.getTracks().forEach((track) => track.stop());
|
||||
state.dataChannel.close();
|
||||
state.connection.close();
|
||||
state = undefined;
|
||||
}
|
||||
|
||||
function sendMediaState() {
|
||||
if (!state) return;
|
||||
const { emitSignalingData, streams } = state;
|
||||
|
||||
emitSignalingData({
|
||||
'@type': 'MediaState',
|
||||
videoRotation: 0,
|
||||
isMuted: !streams.ownAudio?.getTracks()[0].enabled,
|
||||
isBatteryLow: true,
|
||||
videoState: streams.ownVideo?.getTracks()[0].enabled ? 'active' : 'inactive',
|
||||
screencastState: streams.ownPresentation?.getTracks()[0].enabled ? 'active' : 'inactive',
|
||||
});
|
||||
}
|
||||
|
||||
function filterVP8(mediaContent: MediaContent) {
|
||||
if (!state || state.isOutgoing) return mediaContent;
|
||||
|
||||
const { payloadTypes } = mediaContent!;
|
||||
const idx = payloadTypes.findIndex((payloadType) => payloadType.name === 'VP8');
|
||||
const vp8PayloadType = payloadTypes[idx];
|
||||
const rtxIdx = payloadTypes.findIndex((payloadType) => Number(payloadType.parameters?.apt) === vp8PayloadType.id);
|
||||
mediaContent.payloadTypes = [payloadTypes[idx], payloadTypes[rtxIdx]];
|
||||
|
||||
return mediaContent;
|
||||
}
|
||||
|
||||
function sendInitialSetup(sdp: P2pParsedSdp) {
|
||||
if (!state) return;
|
||||
const { emitSignalingData } = state;
|
||||
|
||||
if (!sdp.ssrc || !sdp['ssrc-groups'] || !sdp['ssrc-groups'][0] || !sdp['ssrc-groups'][1]) return;
|
||||
|
||||
emitSignalingData({
|
||||
'@type': 'InitialSetup',
|
||||
fingerprints: sdp.fingerprints,
|
||||
ufrag: sdp.ufrag,
|
||||
pwd: sdp.pwd,
|
||||
audio: {
|
||||
ssrc: fromTelegramSource(sdp.ssrc).toString(),
|
||||
ssrcGroups: [],
|
||||
payloadTypes: sdp.audioPayloadTypes,
|
||||
rtpExtensions: sdp.audioExtmap,
|
||||
},
|
||||
video: filterVP8({
|
||||
ssrc: fromTelegramSource(sdp['ssrc-groups'][0].sources[0]).toString(),
|
||||
ssrcGroups: [{
|
||||
semantics: sdp['ssrc-groups'][0].semantics,
|
||||
ssrcs: sdp['ssrc-groups'][0].sources.map(fromTelegramSource),
|
||||
}],
|
||||
payloadTypes: sdp.videoPayloadTypes,
|
||||
rtpExtensions: sdp.videoExtmap,
|
||||
}),
|
||||
screencast: filterVP8({
|
||||
ssrc: fromTelegramSource(sdp['ssrc-groups'][1].sources[0]).toString(),
|
||||
ssrcGroups: [{
|
||||
semantics: sdp['ssrc-groups'][1].semantics,
|
||||
ssrcs: sdp['ssrc-groups'][1].sources.map(fromTelegramSource),
|
||||
}],
|
||||
payloadTypes: sdp.screencastPayloadTypes,
|
||||
rtpExtensions: sdp.screencastExtmap,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function processSignalingMessage(message: P2pMessage) {
|
||||
if (!state || !state.connection) return;
|
||||
|
||||
switch (message['@type']) {
|
||||
case 'MediaState': {
|
||||
state.mediaState = message;
|
||||
updateStreams();
|
||||
sendMediaState();
|
||||
break;
|
||||
}
|
||||
case 'Candidates': {
|
||||
const { pendingCandidates, gotInitialSetup } = state;
|
||||
message.candidates.forEach((candidate) => {
|
||||
pendingCandidates.push(candidate.sdpString);
|
||||
});
|
||||
if (gotInitialSetup) {
|
||||
await commitPendingIceCandidates();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'InitialSetup': {
|
||||
const {
|
||||
connection, isOutgoing,
|
||||
} = state;
|
||||
if (!connection) return;
|
||||
|
||||
const newConference = {
|
||||
transport: {
|
||||
candidates: [],
|
||||
ufrag: message.ufrag,
|
||||
pwd: message.pwd,
|
||||
fingerprints: message.fingerprints,
|
||||
'rtcp-mux': false,
|
||||
xmlns: '',
|
||||
},
|
||||
sessionId: Date.now(),
|
||||
ssrcs: [
|
||||
message.audio && {
|
||||
isVideo: false,
|
||||
isMain: false,
|
||||
userId: '123',
|
||||
endpoint: '0',
|
||||
mid: '0',
|
||||
sourceGroups: [{
|
||||
sources: [message.audio.ssrc],
|
||||
}],
|
||||
},
|
||||
message.video && {
|
||||
isVideo: true,
|
||||
isPresentation: false,
|
||||
isMain: false,
|
||||
userId: '123',
|
||||
endpoint: '1',
|
||||
mid: '1',
|
||||
sourceGroups: message.video.ssrcGroups.map((l) => ({
|
||||
semantics: l.semantics,
|
||||
sources: l.ssrcs,
|
||||
})),
|
||||
},
|
||||
message.screencast && {
|
||||
isVideo: true,
|
||||
isPresentation: true,
|
||||
isMain: false,
|
||||
userId: '123',
|
||||
endpoint: '2',
|
||||
mid: '2',
|
||||
sourceGroups: message.screencast.ssrcGroups.map((l) => ({
|
||||
semantics: l.semantics,
|
||||
sources: l.ssrcs,
|
||||
})),
|
||||
},
|
||||
],
|
||||
audioPayloadTypes: message.audio!.payloadTypes?.map(p2pPayloadTypeToConference) || [],
|
||||
audioExtensions: message.audio!.rtpExtensions,
|
||||
videoPayloadTypes: filterVP8(message.video!).payloadTypes?.map(p2pPayloadTypeToConference) || [],
|
||||
videoExtensions: message.video!.rtpExtensions,
|
||||
} as Conference;
|
||||
|
||||
await connection.setRemoteDescription({
|
||||
sdp: buildSdp(newConference, isOutgoing, undefined, true),
|
||||
type: isOutgoing ? 'answer' : 'offer',
|
||||
});
|
||||
|
||||
state.conference = newConference;
|
||||
|
||||
if (!isOutgoing) {
|
||||
const answer = await connection.createAnswer();
|
||||
await connection.setLocalDescription(answer);
|
||||
sendInitialSetup(parseSdp(connection.localDescription!, true) as P2pParsedSdp);
|
||||
}
|
||||
state.gotInitialSetup = true;
|
||||
await commitPendingIceCandidates();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function commitPendingIceCandidates() {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const { pendingCandidates, connection } = state;
|
||||
if (!pendingCandidates.length) {
|
||||
return;
|
||||
}
|
||||
await Promise.all(pendingCandidates.map((c) => tryAddCandidate(connection, c)));
|
||||
state.pendingCandidates = [];
|
||||
}
|
||||
|
||||
async function tryAddCandidate(connection: RTCPeerConnection, candidate: string) {
|
||||
try {
|
||||
await connection.addIceCandidate({
|
||||
candidate,
|
||||
sdpMLineIndex: 0,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function createOffer(conn: RTCPeerConnection, params: RTCOfferOptions) {
|
||||
const offer = await conn.createOffer(params);
|
||||
await conn.setLocalDescription(offer);
|
||||
sendInitialSetup(parseSdp(conn.localDescription!, true) as P2pParsedSdp);
|
||||
}
|
||||
@ -1,140 +0,0 @@
|
||||
import { toTelegramSource } from './utils';
|
||||
import type { JoinGroupCallPayload, SsrcGroup } from './types';
|
||||
|
||||
export default (sessionDescription: RTCSessionDescriptionInit, isP2p = false): JoinGroupCallPayload => {
|
||||
if (!sessionDescription || !sessionDescription.sdp) {
|
||||
throw Error('Failed parsing SDP: session description is null');
|
||||
}
|
||||
|
||||
const sections = sessionDescription
|
||||
.sdp
|
||||
.split('\r\nm=')
|
||||
.map((s, i) => (i === 0 ? s : `m=${s}`))
|
||||
.reduce((acc: Record<string, string[]>, el) => {
|
||||
const name = el.match(/^m=(.+?)\s/)?.[1] || 'header';
|
||||
acc[acc.hasOwnProperty(name) && name === 'video' ? 'screencast' : name] = el.split('\r\n').filter(Boolean);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const lookup = (prefix: string, sectionName?: string) => {
|
||||
if (!sectionName) {
|
||||
return Object.values(sections).map((section) => {
|
||||
return section.find((line) => line.startsWith(prefix))?.substr(prefix.length);
|
||||
}).filter(Boolean)[0];
|
||||
} else {
|
||||
return sections[sectionName]?.find((line) => line.startsWith(prefix))?.substr(prefix.length);
|
||||
}
|
||||
};
|
||||
|
||||
const parseExtmaps = (sectionName: string) => {
|
||||
return sections[sectionName].filter((l) => l.startsWith('a=extmap')).map((l) => {
|
||||
const [, id, uri] = l.match(/extmap:(\d+)(?:\/.+)?\s(.+)/)!;
|
||||
return { id: Number(id), uri };
|
||||
});
|
||||
};
|
||||
|
||||
const parsePayloadTypes = (sectionName: string) => {
|
||||
const payloads = sections[sectionName].filter((l) => l.startsWith('a=rtpmap')).map((l) => {
|
||||
const [, id, data] = l.match(/:(\d+)\s(.+)/) || [];
|
||||
const [name, clockrate, channels] = data.split('/');
|
||||
return {
|
||||
id: Number(id), name, clockrate: Number(clockrate), ...(channels && { channels: Number(channels) }),
|
||||
};
|
||||
});
|
||||
|
||||
const fbParams = sections[sectionName].filter((l) => l.startsWith('a=rtcp-fb')).map((l) => {
|
||||
const [, id, data] = l.match(/:(\d+)\s(.+)/) || [];
|
||||
const [type, subtype] = data.split(' ');
|
||||
return { id: Number(id), type, subtype: subtype || '' };
|
||||
});
|
||||
|
||||
const parameters = sections[sectionName].filter((l) => l.startsWith('a=fmtp')).map((l) => {
|
||||
const [, id, data] = l.match(/:(\d+)\s(.+)/) || [];
|
||||
const d = data?.split(';').reduce((acc: Record<string, string>, q) => {
|
||||
const [name, value] = q.split('=');
|
||||
acc[name] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
if (!d || Object.values(d).some((z) => !z)) return undefined;
|
||||
return { id: Number(id), data: d };
|
||||
}).filter(Boolean);
|
||||
|
||||
return payloads.map((payload) => {
|
||||
const p = parameters.filter((l) => l!.id === payload.id).map((q) => q!.data).reduce((acc, el) => {
|
||||
return Object.assign(acc, el);
|
||||
}, {});
|
||||
const f = fbParams.filter((l) => l.id === payload.id).map((l) => {
|
||||
return {
|
||||
type: l.type,
|
||||
subtype: l.subtype,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...payload,
|
||||
...(Object.keys(p).length > 0 && { parameters: p }),
|
||||
...(f.length > 0 && { feedbackTypes: f }),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const rawSource = lookup('a=ssrc:', 'audio');
|
||||
const sourceAudio = rawSource && Number(rawSource.split(' ')[0]);
|
||||
|
||||
// TODO multiple source groups
|
||||
const rawSourceVideo = lookup('a=ssrc-group:', 'video')?.split(' ') || undefined;
|
||||
const rawSourceScreencast = lookup('a=ssrc-group:', 'screencast')?.split(' ') || undefined;
|
||||
|
||||
if (!rawSourceVideo) {
|
||||
throw Error('Failed parsing SDP: no video ssrc');
|
||||
}
|
||||
|
||||
const [hash, fingerprint] = lookup('a=fingerprint:')?.split(' ') || [];
|
||||
|
||||
const setup = lookup('a=setup:');
|
||||
if (!hash || !fingerprint) {
|
||||
throw Error('Failed parsing SDP: no fingerprint');
|
||||
}
|
||||
|
||||
console.log(sections);
|
||||
|
||||
const ufrag = lookup('a=ice-ufrag:');
|
||||
const pwd = lookup('a=ice-pwd:');
|
||||
|
||||
if (!ufrag || !pwd) {
|
||||
throw Error('Failed parsing SDP: no ICE ufrag or pwd');
|
||||
}
|
||||
|
||||
return {
|
||||
fingerprints: [
|
||||
{
|
||||
fingerprint,
|
||||
hash,
|
||||
setup: isP2p ? setup! : 'active',
|
||||
},
|
||||
],
|
||||
pwd,
|
||||
ufrag,
|
||||
...(sourceAudio && { ssrc: toTelegramSource(sourceAudio) }),
|
||||
...(rawSourceVideo && {
|
||||
'ssrc-groups': [
|
||||
{
|
||||
semantics: rawSourceVideo[0],
|
||||
sources: rawSourceVideo.slice(1, rawSourceVideo.length).map(Number).map(toTelegramSource),
|
||||
},
|
||||
(isP2p && rawSourceScreencast && {
|
||||
semantics: rawSourceScreencast[0],
|
||||
sources: rawSourceScreencast.slice(1, rawSourceScreencast.length).map(Number).map(toTelegramSource),
|
||||
}),
|
||||
].filter(Boolean) as SsrcGroup[],
|
||||
}),
|
||||
...(isP2p && {
|
||||
audioExtmap: parseExtmaps('audio'),
|
||||
videoExtmap: parseExtmaps('video'),
|
||||
screencastExtmap: parseExtmaps('screencast'),
|
||||
audioPayloadTypes: parsePayloadTypes('audio'),
|
||||
videoPayloadTypes: parsePayloadTypes('video'),
|
||||
screencastPayloadTypes: parsePayloadTypes('screencast'),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@ -39,7 +39,7 @@ export type ReceiverVideoConstraints = {
|
||||
onStageEndpoints: string[];
|
||||
};
|
||||
|
||||
export type ColibriClass = (
|
||||
export type GroupCallDataChannelMessage = (
|
||||
LastNEndpointsChangeEvent | DebugMessage | EndpointConnectivityStatusChangeEvent |
|
||||
SenderVideoConstraints | DominantSpeakerEndpointChangeEvent | ReceiverVideoConstraints
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,12 +3,13 @@ export {
|
||||
getDevices, getUserStreams, setVolume, isStreamEnabled, toggleStream,
|
||||
leaveGroupCall, handleUpdateGroupCallParticipants, switchCameraInput,
|
||||
toggleSpeaker, toggleNoiseSuppression,
|
||||
} from './secretsauce';
|
||||
} from './group/groupCall';
|
||||
export {
|
||||
joinPhoneCall, processSignalingMessage, getStreams, toggleStreamP2p, stopPhoneCall, switchCameraInputP2p,
|
||||
} from './p2p';
|
||||
export * from './p2pMessage';
|
||||
} from './phone/phoneCall';
|
||||
export * from './phone/signalingMessages';
|
||||
export {
|
||||
IS_SCREENSHARE_SUPPORTED, THRESHOLD,
|
||||
IS_SCREENSHARE_SUPPORTED, sanitizePrimitiveRecord, THRESHOLD,
|
||||
} from './utils';
|
||||
export type { PrimitiveRecord, PrimitiveRecordValue } from './utils';
|
||||
export * from './types';
|
||||
2195
src/lib/vibecalls/phone/phoneCall.ts
Normal file
2195
src/lib/vibecalls/phone/phoneCall.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
import type {
|
||||
Fingerprint, RTCPFeedbackParam, RTPExtension,
|
||||
} from './types';
|
||||
} from '../types';
|
||||
|
||||
export type VideoState = 'inactive' | 'active' | 'suspended';
|
||||
|
||||
@ -8,15 +8,17 @@ export type VideoRotation = 0 | 90 | 180 | 270;
|
||||
|
||||
export type MediaStateMessage = {
|
||||
'@type': 'MediaState';
|
||||
isMuted: boolean;
|
||||
muted: boolean;
|
||||
videoState: VideoState;
|
||||
videoRotation: VideoRotation;
|
||||
screencastState: VideoState;
|
||||
isBatteryLow: boolean;
|
||||
lowBattery: boolean;
|
||||
};
|
||||
|
||||
type CandidatesMessage = {
|
||||
'@type': 'Candidates';
|
||||
exchangeId?: string;
|
||||
ufrag?: string;
|
||||
candidates: P2pCandidate[];
|
||||
};
|
||||
|
||||
@ -24,17 +26,22 @@ export type InitialSetupMessage = {
|
||||
'@type': 'InitialSetup';
|
||||
ufrag: string;
|
||||
pwd: string;
|
||||
renomination: boolean;
|
||||
fingerprints: Fingerprint[];
|
||||
audio?: MediaContent;
|
||||
video?: MediaContent;
|
||||
screencast?: MediaContent;
|
||||
};
|
||||
|
||||
export type MediaContent = {
|
||||
type: 'audio' | 'video';
|
||||
ssrc: string;
|
||||
ssrcGroups: P2pSsrcGroup[];
|
||||
payloadTypes: P2PPayloadType[];
|
||||
rtpExtensions: RTPExtension[];
|
||||
ssrcGroups?: P2pSsrcGroup[];
|
||||
payloadTypes?: P2PPayloadType[];
|
||||
rtpExtensions?: RTPExtension[];
|
||||
};
|
||||
|
||||
export type NegotiateChannelsMessage = {
|
||||
'@type': 'NegotiateChannels';
|
||||
exchangeId: string;
|
||||
contents: MediaContent[];
|
||||
};
|
||||
|
||||
export interface P2PPayloadType {
|
||||
@ -53,6 +60,9 @@ type P2pSsrcGroup = {
|
||||
|
||||
type P2pCandidate = {
|
||||
sdpString: string;
|
||||
sdpMid?: string;
|
||||
sdpMLineIndex?: number;
|
||||
usernameFragment?: string;
|
||||
};
|
||||
|
||||
export type P2pMessage = CandidatesMessage | InitialSetupMessage | MediaStateMessage;
|
||||
export type P2pMessage = CandidatesMessage | InitialSetupMessage | MediaStateMessage | NegotiateChannelsMessage;
|
||||
@ -1,7 +1,8 @@
|
||||
import type {
|
||||
Candidate, GroupCallTransport, PayloadType, RTPExtension, SsrcGroup,
|
||||
} from './types';
|
||||
import { fromTelegramSource } from './utils';
|
||||
} from '../types';
|
||||
|
||||
import { fromTelegramSource } from '../utils';
|
||||
|
||||
export type Conference = {
|
||||
sessionId: number;
|
||||
@ -77,7 +78,9 @@ export default (conference: Conference, isAnswer = false, isPresentation = false
|
||||
add(`a=ice-pwd:${pwd}`);
|
||||
fingerprints.forEach((fingerprint) => {
|
||||
add(`a=fingerprint:${fingerprint.hash} ${fingerprint.fingerprint}`);
|
||||
add(`a=setup:${isP2p ? (fingerprint.setup) : 'passive'}`);
|
||||
const setup = isAnswer && fingerprint.setup !== 'active' && fingerprint.setup !== 'passive'
|
||||
? 'passive' : fingerprint.setup || 'passive';
|
||||
add(`a=setup:${setup}`);
|
||||
});
|
||||
|
||||
candidates.forEach(addCandidate);
|
||||
@ -93,7 +96,7 @@ export default (conference: Conference, isAnswer = false, isPresentation = false
|
||||
|
||||
if (parameters) {
|
||||
const parametersString = Object.keys(parameters).map((key) => {
|
||||
return `${key}=${parameters![key]};`;
|
||||
return `${key}=${parameters[key]};`;
|
||||
}).join(' ');
|
||||
|
||||
add(`a=fmtp:${id} ${parametersString}`);
|
||||
@ -108,7 +111,7 @@ export default (conference: Conference, isAnswer = false, isPresentation = false
|
||||
const payloadTypes = entry.isVideo ? videoPayloadTypes : audioPayloadTypes;
|
||||
|
||||
const type = entry.isVideo ? 'video' : 'audio';
|
||||
add(`m=${type} ${entry.isMain ? 1 : 0} RTP/SAVPF ${payloadTypes.map((l) => l.id).join(' ')}`);
|
||||
add(`m=${type} ${entry.isRemoved ? 0 : 1} RTP/SAVPF ${payloadTypes.map((l) => l.id).join(' ')}`);
|
||||
add('c=IN IP4 0.0.0.0');
|
||||
add('b=AS:1300'); // 1300000 / 1000
|
||||
add(`a=mid:${entry.mid}`);
|
||||
@ -162,9 +165,9 @@ export default (conference: Conference, isAnswer = false, isPresentation = false
|
||||
};
|
||||
|
||||
if (!isP2p) {
|
||||
ssrcs.filter((ssrc) => ssrc.mid === '0' || ssrc.mid === '1').map(addSsrcEntry);
|
||||
ssrcs.filter((ssrc) => ssrc.mid === '0' || ssrc.mid === '1').forEach(addSsrcEntry);
|
||||
} else {
|
||||
ssrcs.filter(addSsrcEntry);
|
||||
ssrcs.forEach(addSsrcEntry);
|
||||
}
|
||||
|
||||
if (!isPresentation) {
|
||||
@ -178,7 +181,7 @@ export default (conference: Conference, isAnswer = false, isPresentation = false
|
||||
}
|
||||
|
||||
if (!isP2p) {
|
||||
ssrcs.filter((ssrc) => ssrc.mid !== '0' && ssrc.mid !== '1').map(addSsrcEntry);
|
||||
ssrcs.filter((ssrc) => ssrc.mid !== '0' && ssrc.mid !== '1').forEach(addSsrcEntry);
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
231
src/lib/vibecalls/sdp/common.ts
Normal file
231
src/lib/vibecalls/sdp/common.ts
Normal file
@ -0,0 +1,231 @@
|
||||
import type { P2PPayloadType } from '../phone/signalingMessages';
|
||||
import type {
|
||||
Fingerprint, RTPExtension,
|
||||
} from '../types';
|
||||
|
||||
export type SdpSection = {
|
||||
kind: string;
|
||||
lines: string[];
|
||||
mid?: string;
|
||||
};
|
||||
|
||||
type SdpSummarySection = {
|
||||
kind: string;
|
||||
mid?: string;
|
||||
port: number;
|
||||
direction?: string;
|
||||
payloads: string[];
|
||||
ssrcs: number[];
|
||||
};
|
||||
|
||||
export function parseSdpSections(sdp: string) {
|
||||
return sdp
|
||||
.split(/\r?\nm=/)
|
||||
.map((section, index) => index === 0 ? section : `m=${section}`)
|
||||
.map((section): SdpSection => {
|
||||
const lines = section.split(/\r?\n/).filter(Boolean);
|
||||
const kind = lines[0]?.match(/^m=([^\s]+)/)?.[1] || 'session';
|
||||
const mid = lines.find((line) => line.startsWith('a=mid:'))?.slice('a=mid:'.length);
|
||||
return { kind, lines, mid };
|
||||
});
|
||||
}
|
||||
|
||||
export function parseBundleMids(sdp: string) {
|
||||
const line = sdp.split(/\r?\n/).find((item) => item.startsWith('a=group:BUNDLE '));
|
||||
return line?.slice('a=group:BUNDLE '.length).split(' ').filter(Boolean);
|
||||
}
|
||||
|
||||
export function findSdpLineValue(sections: SdpSection[], prefix: string, section?: SdpSection) {
|
||||
if (section) {
|
||||
const sectionLine = section.lines.find((line) => line.startsWith(prefix));
|
||||
if (sectionLine) {
|
||||
return sectionLine.slice(prefix.length);
|
||||
}
|
||||
|
||||
for (const item of sections) {
|
||||
if (item.kind !== 'session') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionLine = item.lines.find((line) => line.startsWith(prefix));
|
||||
if (sessionLine) {
|
||||
return sessionLine.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const item of sections) {
|
||||
const value = item.lines.find((line) => line.startsWith(prefix))?.slice(prefix.length);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getSdpDirection(section: SdpSection) {
|
||||
return section.lines.find((line) => {
|
||||
return line === 'a=sendrecv' || line === 'a=sendonly' || line === 'a=recvonly' || line === 'a=inactive';
|
||||
})?.slice(2);
|
||||
}
|
||||
|
||||
export function getSdpPort(section: SdpSection) {
|
||||
return Number(section.lines[0]?.split(' ')[1] || 0);
|
||||
}
|
||||
|
||||
export function parseFingerprints(sections: SdpSection[]): Fingerprint[] {
|
||||
const values = new Map<string, Fingerprint>();
|
||||
|
||||
sections.forEach((section) => {
|
||||
const fingerprint = findSdpLineValue(sections, 'a=fingerprint:', section);
|
||||
if (!fingerprint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [hash, value] = fingerprint.split(' ');
|
||||
const setup = findSdpLineValue(sections, 'a=setup:', section) || 'actpass';
|
||||
if (!hash || !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
values.set(`${hash}:${value}:${setup}`, {
|
||||
hash,
|
||||
setup,
|
||||
fingerprint: value,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(values.values());
|
||||
}
|
||||
|
||||
export function parseSsrcGroups(section: SdpSection) {
|
||||
return section.lines.filter((line) => line.startsWith('a=ssrc-group:')).map((line) => {
|
||||
const [, value] = line.split(':');
|
||||
const [semantics, ...ssrcs] = value.split(' ');
|
||||
return {
|
||||
semantics,
|
||||
ssrcs: ssrcs.map(Number),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function parseSsrcs(section: SdpSection, shouldIncludeGroups = false) {
|
||||
const values = new Set<number>();
|
||||
|
||||
section.lines.forEach((line) => {
|
||||
if (shouldIncludeGroups && line.startsWith('a=ssrc-group:')) {
|
||||
line.match(/\d+/g)?.forEach((value) => {
|
||||
values.add(Number(value));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const match = line.match(/^a=ssrc:(\d+)/);
|
||||
if (!match?.[1]) {
|
||||
return;
|
||||
}
|
||||
|
||||
values.add(Number(match[1]));
|
||||
});
|
||||
|
||||
return Array.from(values);
|
||||
}
|
||||
|
||||
export function parseExtmaps(section: SdpSection): RTPExtension[] {
|
||||
return section.lines.filter((line) => line.startsWith('a=extmap:')).map((line) => {
|
||||
const [, rawId, uri] = line.match(/^a=extmap:(\d+)(?:\/[^\s]+)?\s(.+)$/) || [];
|
||||
if (!rawId || !uri) {
|
||||
throw Error('Failed parsing SDP RTP extension');
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(rawId),
|
||||
uri,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function parsePayloadTypes(section: SdpSection): P2PPayloadType[] {
|
||||
const payloadTypes: P2PPayloadType[] = section.lines.filter((line) => line.startsWith('a=rtpmap:')).map((line) => {
|
||||
const [, rawId, name, rawClockrate, rawChannels] = line.match(/^a=rtpmap:(\d+)\s([^/]+)\/(\d+)(?:\/(\d+))?/) || [];
|
||||
if (!rawId || !name || !rawClockrate) {
|
||||
throw Error('Failed parsing SDP payload type');
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(rawId),
|
||||
name,
|
||||
clockrate: Number(rawClockrate),
|
||||
channels: rawChannels ? Number(rawChannels) : 0,
|
||||
};
|
||||
});
|
||||
|
||||
payloadTypes.forEach((payloadType) => {
|
||||
const parameters = parsePayloadParameters(section, payloadType.id);
|
||||
const feedbackTypes = parseFeedbackTypes(section, payloadType.id);
|
||||
|
||||
if (Object.keys(parameters).length) {
|
||||
payloadType.parameters = parameters;
|
||||
}
|
||||
if (feedbackTypes.length) {
|
||||
payloadType.feedbackTypes = feedbackTypes;
|
||||
}
|
||||
});
|
||||
|
||||
return payloadTypes;
|
||||
}
|
||||
|
||||
export function summarizeSdp(sdp: string, shouldIncludeSsrcGroups = false) {
|
||||
return parseSdpSections(sdp).filter((section) => section.kind !== 'session').map((section): SdpSummarySection => {
|
||||
const mLine = section.lines[0] || '';
|
||||
const parts = mLine.split(' ');
|
||||
|
||||
return {
|
||||
kind: section.kind,
|
||||
mid: section.mid,
|
||||
port: getSdpPort(section),
|
||||
direction: getSdpDirection(section),
|
||||
payloads: parts.slice(3),
|
||||
ssrcs: parseSsrcs(section, shouldIncludeSsrcGroups),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parsePayloadParameters(section: SdpSection, payloadId: number) {
|
||||
const parameters: Record<string, string> = {};
|
||||
const line = section.lines.find((item) => item.startsWith(`a=fmtp:${payloadId} `));
|
||||
const rawParameters = line?.slice(`a=fmtp:${payloadId} `.length);
|
||||
if (!rawParameters) {
|
||||
return parameters;
|
||||
}
|
||||
|
||||
rawParameters.split(';').forEach((item) => {
|
||||
const trimmed = item.trim();
|
||||
const separatorIndex = trimmed.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, separatorIndex);
|
||||
const value = trimmed.slice(separatorIndex + 1);
|
||||
if (key && value) {
|
||||
parameters[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
function parseFeedbackTypes(section: SdpSection, payloadId: number): NonNullable<P2PPayloadType['feedbackTypes']> {
|
||||
return section.lines.filter((line) => line.startsWith(`a=rtcp-fb:${payloadId} `)).map((line) => {
|
||||
const value = line.slice(`a=rtcp-fb:${payloadId} `.length);
|
||||
const [type, subtype] = value.split(' ');
|
||||
return {
|
||||
type,
|
||||
subtype: subtype || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
127
src/lib/vibecalls/sdp/groupSdp.ts
Normal file
127
src/lib/vibecalls/sdp/groupSdp.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import type { JoinGroupCallPayload, P2pParsedSdp, SsrcGroup } from '../types';
|
||||
import type { SdpSection } from './common';
|
||||
|
||||
import { toTelegramSource } from '../utils';
|
||||
import {
|
||||
findSdpLineValue,
|
||||
parseExtmaps as parseSectionExtmaps,
|
||||
parsePayloadTypes as parseSectionPayloadTypes,
|
||||
parseSdpSections,
|
||||
} from './common';
|
||||
|
||||
// Returns undefined when the SDP is missing or malformed.
|
||||
export default (
|
||||
sessionDescription: RTCSessionDescriptionInit,
|
||||
isP2p = false,
|
||||
): JoinGroupCallPayload | P2pParsedSdp | undefined => {
|
||||
if (!sessionDescription.sdp) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sdpSections = parseSdpSections(sessionDescription.sdp);
|
||||
const sections = sdpSections.reduce((acc: Record<string, SdpSection>, section) => {
|
||||
const name = section.kind === 'session' ? 'header' : section.kind;
|
||||
acc[acc.hasOwnProperty(name) && name === 'video' ? 'screencast' : name] = section;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const lookup = (prefix: string, sectionName?: string) => {
|
||||
return findSdpLineValue(Object.values(sections), prefix, sectionName ? sections[sectionName] : undefined);
|
||||
};
|
||||
|
||||
const lookupAll = (prefix: string, sectionName: string) => {
|
||||
return sections[sectionName]?.lines
|
||||
.filter((line) => line.startsWith(prefix))
|
||||
.map((line) => line.slice(prefix.length)) || [];
|
||||
};
|
||||
|
||||
const parseExtmaps = (sectionName: string) => parseSectionExtmaps(sections[sectionName]);
|
||||
const parsePayloadTypes = (sectionName: string) => parseSectionPayloadTypes(sections[sectionName]);
|
||||
|
||||
const rawSource = lookup('a=ssrc:', 'audio');
|
||||
const sourceAudio = rawSource && Number(rawSource.split(' ')[0]);
|
||||
|
||||
const rawSourceVideo = lookupAll('a=ssrc-group:', 'video').map((line) => line.split(' '));
|
||||
const rawSourceScreencast = lookupAll('a=ssrc-group:', 'screencast').map((line) => line.split(' '));
|
||||
|
||||
if (!rawSourceVideo.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [hash, fingerprint] = lookup('a=fingerprint:')?.split(' ') || [];
|
||||
|
||||
const setup = lookup('a=setup:');
|
||||
if (!hash || !fingerprint) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ufrag = lookup('a=ice-ufrag:');
|
||||
const pwd = lookup('a=ice-pwd:');
|
||||
|
||||
if (!ufrag || !pwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payload: JoinGroupCallPayload = {
|
||||
fingerprints: [
|
||||
{
|
||||
fingerprint,
|
||||
hash,
|
||||
setup: setup === 'active' || setup === 'passive' ? setup : 'passive',
|
||||
},
|
||||
],
|
||||
pwd,
|
||||
ufrag,
|
||||
};
|
||||
|
||||
if (sourceAudio) {
|
||||
payload.ssrc = toTelegramSource(sourceAudio);
|
||||
}
|
||||
|
||||
const ssrcGroups = parseSourceGroups(rawSourceVideo);
|
||||
if (!ssrcGroups.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isP2p && rawSourceScreencast.length) {
|
||||
ssrcGroups.push(...parseSourceGroups(rawSourceScreencast));
|
||||
}
|
||||
|
||||
payload['ssrc-groups'] = ssrcGroups;
|
||||
|
||||
if (!isP2p) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const hasScreencast = Boolean(sections.screencast);
|
||||
|
||||
try {
|
||||
return {
|
||||
...payload,
|
||||
audioExtmap: parseExtmaps('audio'),
|
||||
videoExtmap: parseExtmaps('video'),
|
||||
screencastExtmap: hasScreencast ? parseExtmaps('screencast') : [],
|
||||
audioPayloadTypes: parsePayloadTypes('audio'),
|
||||
videoPayloadTypes: parsePayloadTypes('video'),
|
||||
screencastPayloadTypes: hasScreencast ? parsePayloadTypes('screencast') : [],
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
function parseSourceGroups(rawGroups: string[][]): SsrcGroup[] {
|
||||
const result: SsrcGroup[] = [];
|
||||
rawGroups.forEach(([semantics, ...sources]) => {
|
||||
if (!semantics || !sources.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
result.push({
|
||||
semantics,
|
||||
sources: sources.map(Number).map(toTelegramSource),
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
import { P2PPayloadType } from './p2pMessage';
|
||||
import type { P2PPayloadType } from './phone/signalingMessages';
|
||||
|
||||
export type StreamType = 'audio' | 'video' | 'presentation';
|
||||
|
||||
export interface GroupCallParticipant {
|
||||
isSelf?: boolean;
|
||||
@ -111,7 +113,7 @@ export interface GroupCallTransport {
|
||||
|
||||
export interface GroupCallConnectionData {
|
||||
transport: GroupCallTransport;
|
||||
audio: {
|
||||
audio?: {
|
||||
'payload-types': PayloadType[];
|
||||
'rtp-hdrexts': RTPExtension[];
|
||||
};
|
||||
@ -1,17 +1,23 @@
|
||||
import type { P2PPayloadType } from './p2pMessage';
|
||||
import type { P2PPayloadType } from './phone/signalingMessages';
|
||||
import type { PayloadType } from './types';
|
||||
|
||||
export {
|
||||
sanitizePrimitiveRecord,
|
||||
} from '../../util/primitives/primitiveRecord';
|
||||
export type {
|
||||
PrimitiveRecord,
|
||||
PrimitiveRecordValue,
|
||||
} from '../../util/primitives/primitiveRecord';
|
||||
|
||||
/// NOTE: telegram returns sign source, while webrtc uses unsign source internally
|
||||
/// unsign => sign
|
||||
export function toTelegramSource(source: number) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
return source << 0;
|
||||
}
|
||||
|
||||
/// NOTE: telegram returns sign source, while webrtc uses unsign source internally
|
||||
/// sign => unsign
|
||||
export function fromTelegramSource(source: number) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
return source >>> 0;
|
||||
}
|
||||
|
||||
@ -64,5 +70,4 @@ export const THRESHOLD = 0.1;
|
||||
|
||||
export const IS_SCREENSHARE_SUPPORTED = 'getDisplayMedia' in (navigator?.mediaDevices || {});
|
||||
export const IS_ECHO_CANCELLATION_SUPPORTED = navigator?.mediaDevices?.getSupportedConstraints().echoCancellation;
|
||||
// @ts-ignore
|
||||
export const IS_NOISE_SUPPRESSION_SUPPORTED = navigator?.mediaDevices?.getSupportedConstraints().noiseSuppression;
|
||||
@ -82,23 +82,11 @@
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
height: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
|
||||
&::-ms-track {
|
||||
cursor: var(--custom-cursor, pointer);
|
||||
|
||||
width: 100%;
|
||||
border-color: transparent;
|
||||
|
||||
color: transparent;
|
||||
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
@ -73,7 +73,7 @@ export const IS_CANVAS_FILTER_SUPPORTED = (
|
||||
!IS_TEST && 'filter' in (document.createElement('canvas').getContext('2d') || {})
|
||||
);
|
||||
export const IS_REQUEST_FULLSCREEN_SUPPORTED = 'requestFullscreen' in document.createElement('div');
|
||||
export const ARE_CALLS_SUPPORTED = true;
|
||||
export const ARE_CALLS_SUPPORTED = !IS_FIREFOX;
|
||||
|
||||
export const IS_WAVE_TRANSFORM_SUPPORTED = !IS_MOBILE
|
||||
&& !IS_FIREFOX // https://bugzilla.mozilla.org/show_bug.cgi?id=1961378
|
||||
|
||||
13
src/util/primitives/primitiveRecord.ts
Normal file
13
src/util/primitives/primitiveRecord.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export type PrimitiveRecordValue = string | number | boolean;
|
||||
export type PrimitiveRecord = Record<string, PrimitiveRecordValue>;
|
||||
|
||||
export function sanitizePrimitiveRecord(data?: Record<string, unknown>) {
|
||||
const result: PrimitiveRecord = {};
|
||||
Object.entries(data || {}).forEach(([key, value]) => {
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
result[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
@ -97,12 +97,6 @@ export default function createConfig(
|
||||
{
|
||||
directory: path.resolve(__dirname, 'src/lib/rlottie'),
|
||||
},
|
||||
{
|
||||
directory: path.resolve(__dirname, 'src/lib/video-preview'),
|
||||
},
|
||||
{
|
||||
directory: path.resolve(__dirname, 'src/lib/secret-sauce'),
|
||||
},
|
||||
],
|
||||
devMiddleware: {
|
||||
stats: 'minimal',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user