Diferencia 1.3.1 -> 1.3.3 del codigo de la APK oficial aplicada a tres bandas encima de la rama: karma en el perfil, salas con directo, fotos selladas, visibilidad de menciones, telefono por stream en Android y las traducciones nuevas. Sin dependencias nuevas. OasisMobile.css sigue siendo el del fork; del oficial solo se traen al final las reglas de las piezas nuevas (karma, directo a pantalla completa, salas en vivo, graphos).
2124 lines
101 KiB
JavaScript
2124 lines
101 KiB
JavaScript
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { spawn } = require('child_process');
|
|
const nodeCrypto = require('crypto');
|
|
const pull = require('./node_modules/pull-stream');
|
|
const Pushable = require('./node_modules/pull-pushable');
|
|
const sodium = require('./node_modules/chloride');
|
|
const ssbKeys = require('./node_modules/ssb-keys');
|
|
|
|
const RING_MS = 20000;
|
|
const PAM_MIN_MS = 500;
|
|
const dbg = process.env.OASIS_DEBUG ? (...a) => console.log('[phone]', ...a) : () => {};
|
|
const sid = (id) => String(id || '').slice(0, 9);
|
|
const PAM_WARN_MS = 10000;
|
|
const VOICEMAIL_MAX_MS = 60000;
|
|
const RATE = 8000;
|
|
const FRAME_BYTES = 320;
|
|
const FRAME_MS = 20;
|
|
const MAX_BACKLOG = 4000;
|
|
const CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
const RING_WINDOW_MS = 60 * 1000;
|
|
const RING_PER_WINDOW = 20;
|
|
const SEEN_MAX = 5000;
|
|
const HUB_ROOMS_MAX = 256;
|
|
const HUB_RIDS_PER_ROOM = 4;
|
|
const GROUP_MAX = 10;
|
|
const MIX_QUEUE = 10;
|
|
const ROOM_MAX = 50;
|
|
const ROOM_QUIET_AT = 10;
|
|
const ROOM_JOIN_MS = 10000;
|
|
const ROOM_PEEK_MS = 1500;
|
|
const ROOM_FRAME_MAX = 2048;
|
|
const ROOM_RATE_MAX = 120;
|
|
const HUB_LAG_MAX = 2000;
|
|
const ROOM_ACK_EVERY = 100;
|
|
const ROUTES_MAX = 4096;
|
|
const MUTE_BEACON_MS = 2000;
|
|
const MUTE_TTL_MS = 6000;
|
|
const ROOM_EVENTS_MAX = 50;
|
|
const REC_TOGGLE_MIN_MS = 250;
|
|
const CHIMES_AT_ONCE = 3;
|
|
const ROOM_REC_MAX_BYTES = 4 * 3600 * 8000 * 2;
|
|
const CHIMES = {
|
|
join: [[660, 90], [0, 20], [990, 140]],
|
|
leave: [[990, 90], [0, 20], [660, 190]],
|
|
recStart: [[880, 70], [0, 60], [880, 70], [0, 60], [1320, 160]],
|
|
recStop: [[1320, 70], [0, 60], [660, 200]],
|
|
mute: [[523, 80], [0, 30], [392, 160]],
|
|
unmute: [[392, 80], [0, 30], [523, 180]],
|
|
hand: [[784, 60], [0, 40], [988, 60], [0, 40], [1175, 110]]
|
|
};
|
|
const chimeMs = (kind) => (CHIMES[kind] || []).reduce((n, [, ms]) => n + ms, 0);
|
|
const K_CTRL = 0;
|
|
const K_AUDIO = 1;
|
|
const K_DATA = 2;
|
|
const K_KEY = 3;
|
|
const K_VIDEO = 4;
|
|
const VIDEO_CHUNK = 8 * 1024;
|
|
const VIDEO_PAD = 1024;
|
|
const VIDEO_HDR = 6;
|
|
const VIDEO_FILM = 0x80;
|
|
const VIDEO_FRAME_MAX = 256 * 1024;
|
|
const VIDEO_FPS = 8;
|
|
const VIDEO_SIZE = { w: 320, h: 240 };
|
|
const STREAM_SIZE = { w: 640, h: 480 };
|
|
const GRID_TILE_LARGE = { w: 640, h: 480 };
|
|
const GRID_LARGE_MAX = 4;
|
|
const ROOM_VIDEO_CHUNK_EXTRA = 45;
|
|
const VIDEO_HOLD_MS = 3000;
|
|
const ROOM_VIDEO_FRAME_MAX = 10 * 1024;
|
|
const ROOM_VIDEO_BYTES_PER_S = 200 * 1024;
|
|
const ROOM_VIDEO_SEND_MAX = Math.floor(ROOM_VIDEO_BYTES_PER_S * 0.9);
|
|
const VAD_MIN = 200;
|
|
const VAD_FLOOR_MAX = 1500;
|
|
const VAD_HANG = 15;
|
|
const RID = /^[0-9a-f]{32}$/;
|
|
|
|
const BIAS = 0x84;
|
|
const CLIP = 32635;
|
|
const ulawEncodeSample = (sample) => {
|
|
let s = sample;
|
|
const sign = (s >> 8) & 0x80;
|
|
if (sign) s = -s;
|
|
if (s > CLIP) s = CLIP;
|
|
s += BIAS;
|
|
let exponent = 7;
|
|
for (let mask = 0x4000; (s & mask) === 0 && exponent > 0; exponent--, mask >>= 1);
|
|
const mantissa = (s >> (exponent + 3)) & 0x0f;
|
|
return ~(sign | (exponent << 4) | mantissa) & 0xff;
|
|
};
|
|
const ULAW_DECODE = new Int16Array(256);
|
|
for (let i = 0; i < 256; i++) {
|
|
const u = ~i & 0xff;
|
|
const t = (((u & 0x0f) << 3) + BIAS) << ((u & 0x70) >> 4);
|
|
ULAW_DECODE[i] = (u & 0x80) ? BIAS - t : t - BIAS;
|
|
}
|
|
const ulawEncode = (pcm) => {
|
|
const out = Buffer.alloc(pcm.length >> 1);
|
|
for (let i = 0; i < out.length; i++) out[i] = ulawEncodeSample(pcm.readInt16LE(i * 2));
|
|
return out;
|
|
};
|
|
const ulawDecode = (ulaw) => {
|
|
const out = Buffer.alloc(ulaw.length * 2);
|
|
for (let i = 0; i < ulaw.length; i++) out.writeInt16LE(ULAW_DECODE[ulaw[i]], i * 2);
|
|
return out;
|
|
};
|
|
|
|
const wavFile = (pcm) => {
|
|
const h = Buffer.alloc(44);
|
|
h.write('RIFF', 0); h.writeUInt32LE(36 + pcm.length, 4); h.write('WAVE', 8);
|
|
h.write('fmt ', 12); h.writeUInt32LE(16, 16); h.writeUInt16LE(1, 20); h.writeUInt16LE(1, 22);
|
|
h.writeUInt32LE(RATE, 24); h.writeUInt32LE(RATE * 2, 28); h.writeUInt16LE(2, 32); h.writeUInt16LE(16, 34);
|
|
h.write('data', 36); h.writeUInt32LE(pcm.length, 40);
|
|
return Buffer.concat([h, pcm]);
|
|
};
|
|
|
|
const connectingCycle = () => {
|
|
const n = RATE * 2;
|
|
const buf = Buffer.alloc(n * 2);
|
|
let seed = 0x2545f491;
|
|
let lp = 0;
|
|
for (let i = 0; i < n; i++) {
|
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
|
lp += (((seed >>> 8) / 0x800000) - 1 - lp) * 0.08;
|
|
const env = Math.sin(Math.PI * i / n);
|
|
buf.writeInt16LE(Math.round(lp * 9000 * env * env), i * 2);
|
|
}
|
|
return buf;
|
|
};
|
|
const toneCycle = (kind) => {
|
|
if (kind === 'connecting') return connectingCycle();
|
|
const segs = kind === 'ring'
|
|
? [{ ms: 1000, freq: (k) => (Math.floor(k / (RATE / 20)) % 2 ? 1250 : 1000) }, { ms: 2000 }]
|
|
: [{ ms: 1500, freq: () => 425 }, { ms: 3000 }];
|
|
const total = segs.reduce((a, s) => a + s.ms, 0) * RATE / 1000;
|
|
const buf = Buffer.alloc(total * 2);
|
|
let i = 0;
|
|
let phase = 0;
|
|
for (const s of segs) {
|
|
const n = s.ms * RATE / 1000;
|
|
for (let k = 0; k < n; k++, i++) {
|
|
let v = 0;
|
|
if (s.freq) { phase += 2 * Math.PI * s.freq(k) / RATE; v = Math.round(Math.sin(phase) * 6000); }
|
|
buf.writeInt16LE(v, i * 2);
|
|
}
|
|
}
|
|
return buf;
|
|
};
|
|
|
|
const onPath = (bin) => (process.env.PATH || '').split(path.delimiter).some(d => d && fs.existsSync(path.join(d, bin)));
|
|
const BACKENDS = [
|
|
{ rec: ['parec', ['--raw', `--rate=${RATE}`, '--channels=1', '--format=s16le', '--latency-msec=20']], play: ['pacat', ['--playback', '--raw', `--rate=${RATE}`, '--channels=1', '--format=s16le', '--latency-msec=60']] },
|
|
{ rec: ['pw-record', ['--rate', String(RATE), '--channels', '1', '--format', 's16', '-']], play: ['pw-play', ['--rate', String(RATE), '--channels', '1', '--format', 's16', '-']] },
|
|
{ rec: ['arecord', ['-q', '-t', 'raw', '-f', 'S16_LE', '-r', String(RATE), '-c', '1']], play: ['aplay', ['-q', '-t', 'raw', '-f', 'S16_LE', '-r', String(RATE), '-c', '1']] }
|
|
];
|
|
const systemAudio = () => {
|
|
const backend = BACKENDS.find(b => onPath(b.rec[0]) && onPath(b.play[0]));
|
|
if (!backend) return null;
|
|
return {
|
|
capture(onPcm) {
|
|
const p = spawn(backend.rec[0], backend.rec[1], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
p.on('error', () => {});
|
|
p.stdout.on('data', onPcm);
|
|
return { stop() { try { p.kill('SIGTERM'); } catch (_) {} } };
|
|
},
|
|
player() {
|
|
const p = spawn(backend.play[0], backend.play[1], { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
p.on('error', () => {});
|
|
p.stdin.on('error', () => {});
|
|
return {
|
|
write(buf) {
|
|
if (!p.stdin.writable || p.stdin.writableLength > MAX_BACKLOG) return false;
|
|
p.stdin.write(buf);
|
|
return true;
|
|
},
|
|
end() { try { p.stdin.end(); } catch (_) {} },
|
|
stop() { try { p.stdin.end(); p.kill('SIGTERM'); } catch (_) {} }
|
|
};
|
|
}
|
|
};
|
|
};
|
|
let audioFactory = systemAudio;
|
|
|
|
const JPEG_SOI = Buffer.from([0xff, 0xd8, 0xff]);
|
|
const JPEG_EOI = Buffer.from([0xff, 0xd9]);
|
|
const jpegSplitter = (onJpeg) => {
|
|
let pending = Buffer.alloc(0);
|
|
return (chunk) => {
|
|
pending = pending.length ? Buffer.concat([pending, chunk]) : Buffer.from(chunk);
|
|
for (;;) {
|
|
const start = pending.indexOf(JPEG_SOI);
|
|
if (start < 0) { pending = Buffer.alloc(0); return; }
|
|
const end = pending.indexOf(JPEG_EOI, start + 3);
|
|
if (end < 0) { pending = pending.length - start > VIDEO_FRAME_MAX ? Buffer.alloc(0) : pending.subarray(start); return; }
|
|
onJpeg(Buffer.from(pending.subarray(start, end + 2)));
|
|
pending = pending.subarray(end + 2);
|
|
}
|
|
};
|
|
};
|
|
const VIDEO_BACKENDS = [
|
|
{ bin: 'v4l2-ctl', args: (dev) => ['--device', dev, `--set-fmt-video=width=${VIDEO_SIZE.w},height=${VIDEO_SIZE.h},pixelformat=MJPG`, `--set-parm=${VIDEO_FPS}`, '--stream-mmap', '--stream-to=-'] },
|
|
{ bin: 'gst-launch-1.0', args: (dev) => ['-q', 'v4l2src', `device=${dev}`, '!', `video/x-raw,width=${VIDEO_SIZE.w},height=${VIDEO_SIZE.h}`, '!', 'videorate', '!', `video/x-raw,framerate=${VIDEO_FPS}/1`, '!', 'videoconvert', '!', 'jpegenc', 'quality=60', '!', 'fdsink', 'fd=1'] },
|
|
{ bin: 'ffmpeg', args: (dev) => ['-loglevel', 'quiet', '-f', 'v4l2', '-video_size', `${VIDEO_SIZE.w}x${VIDEO_SIZE.h}`, '-i', dev, '-vf', `fps=${VIDEO_FPS}`, '-c:v', 'mjpeg', '-q:v', '8', '-f', 'image2pipe', '-'] }
|
|
];
|
|
const videoDevices = () => {
|
|
try { return fs.readdirSync('/dev').filter(n => /^video\d+$/.test(n)).sort((a, b) => Number(a.slice(5)) - Number(b.slice(5))).map(n => `/dev/${n}`); } catch (_) { return []; }
|
|
};
|
|
const systemVideo = (server, device) => {
|
|
const backend = VIDEO_BACKENDS.find(b => onPath(b.bin));
|
|
const dev = device || videoDevices()[0];
|
|
if (!backend || !dev) return null;
|
|
return {
|
|
capture(onJpeg) {
|
|
const p = spawn(backend.bin, backend.args(dev), { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
p.on('error', () => {});
|
|
p.stdout.on('data', jpegSplitter(onJpeg));
|
|
return { stop() { try { p.kill('SIGTERM'); } catch (_) {} } };
|
|
}
|
|
};
|
|
};
|
|
let videoFactory = systemVideo;
|
|
|
|
const hasBin = (bin) => (path.isAbsolute(String(bin || '')) ? fs.existsSync(bin) : onPath(bin));
|
|
const gstQuote = (s) => `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
const STREAM_BACKENDS = [
|
|
{
|
|
bin: () => process.env.OASIS_FFMPEG || 'ffmpeg',
|
|
retryWithoutAudio: true,
|
|
args: (file, withAudio) => [
|
|
'-loglevel', 'quiet', '-protocol_whitelist', 'file,crypto', '-re', '-i', file,
|
|
'-map', '0:v:0', '-vf', `fps=${VIDEO_FPS},scale=${STREAM_SIZE.w}:${STREAM_SIZE.h}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${STREAM_SIZE.w}:${STREAM_SIZE.h}:(ow-iw)/2:(oh-ih)/2`,
|
|
'-c:v', 'mjpeg', '-q:v', '9', '-f', 'image2pipe', 'pipe:1',
|
|
...(withAudio ? ['-map', '0:a:0', '-ac', '1', '-ar', String(RATE), '-f', 's16le', 'pipe:3'] : [])
|
|
]
|
|
},
|
|
{
|
|
bin: () => process.env.OASIS_GST_LAUNCH || 'gst-launch-1.0',
|
|
args: (file) => [
|
|
'-q', 'filesrc', `location=${gstQuote(file)}`, '!', 'decodebin', 'name=d',
|
|
'd.', '!', 'queue', '!', 'videoconvert', '!', 'videoscale', 'add-borders=true', '!', `video/x-raw,width=${STREAM_SIZE.w},height=${STREAM_SIZE.h},pixel-aspect-ratio=1/1`, '!', 'videorate', '!', `video/x-raw,framerate=${VIDEO_FPS}/1`, '!', 'jpegenc', 'quality=50', '!', 'fdsink', 'fd=1', 'sync=true',
|
|
'd.', '!', 'queue', '!', 'audioconvert', '!', 'audioresample', '!', `audio/x-raw,format=S16LE,rate=${RATE},channels=1`, '!', 'fdsink', 'fd=3', 'sync=true', 'async=false'
|
|
]
|
|
}
|
|
];
|
|
const STREAM_STALL_MS = 5000;
|
|
const STREAM_START_MS = 20000;
|
|
const systemStream = () => {
|
|
const backend = STREAM_BACKENDS.find(b => hasBin(b.bin()));
|
|
if (!backend) return null;
|
|
return {
|
|
open(file, { onJpeg, onPcm, onEnd }) {
|
|
let proc = null;
|
|
let frames = 0;
|
|
let lastAt = 0;
|
|
let ended = false;
|
|
const startedAt = Date.now();
|
|
const watch = setInterval(() => {
|
|
const now = Date.now();
|
|
if (frames ? now - lastAt > STREAM_STALL_MS : now - startedAt > STREAM_START_MS) finish();
|
|
}, 1000);
|
|
if (watch.unref) watch.unref();
|
|
const halt = () => { ended = true; clearInterval(watch); try { if (proc) proc.kill('SIGTERM'); } catch (_) {} };
|
|
const finish = () => { if (ended) return; halt(); onEnd(); };
|
|
const run = (withAudio) => {
|
|
const p = spawn(backend.bin(), backend.args(file, withAudio), { stdio: ['ignore', 'pipe', 'ignore', 'pipe'] });
|
|
proc = p;
|
|
p.on('error', () => {});
|
|
p.stdout.on('error', () => {});
|
|
p.stdout.on('data', jpegSplitter((jpeg) => { if (ended || proc !== p) return; frames++; lastAt = Date.now(); onJpeg(jpeg); }));
|
|
if (p.stdio[3]) {
|
|
p.stdio[3].on('error', () => {});
|
|
p.stdio[3].on('data', (pcm) => { if (!ended && proc === p) onPcm(pcm); });
|
|
}
|
|
p.on('close', () => {
|
|
if (ended || proc !== p) return;
|
|
if (withAudio && !frames && backend.retryWithoutAudio) return run(false);
|
|
finish();
|
|
});
|
|
};
|
|
run(true);
|
|
return { stop() { if (!ended) halt(); } };
|
|
}
|
|
};
|
|
};
|
|
let streamFactory = systemStream;
|
|
|
|
const videoChunks = (seq, jpeg, track = 0) => {
|
|
const n = Math.ceil(jpeg.length / VIDEO_CHUNK);
|
|
const out = [];
|
|
for (let i = 0; i < n; i++) {
|
|
const data = jpeg.subarray(i * VIDEO_CHUNK, (i + 1) * VIDEO_CHUNK);
|
|
const plain = Buffer.alloc(Math.ceil((VIDEO_HDR + data.length) / VIDEO_PAD) * VIDEO_PAD);
|
|
plain.writeUInt16BE(seq & 0xffff, 0);
|
|
plain[2] = i;
|
|
plain[3] = n | (track ? VIDEO_FILM : 0);
|
|
plain.writeUInt16BE(data.length, 4);
|
|
data.copy(plain, VIDEO_HDR);
|
|
nodeCrypto.randomFillSync(plain, VIDEO_HDR + data.length);
|
|
out.push(plain);
|
|
}
|
|
return out;
|
|
};
|
|
const videoOff = (track = 0) => { const plain = Buffer.alloc(VIDEO_PAD); plain[3] = track ? VIDEO_FILM : 0; nodeCrypto.randomFillSync(plain, VIDEO_HDR); return plain; };
|
|
const videoTrack = (plain) => (Buffer.isBuffer(plain) && plain.length >= VIDEO_HDR && (plain[3] & VIDEO_FILM) ? 1 : 0);
|
|
const videoAssembler = () => {
|
|
let cur = null;
|
|
return (plain) => {
|
|
if (!Buffer.isBuffer(plain) || plain.length < VIDEO_HDR) return null;
|
|
const seq = plain.readUInt16BE(0), idx = plain[2], n = plain[3] & ~VIDEO_FILM, len = plain.readUInt16BE(4);
|
|
if (!n) { cur = null; return 'off'; }
|
|
if (idx >= n || VIDEO_HDR + len > plain.length || n * VIDEO_CHUNK > VIDEO_FRAME_MAX) return null;
|
|
if (!cur || cur.seq !== seq) cur = { seq, n, parts: new Array(n), got: 0 };
|
|
if (cur.parts[idx]) return null;
|
|
cur.parts[idx] = Buffer.from(plain.subarray(VIDEO_HDR, VIDEO_HDR + len));
|
|
cur.got++;
|
|
if (cur.got < n) return null;
|
|
const jpeg = Buffer.concat(cur.parts);
|
|
cur = null;
|
|
return jpeg;
|
|
};
|
|
};
|
|
|
|
const BEEP_MS = 700;
|
|
const playBeep = (audio, count = 1) => {
|
|
const on = (count > 1 ? 150 : BEEP_MS) * RATE / 1000;
|
|
const gap = 150 * RATE / 1000;
|
|
const pcm = Buffer.alloc((on * count + gap * (count - 1)) * 2);
|
|
let phase = 0;
|
|
for (let b = 0; b < count; b++) {
|
|
const start = b * (on + gap);
|
|
for (let k = 0; k < on; k++) {
|
|
phase += 2 * Math.PI * 1000 / RATE;
|
|
pcm.writeInt16LE(Math.round(Math.sin(phase) * 9000), (start + k) * 2);
|
|
}
|
|
}
|
|
const player = audio.player();
|
|
player.write(pcm);
|
|
if (typeof player.end === 'function') player.end();
|
|
else setTimeout(() => player.stop(), pcm.length / (RATE * 2) * 1000 + 300);
|
|
};
|
|
|
|
const chimeBusy = new WeakMap();
|
|
const playChime = (audio, kind) => {
|
|
const notes = CHIMES[kind];
|
|
if (!audio || !notes) return;
|
|
const now = Date.now();
|
|
const playing = (chimeBusy.get(audio) || []).filter(t => t > now);
|
|
if (playing.length >= CHIMES_AT_ONCE) return;
|
|
playing.push(now + chimeMs(kind));
|
|
chimeBusy.set(audio, playing);
|
|
const pcm = Buffer.alloc(Math.round(chimeMs(kind) * RATE / 1000) * 2);
|
|
let at = 0, phase = 0;
|
|
for (const [freq, ms] of notes) {
|
|
const n = Math.round(ms * RATE / 1000);
|
|
for (let k = 0; k < n && (at + k) * 2 + 1 < pcm.length; k++) {
|
|
if (freq > 0) {
|
|
phase += 2 * Math.PI * freq / RATE;
|
|
const env = Math.min(1, k / 40, (n - k) / 40);
|
|
pcm.writeInt16LE(Math.round(Math.sin(phase) * 7000 * env), (at + k) * 2);
|
|
}
|
|
}
|
|
at += n;
|
|
}
|
|
const player = audio.player();
|
|
player.write(pcm);
|
|
if (typeof player.end === 'function') player.end();
|
|
else setTimeout(() => player.stop(), pcm.length / (RATE * 2) * 1000 + 300);
|
|
};
|
|
const recordingPrefix = (ref) => nodeCrypto.createHash('sha256').update(String(ref || '')).digest('hex').slice(0, 16);
|
|
|
|
const startTone = (audio, kind) => {
|
|
const cycle = toneCycle(kind);
|
|
const player = audio.player();
|
|
const chunk = RATE * 2 / 10;
|
|
let pos = 0;
|
|
const tick = () => {
|
|
const end = Math.min(pos + chunk, cycle.length);
|
|
player.write(cycle.subarray(pos, end));
|
|
pos = end >= cycle.length ? 0 : end;
|
|
};
|
|
tick();
|
|
const t = setInterval(tick, 100);
|
|
return { stop() { clearInterval(t); player.stop(); } };
|
|
};
|
|
|
|
const mixInto = (acc, pcm) => { for (let i = 0; i < acc.length && i * 2 + 1 < pcm.length; i++) acc[i] += pcm.readInt16LE(i * 2); };
|
|
const clampPcm = (acc) => {
|
|
const out = Buffer.alloc(acc.length * 2);
|
|
for (let i = 0; i < acc.length; i++) out.writeInt16LE(Math.max(-32768, Math.min(32767, acc[i])), i * 2);
|
|
return out;
|
|
};
|
|
|
|
let OpusScript = null;
|
|
try { OpusScript = require('opusscript'); } catch (_) {}
|
|
let sharpLib = null;
|
|
try { sharpLib = require('./node_modules/sharp'); } catch (_) {}
|
|
const OPUS_TAG = 0xf0;
|
|
const OPUS_BITRATE = 16000;
|
|
const makeEncoder = (codec) => {
|
|
if (codec !== 'opus' || !OpusScript) return { encode: ulawEncode, free() {} };
|
|
let enc = null;
|
|
return {
|
|
encode(pcm) {
|
|
try {
|
|
if (!enc) { enc = new OpusScript(RATE, 1, OpusScript.Application.VOIP); try { enc.encoderCTL(4002, OPUS_BITRATE); } catch (_) {} }
|
|
return Buffer.concat([Buffer.from([OPUS_TAG]), enc.encode(pcm, pcm.length >> 1)]);
|
|
} catch (_) { return ulawEncode(pcm); }
|
|
},
|
|
free() { if (enc) { try { enc.delete(); } catch (_) {} enc = null; } }
|
|
};
|
|
};
|
|
const makeDecoder = () => {
|
|
let dec = null;
|
|
return {
|
|
decode(buf) {
|
|
if (buf.length === FRAME_BYTES / 2 || buf[0] !== OPUS_TAG || !OpusScript) return ulawDecode(buf);
|
|
try {
|
|
if (!dec) dec = new OpusScript(RATE, 1, OpusScript.Application.VOIP);
|
|
return Buffer.from(dec.decode(buf.subarray(1)));
|
|
} catch (_) { return Buffer.alloc(FRAME_BYTES); }
|
|
},
|
|
free() { if (dec) { try { dec.delete(); } catch (_) {} dec = null; } }
|
|
};
|
|
};
|
|
const ctrlFrame = (obj) => Buffer.concat([Buffer.from([K_CTRL]), Buffer.from(JSON.stringify(obj))]);
|
|
const levelOf = (pcm) => {
|
|
const n = pcm.length >> 1;
|
|
let sum = 0;
|
|
for (let i = 0; i < n; i++) sum += Math.abs(pcm.readInt16LE(i * 2));
|
|
return n ? sum / n : 0;
|
|
};
|
|
const voiceGate = () => {
|
|
let floor = 0;
|
|
let hang = 0;
|
|
return (pcm) => {
|
|
const lv = levelOf(pcm);
|
|
floor = lv < floor ? lv : floor + (lv - floor) * 0.002;
|
|
if (lv > Math.max(VAD_MIN, Math.min(floor, VAD_FLOOR_MAX) * 3)) hang = VAD_HANG;
|
|
else if (hang > 0) hang--;
|
|
return hang > 0;
|
|
};
|
|
};
|
|
|
|
const b64 = (buf) => Buffer.from(buf).toString('base64');
|
|
const isFeedId = (id) => typeof id === 'string' && /^@[A-Za-z0-9+/]{43}=\.ed25519$/.test(id);
|
|
const pkOf = (id) => Buffer.from(String(id).slice(1, -8), 'base64');
|
|
const signParts = (keys, parts) => b64(sodium.crypto_sign_detached(Buffer.from(parts.join('|')), Buffer.from(String(keys.private).replace(/\.ed25519$/, ''), 'base64')));
|
|
const verifyParts = (id, parts, sig) => {
|
|
try { return !!sodium.crypto_sign_verify_detached(Buffer.from(String(sig), 'base64'), Buffer.from(parts.join('|')), pkOf(id)); } catch (_) { return false; }
|
|
};
|
|
const ephKey = (v) => {
|
|
try { const b = Buffer.from(String(v), 'base64'); return b.length === 32 ? b : null; } catch (_) { return null; }
|
|
};
|
|
const deriveKeys = (mySecret, theirPublic, callId, callerPk, calleePk) => {
|
|
const shared = sodium.crypto_scalarmult(mySecret, theirPublic);
|
|
const label = (l) => sodium.crypto_hash_sha256(Buffer.concat([Buffer.from(shared), Buffer.from(l), Buffer.from(callId, 'hex'), callerPk, calleePk]));
|
|
return { a2b: label('oasis-voices-a2b'), b2a: label('oasis-voices-b2a'), va2b: label('oasis-video-a2b'), vb2a: label('oasis-video-b2a') };
|
|
};
|
|
const sealer = (key) => {
|
|
const prefix = nodeCrypto.randomBytes(16);
|
|
let counter = 0n;
|
|
return (plain) => {
|
|
const nonce = Buffer.alloc(24);
|
|
prefix.copy(nonce, 0);
|
|
nonce.writeBigUInt64BE(++counter, 16);
|
|
return Buffer.concat([nonce, Buffer.from(sodium.crypto_secretbox_easy(plain, nonce, key))]);
|
|
};
|
|
};
|
|
const opener = (key) => {
|
|
let prefix = null;
|
|
let last = 0n;
|
|
return (frame) => {
|
|
if (!Buffer.isBuffer(frame) || frame.length < 40) return null;
|
|
const nonce = frame.subarray(0, 24);
|
|
const p = nonce.subarray(0, 16).toString('hex');
|
|
const counter = nonce.readBigUInt64BE(16);
|
|
if ((prefix && p !== prefix) || counter <= last) return null;
|
|
const plain = sodium.crypto_secretbox_open_easy(frame.subarray(24), nonce, key);
|
|
if (!plain) return null;
|
|
prefix = p;
|
|
last = counter;
|
|
return Buffer.from(plain);
|
|
};
|
|
};
|
|
|
|
module.exports = {
|
|
name: 'phone',
|
|
version: '1.0.0',
|
|
manifest: {
|
|
ring: 'async', answer: 'async', hangup: 'async', audio: 'duplex', relay: 'async', relayAudio: 'duplex', video: 'duplex', relayVideo: 'duplex',
|
|
call: 'async', accept: 'async', reject: 'async', end: 'async', mute: 'async', silence: 'async', dismiss: 'async', camera: 'async', stream: 'async', videoFrames: 'source',
|
|
viewerJoin: 'async', viewerLeave: 'async', viewerRooms: 'sync', publicFeed: 'source',
|
|
recordStop: 'async', recordCancel: 'async',
|
|
roomHub: 'duplex', roomInfo: 'async', roomAdmits: 'async', roomJoin: 'async', roomLeave: 'async', roomMute: 'async', roomHand: 'async', roomCount: 'async',
|
|
roomRecStart: 'async', roomRecStop: 'async', roomNotify: 'async', roomClearEvents: 'async',
|
|
roomState: 'sync', roomToken: 'sync', roomHubFor: 'async',
|
|
state: 'sync', available: 'sync', pubs: 'sync', events: 'source'
|
|
},
|
|
permissions: { anonymous: { allow: ['ring', 'answer', 'hangup', 'audio', 'relay', 'relayAudio', 'video', 'relayVideo', 'roomHub', 'roomInfo', 'roomAdmits'] } },
|
|
init(server, config) {
|
|
const keys = config.keys;
|
|
const audio = audioFactory(server);
|
|
const recBase = String((config && config.path) || os.tmpdir());
|
|
const recDir = path.join(recBase, 'oasis', 'rooms');
|
|
try { if (!fs.existsSync(recDir) && fs.existsSync(path.join(recBase, 'rooms-recordings'))) { fs.mkdirSync(path.dirname(recDir), { recursive: true }); fs.renameSync(path.join(recBase, 'rooms-recordings'), recDir); } } catch (_) {}
|
|
const subscribers = new Set();
|
|
const seenCalls = new Map();
|
|
const routes = new Map();
|
|
const hubRooms = new Map();
|
|
let call = null;
|
|
let room = null;
|
|
const silenced = new Set();
|
|
|
|
const policy = () => {
|
|
if (config.phone && typeof config.phone === 'object') return config.phone;
|
|
try {
|
|
const cfg = require('../configs/config-manager.js').getConfig();
|
|
return { ...(cfg.phone || {}), off: !!(cfg.modules && cfg.modules.phoneMod === 'off') };
|
|
} catch (_) { return {}; }
|
|
};
|
|
const ringMs = Number(policy().ringMs) > 0 ? Number(policy().ringMs) : RING_MS;
|
|
const voicemailMaxMs = Number(policy().voicemailMaxMs) > 0 ? Number(policy().voicemailMaxMs) : VOICEMAIL_MAX_MS;
|
|
const pamWarnMs = Number(policy().pamWarnMs) > 0 ? Number(policy().pamWarnMs) : PAM_WARN_MS;
|
|
const roomMax = Number(policy().roomMax) > 0 ? Math.min(ROOM_MAX, Number(policy().roomMax)) : ROOM_MAX;
|
|
const roomQuietAt = Number(policy().roomQuietAt) > 0 ? Number(policy().roomQuietAt) : ROOM_QUIET_AT;
|
|
const video = videoFactory(server, policy().videoDevice);
|
|
const streamer = streamFactory(server);
|
|
const videoAllowed = () => policy().video !== false;
|
|
const videoOn = () => !!video && videoAllowed();
|
|
const codecsOffered = () => [...(OpusScript && policy().opus !== false ? ['opus'] : []), ...(videoOn() ? ['mjpeg'] : [])];
|
|
const pickCodec = (offer) => (codecsOffered().includes('opus') && Array.isArray(offer) && offer.includes('opus') ? 'opus' : 'ulaw');
|
|
const pickVideo = (offer, mine = false) => (videoAllowed() && ((Array.isArray(offer) && offer.includes('mjpeg')) || mine) ? 'mjpeg' : null);
|
|
const frameWatchers = new Map();
|
|
const publishFrame = (target, jpeg) => { const set = frameWatchers.get(target); if (set) for (const p of set) p.push(jpeg); };
|
|
const endFrames = (target) => {
|
|
const set = frameWatchers.get(target);
|
|
if (!set) return;
|
|
frameWatchers.delete(target);
|
|
for (const p of set) { try { p.end(); } catch (_) {} }
|
|
};
|
|
let cam = null;
|
|
let camSeq = 0;
|
|
const startCam = (onJpeg) => {
|
|
if (cam || !videoOn()) return false;
|
|
const cap = video.capture((jpeg) => {
|
|
if (!cam || jpeg.length > VIDEO_FRAME_MAX) return;
|
|
camSeq = (camSeq + 1) & 0xffff;
|
|
publishFrame('me', jpeg);
|
|
onJpeg(jpeg, camSeq);
|
|
});
|
|
cam = { stop() { cap.stop(); } };
|
|
return true;
|
|
};
|
|
const stopCam = () => { if (!cam) return; const c = cam; cam = null; c.stop(); endFrames('me'); };
|
|
const relation = (method, source, dest, done) => {
|
|
const friends = server.friends;
|
|
if (!friends || typeof friends[method] !== 'function') return done(false);
|
|
friends[method]({ source, dest }, (err, yes) => done(!err && !!yes));
|
|
};
|
|
const allowedCaller = (from, done) => {
|
|
const p = policy();
|
|
if (p.off || p.dnd === true) return done(false);
|
|
relation('isBlocking', server.id, from, (blocked) => {
|
|
if (blocked) return done(false);
|
|
if (p.visibility !== 'mutuals') return done(true);
|
|
relation('isFollowing', server.id, from, (a) => {
|
|
if (!a) return done(false);
|
|
relation('isFollowing', from, server.id, (b) => done(!!b));
|
|
});
|
|
});
|
|
};
|
|
const relayAllowed = (a, b, done) => {
|
|
const p = policy();
|
|
if (p.relay === false) return done(false);
|
|
if (p.relayOpen === true) return done(true);
|
|
relation('isFollowing', server.id, a, (x) => x ? done(true) : relation('isFollowing', server.id, b, (y) => done(!!y)));
|
|
};
|
|
const seal = (payload, to) => { try { return ssbKeys.box(payload, [to]); } catch (_) { return null; } };
|
|
const unseal = (sealed) => { try { return ssbKeys.unbox(String(sealed), keys) || null; } catch (_) { return null; } };
|
|
const opened = (req) => (req && typeof req.sealed === 'string' ? unseal(req.sealed) : req);
|
|
const pubKeys = () => {
|
|
const set = new Set();
|
|
const add = (list) => { for (const e of list || []) if (Array.isArray(e) && e[1] && e[1].type === 'pub' && e[1].key) set.add(e[1].key); };
|
|
try { add(server.conn.dbPeers()); } catch (_) {}
|
|
try { add(server.conn.query().peersAll()); } catch (_) {}
|
|
return set;
|
|
};
|
|
const knownPubs = () => {
|
|
const connected = Object.keys(server.peers || {}).filter(id => id !== server.id && liveRpc(id));
|
|
if (!server.conn || typeof server.conn.query !== 'function') return connected;
|
|
const set = pubKeys();
|
|
return connected.filter(id => set.has(id));
|
|
};
|
|
let routesPrunedAt = 0;
|
|
const learn = (cid, target, from, forwarded) => {
|
|
const now = Date.now();
|
|
if (now - routesPrunedAt > 1000) {
|
|
routesPrunedAt = now;
|
|
for (const [k, v] of routes) if (now - v.ts > CLOCK_SKEW_MS * 2) routes.delete(k);
|
|
}
|
|
if (!routes.has(cid) && routes.size >= ROUTES_MAX) return { id: target, via: liveRpc(target) ? target : null };
|
|
const r = routes.get(cid) || { sides: [], ts: now };
|
|
let t = r.sides.find(x => x.id === target) || r.sides.find(x => x.id === null);
|
|
if (!t) { t = { id: target, via: null }; r.sides.push(t); }
|
|
t.id = target;
|
|
if (liveRpc(target)) t.via = target;
|
|
let o = r.sides.find(x => x !== t);
|
|
if (!o) { o = { id: forwarded ? null : from, via: from }; r.sides.push(o); }
|
|
else if (!o.via) o.via = from;
|
|
r.ts = now;
|
|
routes.set(cid, r);
|
|
return t;
|
|
};
|
|
let seenPrunedAt = 0;
|
|
const directRings = new Map();
|
|
const ringBudget = new Map();
|
|
const trimOldest = (map, max) => { while (map.size > max) map.delete(map.keys().next().value); };
|
|
const firstSeen = (callId) => {
|
|
const now = Date.now();
|
|
if (now - seenPrunedAt > 1000) {
|
|
seenPrunedAt = now;
|
|
for (const [k, t] of seenCalls) if (now - t > CLOCK_SKEW_MS * 2) seenCalls.delete(k);
|
|
for (const [k, t] of directRings) if (now - t > CLOCK_SKEW_MS * 2) directRings.delete(k);
|
|
for (const [k, list] of ringBudget) { const live = list.filter(t => now - t < RING_WINDOW_MS); if (live.length) ringBudget.set(k, live); else ringBudget.delete(k); }
|
|
}
|
|
if (seenCalls.has(callId)) return false;
|
|
seenCalls.set(callId, now);
|
|
trimOldest(seenCalls, SEEN_MAX);
|
|
trimOldest(directRings, SEEN_MAX);
|
|
return true;
|
|
};
|
|
const callerAllowed = (from) => {
|
|
const now = Date.now();
|
|
const list = (ringBudget.get(from) || []).filter(t => now - t < RING_WINDOW_MS);
|
|
if (list.length >= RING_PER_WINDOW) { ringBudget.set(from, list); return false; }
|
|
list.push(now);
|
|
ringBudget.set(from, list);
|
|
trimOldest(ringBudget, SEEN_MAX);
|
|
return true;
|
|
};
|
|
|
|
const MOBILE_STATE = path.join(__dirname, '..', '..', '.phone_state.json');
|
|
let mobileCall = null;
|
|
let mobileRoom = null;
|
|
const writeMobileState = () => {
|
|
if (process.env.OASIS_MOBILE !== '1') return;
|
|
const s = mobileCall || mobileRoom ? { ...(mobileCall || {}), room: mobileRoom ? { phase: mobileRoom.phase, cam: !!mobileRoom.cam } : null } : null;
|
|
try { fs.writeFileSync(MOBILE_STATE, JSON.stringify(s)); } catch (_) {}
|
|
};
|
|
writeMobileState();
|
|
const emit = (ev) => { for (const p of subscribers) p.push(ev); };
|
|
const snapshot = () => call ? {
|
|
id: call.id, peer: call.peer, dir: call.dir, phase: call.phase,
|
|
startedAt: call.startedAt, answeredAt: call.answeredAt || 0, ringUntil: call.ringUntil || 0,
|
|
muted: !!call.muted, silenced: silenced.has(call.peer), recordingStartedAt: call.recordingStartedAt || 0,
|
|
reached: call.group ? call.legs.some(l => l.reached) : !!call.reached, why: call.why || '', codec: call.codec || '',
|
|
video: !!call.video, cam: !!call.cam, peerCam: !!call.peerCam, videoAvailable: videoOn(),
|
|
...(call.group ? { group: true, peers: call.legs.map(l => ({ id: l.peer, phase: l.phase, reached: !!l.reached, silenced: silenced.has(l.peer) })) } : {})
|
|
} : null;
|
|
const changed = () => { const s = snapshot(); mobileCall = s; writeMobileState(); emit({ type: 'state', state: s }); };
|
|
const stopTone = (c) => { if (c.tone) { c.tone.stop(); c.tone = null; } };
|
|
const stopRecorder = (c) => { clearTimeout(c.recDelay); clearTimeout(c.recWarn); if (c.recorder) { c.recorder.stop(); c.recorder = null; } clearTimeout(c.recTimer); };
|
|
const startRecording = (c) => {
|
|
const max = voicemailMaxMs * RATE * 2 / 1000;
|
|
c.rec = { chunks: [], bytes: 0 };
|
|
c.phase = 'recording';
|
|
c.recordingStartedAt = Date.now() + BEEP_MS + 400;
|
|
playBeep(audio);
|
|
c.recDelay = setTimeout(() => {
|
|
if (call !== c || c.phase !== 'recording') return;
|
|
c.recordingStartedAt = Date.now();
|
|
c.recorder = audio.capture((pcm) => {
|
|
if (!c.rec || c.rec.bytes >= max) return;
|
|
const take = Buffer.from(pcm.subarray(0, Math.max(0, max - c.rec.bytes)));
|
|
c.rec.chunks.push(take);
|
|
c.rec.bytes += take.length;
|
|
});
|
|
if (voicemailMaxMs > pamWarnMs) c.recWarn = setTimeout(() => { if (call === c && c.phase === 'recording') playBeep(audio, 2); }, voicemailMaxMs - pamWarnMs);
|
|
c.recTimer = setTimeout(() => {
|
|
if (call !== c || c.phase !== 'recording') return;
|
|
stopRecorder(c);
|
|
c.phase = 'recorded';
|
|
changed();
|
|
emit({ type: 'recordLimit', id: c.id });
|
|
}, voicemailMaxMs);
|
|
changed();
|
|
}, BEEP_MS + 400);
|
|
};
|
|
const finish = (outcome) => {
|
|
const c = call;
|
|
if (!c) return;
|
|
call = null;
|
|
clearTimeout(c.timer);
|
|
stopTone(c);
|
|
stopRecorder(c);
|
|
if (c.media) c.media.stop();
|
|
stopCallVideo(c);
|
|
if (c.group) {
|
|
for (const leg of c.legs) { clearTimeout(leg.timer); if (leg.out) { try { leg.out.end(); } catch (_) {} } }
|
|
if (c.mixer) c.mixer.stop();
|
|
for (const leg of c.legs) { if (leg.enc) leg.enc.free(); if (leg.dec) leg.dec.free(); }
|
|
}
|
|
emit({ type: 'ended', call: { id: c.id, peer: c.peer, ...(c.group ? { peers: c.legs.map(l => l.peer) } : {}), dir: c.dir, startedAt: c.startedAt, answeredAt: c.answeredAt || 0, endedAt: Date.now(), outcome } });
|
|
changed();
|
|
};
|
|
const liveRpc = (id) => {
|
|
const list = server.peers && server.peers[id];
|
|
return Array.isArray(list) ? list.find(r => r && !r.closed && r.phone) || null : null;
|
|
};
|
|
const sendVia = (via, to, method, payload, cb = () => {}) => {
|
|
try {
|
|
if (via) {
|
|
const r = liveRpc(via);
|
|
const sealed = seal(payload, to);
|
|
if (!r || !sealed) return cb(new Error('no-connection'));
|
|
return r.phone.relay({ to, method, cid: payload.callId, sealed }, cb);
|
|
}
|
|
const r = liveRpc(to);
|
|
if (!r) return cb(new Error('no-connection'));
|
|
return r.phone[method](payload, cb);
|
|
} catch (err) { cb(err); }
|
|
};
|
|
const notifyHangup = (c) => {
|
|
const ts = Date.now();
|
|
const payload = { callId: c.id, from: server.id, ts, sig: signParts(keys, ['hangup', c.id, c.peer, ts]) };
|
|
if (c.phase === 'calling' && c.dir === 'out') {
|
|
for (const via of c.paths || []) sendVia(via, c.peer, 'hangup', payload, (err) => dbg('hangup sent', sid(c.peer), via ? 'via ' + sid(via) : 'direct', err ? 'error ' + err.message : 'ok'));
|
|
} else {
|
|
sendVia(c.via || null, c.peer, 'hangup', payload, (err) => dbg('hangup sent', sid(c.peer), c.via ? 'via ' + sid(c.via) : 'direct', err ? 'error ' + err.message : 'ok'));
|
|
}
|
|
};
|
|
const senderOf = (rpcCtx, payload) => {
|
|
const direct = rpcCtx && rpcCtx.id;
|
|
const claimed = payload && isFeedId(payload.from) ? payload.from : direct;
|
|
return { sender: claimed, via: claimed && direct && claimed !== direct ? direct : null };
|
|
};
|
|
const netFirst = (entries) => entries.slice().sort((x, y) => (/^onion:/.test(String(x && x[0])) ? 1 : 0) - (/^onion:/.test(String(y && y[0])) ? 1 : 0));
|
|
const reach = (id, done) => {
|
|
const now = liveRpc(id);
|
|
if (now) return done(now);
|
|
let entries = [];
|
|
try { entries = netFirst(server.conn.query().peersAll()); } catch (_) {}
|
|
const hit = entries.find(e => Array.isArray(e) && e[0] && e[1] && e[1].key === id);
|
|
if (!hit) return done(null);
|
|
try {
|
|
server.conn.connect(hit[0], (err, rpc) => done(!err && rpc && rpc.phone ? rpc : liveRpc(id)));
|
|
} catch (_) { done(null); }
|
|
};
|
|
const relayCandidates = (target) => knownPubs().filter(id => id !== target);
|
|
|
|
const calleePubs = (target, done) => {
|
|
if (!server.friends || typeof server.friends.hops !== 'function' || !server.conn || typeof server.conn.query !== 'function') return done([]);
|
|
try {
|
|
server.friends.hops({ start: target, max: 1 }, (err, hops) => {
|
|
if (err || !hops) return done([]);
|
|
let entries = [];
|
|
try { entries = netFirst(server.conn.dbPeers().concat(server.conn.query().peersAll())); } catch (_) {}
|
|
const seen = new Set();
|
|
done(entries
|
|
.filter(e => Array.isArray(e) && e[0] && e[1] && e[1].type === 'pub' && hops[e[1].key] === 1 && !liveRpc(e[1].key) && !seen.has(e[1].key) && seen.add(e[1].key))
|
|
.slice(0, 5)
|
|
.map(e => ({ address: e[0], key: e[1].key })));
|
|
});
|
|
} catch (_) { done([]); }
|
|
};
|
|
|
|
const ringPaths = (holder, target, req, alive, onReached, legacy = false) => {
|
|
dbg('ring', sid(target), 'legacy=' + legacy, 'live-pubs=' + knownPubs().map(sid).join(','));
|
|
reach(target, (rpc) => {
|
|
dbg('direct', sid(target), rpc ? 'reachable' : 'unreachable');
|
|
if (!alive() || !rpc) return;
|
|
holder.paths.push(null);
|
|
if (legacy) onReached();
|
|
sendVia(null, target, 'ring', req, (err) => { dbg('direct ring', sid(target), err ? 'error ' + err.message : 'delivered'); if (!err && alive()) onReached(); });
|
|
});
|
|
for (const via of relayCandidates(target)) {
|
|
holder.paths.push(via);
|
|
sendVia(via, target, 'ring', req, (err) => dbg('relay via', sid(via), err ? 'error ' + err.message : 'accepted'));
|
|
}
|
|
calleePubs(target, (pubs) => {
|
|
dbg('callee pubs', pubs.map(p => sid(p.key) + '@' + p.address).join(' ') || 'none');
|
|
for (const entry of pubs) {
|
|
try {
|
|
server.conn.connect(entry.address, (err) => {
|
|
dbg('connect callee pub', sid(entry.key), err ? 'failed ' + err.message : 'ok');
|
|
if (err || !alive()) return;
|
|
holder.paths.push(entry.key);
|
|
sendVia(entry.key, target, 'ring', req, (e2) => dbg('relay via callee pub', sid(entry.key), e2 ? 'error ' + e2.message : 'accepted'));
|
|
});
|
|
} catch (_) {}
|
|
}
|
|
});
|
|
};
|
|
const ringRequest = (callId, eph, target) => {
|
|
const ts = Date.now();
|
|
const ephPk = b64(eph.publicKey);
|
|
return { callId, from: server.id, to: target, ephPk, ts, sig: signParts(keys, ['ring', callId, ephPk, target, ts]), codecs: codecsOffered() };
|
|
};
|
|
|
|
const legOf = (c, callId) => (c && c.group ? c.legs.find(l => l.id === callId) || null : null);
|
|
const toVoicemail = (c, why) => {
|
|
if (call !== c || c.phase !== 'calling') return;
|
|
dbg('voicemail', sid(c.peer), why, 'reached=' + !!c.reached);
|
|
clearTimeout(c.timer);
|
|
stopTone(c);
|
|
if (why !== 'busy') notifyHangup(c);
|
|
c.why = why;
|
|
startRecording(c);
|
|
changed();
|
|
};
|
|
const reachedCall = (c) => {
|
|
if (call !== c || c.phase !== 'calling' || c.reached) return;
|
|
dbg('reached', sid(c.peer), 'now ringing there');
|
|
c.reached = true;
|
|
c.ringUntil = Date.now() + ringMs;
|
|
stopTone(c);
|
|
c.tone = startTone(audio, 'ringback');
|
|
clearTimeout(c.timer);
|
|
c.timer = setTimeout(() => toVoicemail(c, 'noanswer'), ringMs);
|
|
changed();
|
|
};
|
|
const reachedLeg = (c, leg) => {
|
|
if (call !== c || leg.phase !== 'calling' || leg.reached) return;
|
|
leg.reached = true;
|
|
clearTimeout(leg.timer);
|
|
leg.timer = setTimeout(() => { if (call === c && leg.phase === 'calling') dropLeg(c, leg, true); }, ringMs);
|
|
if (c.phase === 'calling' && !c.ringing) { stopTone(c); c.ringing = true; c.tone = startTone(audio, 'ringback'); c.ringUntil = Date.now() + ringMs; }
|
|
changed();
|
|
};
|
|
const ringingAck = (c, req, from, cb) => {
|
|
const target = c && c.group ? legOf(c, req.callId) : c;
|
|
if (!target || c.dir !== 'out' || target.phase !== 'calling' || from !== target.peer || req.callId !== target.id) return cb(new Error('no-call'));
|
|
if (Math.abs(Date.now() - Number(req.ts)) > CLOCK_SKEW_MS || !verifyParts(from, ['ringing', req.callId, req.ringing, server.id, req.ts], req.sig)) return cb(new Error('rejected'));
|
|
cb(null, true);
|
|
if (req.ringing === 'busy') {
|
|
if (c.group) { target.reached = true; return dropLeg(c, target, false); }
|
|
c.reached = true;
|
|
return toVoicemail(c, 'busy');
|
|
}
|
|
if (c.group) reachedLeg(c, target);
|
|
else reachedCall(c);
|
|
};
|
|
const hangupLeg = (leg) => {
|
|
const ts = Date.now();
|
|
const payload = { callId: leg.id, from: server.id, ts, sig: signParts(keys, ['hangup', leg.id, leg.peer, ts]) };
|
|
if (leg.phase === 'calling') for (const via of leg.paths) sendVia(via, leg.peer, 'hangup', payload);
|
|
else sendVia(leg.via || null, leg.peer, 'hangup', payload);
|
|
};
|
|
const settleGroup = (c) => {
|
|
if (call !== c) return;
|
|
if (c.legs.some(l => l.phase === 'connected' || l.phase === 'calling')) return changed();
|
|
finish(c.answeredAt ? 'ended' : 'noanswer');
|
|
};
|
|
const dropLeg = (c, leg, notify) => {
|
|
if (leg.phase === 'gone') return;
|
|
if (notify) hangupLeg(leg);
|
|
clearTimeout(leg.timer);
|
|
leg.phase = 'gone';
|
|
if (leg.out) { try { leg.out.end(); } catch (_) {} leg.out = null; }
|
|
if (leg.enc) leg.enc.free();
|
|
if (leg.dec) leg.dec.free();
|
|
leg.queue = [];
|
|
settleGroup(c);
|
|
};
|
|
const startMixer = (c) => {
|
|
if (c.mixer) return;
|
|
const player = audio.player();
|
|
const samples = FRAME_BYTES / 2;
|
|
let pending = Buffer.alloc(0);
|
|
const capture = audio.capture((pcm) => {
|
|
if (call !== c) return;
|
|
pending = pending.length ? Buffer.concat([pending, pcm]) : Buffer.from(pcm);
|
|
while (pending.length >= FRAME_BYTES) {
|
|
const mic = pending.subarray(0, FRAME_BYTES);
|
|
pending = pending.subarray(FRAME_BYTES);
|
|
const legs = c.legs.filter(l => l.phase === 'connected' && l.out);
|
|
const heard = legs.map(l => l.queue.shift() || null);
|
|
const all = new Int32Array(samples);
|
|
heard.forEach((f, i) => { if (f && !silenced.has(legs[i].peer)) mixInto(all, f); });
|
|
player.write(clampPcm(all));
|
|
legs.forEach((leg, i) => {
|
|
const acc = new Int32Array(samples);
|
|
if (!c.muted) mixInto(acc, mic);
|
|
heard.forEach((f, j) => { if (f && j !== i) mixInto(acc, f); });
|
|
leg.out.push(leg.seal(leg.enc.encode(clampPcm(acc))));
|
|
});
|
|
}
|
|
});
|
|
c.mixer = { stop() { capture.stop(); player.stop(); } };
|
|
};
|
|
const startLegMedia = (c, leg, duplex) => {
|
|
const open = opener(leg.keys.b2a);
|
|
leg.seal = sealer(leg.keys.a2b);
|
|
leg.enc = makeEncoder(leg.codec);
|
|
leg.dec = makeDecoder();
|
|
leg.out = Pushable(() => {});
|
|
leg.queue = [];
|
|
pull(leg.out, duplex.sink);
|
|
pull(duplex.source, pull.drain((frame) => {
|
|
const plain = open(Buffer.isBuffer(frame) ? frame : Buffer.from(frame || []));
|
|
if (!plain || leg.phase !== 'connected') return;
|
|
leg.queue.push(leg.dec.decode(plain));
|
|
if (leg.queue.length > MIX_QUEUE) leg.queue.shift();
|
|
}, () => { if (call === c) dropLeg(c, leg, false); }));
|
|
startMixer(c);
|
|
};
|
|
const groupCall = (targets, legacy, cb) => {
|
|
const startedAt = Date.now();
|
|
const c = { id: nodeCrypto.randomBytes(16).toString('hex'), peer: targets[0], dir: 'out', group: true, phase: 'calling', startedAt, ringUntil: startedAt + ringMs, legs: [] };
|
|
call = c;
|
|
c.tone = startTone(audio, 'connecting');
|
|
for (const peer of targets) {
|
|
const leg = { id: nodeCrypto.randomBytes(16).toString('hex'), peer, phase: 'calling', eph: sodium.crypto_box_keypair(), paths: [], via: null, queue: [] };
|
|
c.legs.push(leg);
|
|
leg.timer = setTimeout(() => { if (call === c && leg.phase === 'calling') dropLeg(c, leg, true); }, ringMs);
|
|
ringPaths(leg, peer, ringRequest(leg.id, leg.eph, peer), () => call === c && leg.phase === 'calling', () => reachedLeg(c, leg), legacy.has(peer));
|
|
}
|
|
changed();
|
|
cb(null, snapshot());
|
|
};
|
|
const groupAnswer = (c, req, from, via, cb) => {
|
|
const leg = legOf(c, req && req.callId);
|
|
if (!leg || leg.phase !== 'calling' || from !== leg.peer) return cb(new Error('no-call'));
|
|
const calleePk = ephKey(req.ephPk);
|
|
if (!calleePk || !verifyParts(from, ['answer', req.callId, req.ephPk, server.id, req.ts], req.sig)) return cb(new Error('rejected'));
|
|
const path = via ? liveRpc(via) : liveRpc(from);
|
|
if (!path) return cb(new Error('no-connection'));
|
|
leg.keys = deriveKeys(leg.eph.secretKey, calleePk, leg.id, Buffer.from(leg.eph.publicKey), calleePk);
|
|
leg.codec = pickCodec([req.codec]);
|
|
clearTimeout(leg.timer);
|
|
leg.via = via;
|
|
leg.phase = 'connected';
|
|
leg.answeredAt = Date.now();
|
|
if (c.phase === 'calling') { stopTone(c); c.phase = 'connected'; c.answeredAt = leg.answeredAt; }
|
|
cb(null, true);
|
|
changed();
|
|
const quiet = () => {};
|
|
startLegMedia(c, leg, via ? path.phone.relayAudio({ to: leg.peer, callId: leg.id }, quiet) : path.phone.audio({ callId: leg.id }, quiet));
|
|
};
|
|
|
|
const startMedia = (c, duplex, role) => {
|
|
const seal = sealer(role === 'out' ? c.keys.a2b : c.keys.b2a);
|
|
const open = opener(role === 'out' ? c.keys.b2a : c.keys.a2b);
|
|
const out = Pushable(() => {});
|
|
const player = audio.player();
|
|
const enc = makeEncoder(c.codec);
|
|
const dec = makeDecoder();
|
|
let pending = Buffer.alloc(0);
|
|
const capture = audio.capture((pcm) => {
|
|
if (call !== c || c.phase !== 'connected') return;
|
|
pending = pending.length ? Buffer.concat([pending, pcm]) : Buffer.from(pcm);
|
|
while (pending.length >= FRAME_BYTES) {
|
|
const frame = pending.subarray(0, FRAME_BYTES);
|
|
pending = pending.subarray(FRAME_BYTES);
|
|
if (!c.muted) out.push(seal(enc.encode(frame)));
|
|
}
|
|
});
|
|
const incoming = [];
|
|
c.audioLag = () => incoming.length * FRAME_MS;
|
|
const pace = setInterval(() => {
|
|
if (call !== c) return;
|
|
if (incoming.length > MIX_QUEUE) incoming.splice(0, incoming.length - MIX_QUEUE);
|
|
const next = incoming.shift();
|
|
if (next) player.write(next);
|
|
}, FRAME_MS);
|
|
if (pace.unref) pace.unref();
|
|
const sink = pull.drain((frame) => {
|
|
const plain = open(Buffer.isBuffer(frame) ? frame : Buffer.from(frame || []));
|
|
if (plain && !silenced.has(c.peer)) incoming.push(dec.decode(plain));
|
|
}, () => { if (call === c) finish('ended'); });
|
|
c.media = { stop() { clearInterval(pace); try { out.end(); } catch (_) {} capture.stop(); player.stop(); enc.free(); dec.free(); } };
|
|
if (!duplex) return { source: out, sink };
|
|
pull(out, duplex.sink);
|
|
pull(duplex.source, sink);
|
|
return null;
|
|
};
|
|
|
|
const peerFrameSeen = (c) => {
|
|
clearTimeout(c.peerTimer);
|
|
c.peerTimer = setTimeout(() => { if (call === c && c.peerCam) { c.peerCam = false; endFrames(c.peer); changed(); } }, VIDEO_HOLD_MS);
|
|
if (c.peerTimer.unref) c.peerTimer.unref();
|
|
if (!c.peerCam) { c.peerCam = true; changed(); }
|
|
};
|
|
const startVideo = (c, duplex, role) => {
|
|
const seal = sealer(role === 'out' ? c.keys.va2b : c.keys.vb2a);
|
|
const open = opener(role === 'out' ? c.keys.vb2a : c.keys.va2b);
|
|
const out = Pushable(() => {});
|
|
const assemble = videoAssembler();
|
|
const sink = pull.drain((frame) => {
|
|
const plain = open(Buffer.isBuffer(frame) ? frame : Buffer.from(frame || []));
|
|
if (!plain || call !== c) return;
|
|
const got = assemble(plain);
|
|
if (got === 'off') { clearTimeout(c.peerTimer); if (c.peerCam) { c.peerCam = false; endFrames(c.peer); changed(); } return; }
|
|
if (!got) return;
|
|
peerFrameSeen(c);
|
|
const hold = setTimeout(() => { if (call === c && c.peerCam) publishFrame(c.peer, got); }, typeof c.audioLag === 'function' ? c.audioLag() : 0);
|
|
if (hold.unref) hold.unref();
|
|
}, () => { if (call === c) stopCallVideo(c); });
|
|
c.vmedia = { out, seal, stop() { try { out.end(); } catch (_) {} } };
|
|
if (!duplex) return { source: out, sink };
|
|
pull(out, duplex.sink);
|
|
pull(duplex.source, sink);
|
|
return null;
|
|
};
|
|
const stopCallVideo = (c) => {
|
|
clearTimeout(c.peerTimer);
|
|
if (c.cam) { c.cam = false; stopCam(); }
|
|
if (c.vmedia) { c.vmedia.stop(); c.vmedia = null; }
|
|
endFrames(c.peer);
|
|
c.peerCam = false;
|
|
};
|
|
const sendCallVideo = (c, plain) => { if (c.vmedia) c.vmedia.out.push(c.vmedia.seal(plain)); };
|
|
|
|
const deliver = (m, frame, droppable) => {
|
|
if (droppable && m.sent - m.acked > HUB_LAG_MAX) return;
|
|
m.sent++;
|
|
m.out.push(frame);
|
|
};
|
|
const hubBroadcast = (r, from, frame, droppable) => {
|
|
for (const [slot, m] of r.members) {
|
|
if (slot !== from) deliver(m, frame, droppable);
|
|
}
|
|
};
|
|
const hubHumans = (h) => [...h.members.values()].filter(m => !m.viewer).length;
|
|
const hubAttach = (rid, id, out, scope = null, viewer = false) => {
|
|
let r = hubRooms.get(rid);
|
|
if (!r) {
|
|
if (hubRooms.size >= HUB_ROOMS_MAX) return null;
|
|
if (scope && [...hubRooms.values()].filter(h => h.scope === scope).length >= HUB_RIDS_PER_ROOM) return null;
|
|
r = { members: new Map(), scope };
|
|
hubRooms.set(rid, r);
|
|
}
|
|
if (scope && r.scope !== scope) return null;
|
|
for (const old of [...r.members.values()]) if (old.id === id) old.kick();
|
|
if (!hubRooms.has(rid)) hubRooms.set(rid, r);
|
|
if (r.members.size >= roomMax) return null;
|
|
let slot = 0;
|
|
while (r.members.has(slot)) slot++;
|
|
const peers = [...r.members].map(([s, m]) => (m.viewer ? { slot: s, id: m.id, viewer: true } : { slot: s, id: m.id }));
|
|
const m = { id, out, sent: 0, acked: 0, viewer: !!viewer };
|
|
m.leave = () => {
|
|
if (r.members.get(slot) !== m) return;
|
|
r.members.delete(slot);
|
|
if (r.members.size) hubBroadcast(r, slot, ctrlFrame({ t: 'leave', slot }), false);
|
|
else if (hubRooms.get(rid) === r) hubRooms.delete(rid);
|
|
};
|
|
m.kick = () => { m.leave(); try { out.end(); } catch (_) {} };
|
|
m.handle = (frame) => {
|
|
if (r.members.get(slot) !== m || frame.length < 2) return;
|
|
const kind = frame[0];
|
|
if (kind === K_VIDEO) {
|
|
if (frame.length > ROOM_VIDEO_FRAME_MAX || policy().video === false) return;
|
|
const at = Date.now();
|
|
if (at - (m.vwindowAt || 0) >= 1000) { m.vwindowAt = at; m.vbytes = 0; }
|
|
m.vbytes = (m.vbytes || 0) + frame.length;
|
|
if (m.vbytes > ROOM_VIDEO_BYTES_PER_S) return;
|
|
hubBroadcast(r, slot, Buffer.concat([Buffer.from([K_VIDEO, slot]), frame.subarray(1)]), true);
|
|
return;
|
|
}
|
|
if (frame.length > ROOM_FRAME_MAX) return;
|
|
if (kind === K_CTRL) {
|
|
let msg = null;
|
|
try { msg = JSON.parse(frame.subarray(1).toString('utf8')); } catch (_) {}
|
|
if (msg && msg.t === 'bye') return m.kick();
|
|
const n = msg && msg.t === 'ack' ? Number(msg.n) : NaN;
|
|
if (Number.isFinite(n) && n > m.acked && n <= m.sent) m.acked = n;
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
if (now - (m.windowAt || 0) >= 1000) { m.windowAt = now; m.windowCount = 0; }
|
|
if (++m.windowCount > ROOM_RATE_MAX) return;
|
|
if (kind === K_AUDIO || kind === K_DATA) {
|
|
hubBroadcast(r, slot, Buffer.concat([Buffer.from([kind, slot]), frame.subarray(1)]), true);
|
|
} else if (kind === K_KEY && frame.length > 2 && frame[1] !== slot) {
|
|
const target = r.members.get(frame[1]);
|
|
if (target) deliver(target, Buffer.concat([Buffer.from([K_KEY, slot]), frame.subarray(2)]), false);
|
|
}
|
|
};
|
|
r.members.set(slot, m);
|
|
deliver(m, ctrlFrame({ t: 'hello', slot, peers, max: roomMax, video: policy().video !== false }), false);
|
|
hubBroadcast(r, slot, ctrlFrame(viewer ? { t: 'join', slot, id, viewer: true } : { t: 'join', slot, id }), false);
|
|
return m;
|
|
};
|
|
const hubDuplex = (from, opts) => {
|
|
let member = null;
|
|
let closed = false;
|
|
const out = Pushable(() => { closed = true; if (member) member.leave(); });
|
|
const sink = pull.drain((frame) => {
|
|
if (member) member.handle(Buffer.isBuffer(frame) ? frame : Buffer.from(frame || []));
|
|
}, () => { closed = true; if (member) member.leave(); try { out.end(); } catch (_) {} });
|
|
const refuse = (t) => { out.push(ctrlFrame({ t })); out.end(); };
|
|
const rid = String((opts && opts.rid) || '');
|
|
const roomId = String((opts && opts.roomId) || '');
|
|
const tokenOk = roomId ? (RID.test(roomId) && verifyParts(opts.owner, ['room', roomId], opts.token)) : verifyParts(opts.owner, ['room', rid], opts.token);
|
|
if (!from || !RID.test(rid) || !isFeedId(opts.owner) || !tokenOk) {
|
|
refuse('refused');
|
|
return { source: out, sink };
|
|
}
|
|
const admit = () => {
|
|
if (closed) return;
|
|
member = hubAttach(rid, from, out, roomId ? `${opts.owner}|${roomId}` : null, opts.viewer === true);
|
|
if (!member) refuse('full');
|
|
};
|
|
if (opts.owner === server.id || from === server.id) admit();
|
|
else relayAllowed(opts.owner, from, (ok) => (ok ? admit() : refuse('refused')));
|
|
return { source: out, sink };
|
|
};
|
|
|
|
const viewers = new Map();
|
|
const active = (r) => !!r && (r === room || viewers.get(r.rid) === r);
|
|
const touched = (r) => { if (r === room) roomChanged(); };
|
|
const humansIn = (r) => [...r.peers.values()].filter(p => !p.viewer).length;
|
|
const roomSnapshot = () => {
|
|
const r = room;
|
|
if (!r) return null;
|
|
const now = Date.now();
|
|
return {
|
|
rid: r.rid, ref: r.ref, title: r.title, phase: r.phase, joinedAt: r.joinedAt, muted: !!r.muted, hand: !!r.hand, handSince: r.hand ? r.handAt : 0,
|
|
secure: !!r.kid, count: humansIn(r) + 1, max: r.max, public: [...r.peers.values()].some(p => p.viewer), speaking: now - r.sentAt < 1000,
|
|
cam: !!r.cam, videoAvailable: videoOn() && r.hubVideo === true, grid: !!sharpLib, gridOrder: gridIds(r),
|
|
streaming: r.stream ? r.stream.name : null, streamAvailable: fileStreamOn() && r.hubVideo === true,
|
|
notify: !!r.notify, recording: !!r.rec, recordingSince: r.rec ? r.rec.at : 0, recordingBy: [...r.recPeers],
|
|
events: r.notify ? r.events.slice(-ROOM_EVENTS_MAX) : [],
|
|
peers: [...r.peers.values()].map(p => ({ id: p.id, speaking: now - p.heardAt < 1000, muted: now - p.mutedAt < MUTE_TTL_MS, hand: now - (p.handBeat || 0) < MUTE_TTL_MS, handSince: p.handSince || 0, silenced: silenced.has(p.id), recording: r.recPeers.has(p.id), cam: now - (p.frameAt || 0) < VIDEO_HOLD_MS, film: now - (p.filmAt || 0) < VIDEO_HOLD_MS, viewer: !!p.viewer }))
|
|
};
|
|
};
|
|
const roomChanged = () => { const r = roomSnapshot(); mobileRoom = r; writeMobileState(); emit({ type: 'room', room: r }); };
|
|
const pushEvent = (r, t, id) => {
|
|
r.events.push({ t, id, ts: Date.now() });
|
|
if (r.events.length > ROOM_EVENTS_MAX) r.events.splice(0, r.events.length - ROOM_EVENTS_MAX);
|
|
touched(r);
|
|
};
|
|
const startRoomRec = (r) => {
|
|
if (!r || r.rec || r.phase !== 'live') return false;
|
|
try {
|
|
fs.mkdirSync(recDir, { recursive: true, mode: 0o700 });
|
|
try { fs.chmodSync(recDir, 0o700); } catch (_) {}
|
|
const file = path.join(recDir, `${recordingPrefix(r.ref)}-${Date.now()}.wav`);
|
|
const fd = fs.openSync(file, 'w', 0o600);
|
|
fs.writeSync(fd, wavFile(Buffer.alloc(0)));
|
|
r.rec = { fd, file, bytes: 0, at: Date.now() };
|
|
} catch (_) { return false; }
|
|
sendRoomData(r, { t: 'rec', on: true });
|
|
playChime(audio, 'recStart');
|
|
pushEvent(r, 'recStart', server.id);
|
|
return true;
|
|
};
|
|
const stopRoomRec = (r) => {
|
|
if (!r || !r.rec) return false;
|
|
const rec = r.rec;
|
|
r.rec = null;
|
|
try {
|
|
const h = Buffer.alloc(4);
|
|
h.writeUInt32LE(36 + rec.bytes, 0); fs.writeSync(rec.fd, h, 0, 4, 4);
|
|
h.writeUInt32LE(rec.bytes, 0); fs.writeSync(rec.fd, h, 0, 4, 40);
|
|
fs.closeSync(rec.fd);
|
|
if (!rec.bytes) fs.unlinkSync(rec.file);
|
|
} catch (_) {}
|
|
if (room === r) {
|
|
sendRoomData(r, { t: 'rec', on: false });
|
|
playChime(audio, 'recStop');
|
|
pushEvent(r, 'recStop', server.id);
|
|
}
|
|
if (rec.bytes) emit({ type: 'roomRecorded', room: { ref: r.ref, title: r.title }, file: rec.file, bytes: rec.bytes, durationMs: Math.round(rec.bytes / (RATE * 2) * 1000) });
|
|
return true;
|
|
};
|
|
const keeperSlot = (r) => r.order[0];
|
|
const trimKeys = (r) => { while (r.keys.size > 3) r.keys.delete(r.keys.keys().next().value); };
|
|
const useKey = (r, kid, key) => {
|
|
if (r.kid === kid && r.sealer && r.keys.get(kid) && r.keys.get(kid).equals(key)) return;
|
|
r.keys.set(kid, key);
|
|
trimKeys(r);
|
|
r.kid = kid;
|
|
r.sealer = sealer(key);
|
|
sendCodecs(r);
|
|
};
|
|
const newRoomKey = (r) => useKey(r, nodeCrypto.randomBytes(4).toString('hex'), nodeCrypto.randomBytes(32));
|
|
const sendRoomKey = (r, slot) => {
|
|
const p = r.peers.get(slot);
|
|
const key = r.kid && r.keys.get(r.kid);
|
|
if (!p || !key) return;
|
|
const ts = Date.now();
|
|
const sealed = seal({ rid: r.rid, kid: r.kid, key: b64(key), from: server.id, ts, sig: signParts(keys, ['roomkey', r.rid, r.kid, p.id, ts]) }, p.id);
|
|
if (sealed) r.out.push(Buffer.concat([Buffer.from([K_KEY, slot]), Buffer.from(sealed)]));
|
|
};
|
|
const peerEntry = (id, viewer = false) => ({ id, viewer: !!viewer, queue: [], openers: new Map(), heardAt: 0, mutedAt: 0, dec: null, opus: false, greeted: false });
|
|
const dropPeer = (r, slot) => { const p = r.peers.get(slot); if (p) { if (p.dec) p.dec.free(); clearTimeout(p.vtimer); clearTimeout(p.ftimer); endFrames(r.tag + p.id); endFrames(r.tag + filmKey(p.id)); gridNote(r, p.id, null); gridNote(r, filmKey(p.id), null); } r.peers.delete(slot); };
|
|
const sendCodecs = (r) => { if (r.out && r.sealer && codecsOffered().length) sendRoomData(r, { t: 'codecs', codecs: codecsOffered() }); };
|
|
const onRoomCtrl = (r, msg) => {
|
|
if (msg.t === 'hello' && r.slot === null) {
|
|
r.slot = Number(msg.slot);
|
|
r.max = Number(msg.max) > 0 ? Number(msg.max) : ROOM_MAX;
|
|
r.hubVideo = msg.video === true;
|
|
for (const p of Array.isArray(msg.peers) ? msg.peers : []) {
|
|
if (!p || !isFeedId(p.id)) continue;
|
|
r.peers.set(Number(p.slot), peerEntry(p.id, p.viewer === true));
|
|
r.order.push(Number(p.slot));
|
|
}
|
|
r.order.push(r.slot);
|
|
if (r.peers.size >= roomQuietAt) r.muted = true;
|
|
if (!r.static && keeperSlot(r) === r.slot) newRoomKey(r);
|
|
r.phase = 'live';
|
|
if (r.ready) return r.ready(null);
|
|
} else if (msg.t === 'join' && isFeedId(msg.id)) {
|
|
const slot = Number(msg.slot);
|
|
dropPeer(r, slot);
|
|
r.peers.set(slot, peerEntry(msg.id, msg.viewer === true));
|
|
r.order = r.order.filter(s => s !== slot).concat(slot);
|
|
if (!r.static && keeperSlot(r) === r.slot) sendRoomKey(r, slot);
|
|
if (r.notify && msg.viewer !== true) playChime(audio, 'join');
|
|
pushEvent(r, 'join', msg.id);
|
|
} else if (msg.t === 'leave') {
|
|
const slot = Number(msg.slot);
|
|
const wasKeeper = keeperSlot(r) === slot;
|
|
const gone = r.peers.get(slot);
|
|
dropPeer(r, slot);
|
|
r.order = r.order.filter(s => s !== slot);
|
|
if (gone) {
|
|
if (r.recPeers.delete(gone.id)) pushEvent(r, 'recStop', gone.id);
|
|
if (r.notify) playChime(audio, 'leave');
|
|
pushEvent(r, 'leave', gone.id);
|
|
}
|
|
if (!r.static && keeperSlot(r) === r.slot && (wasKeeper || !r.kid)) {
|
|
newRoomKey(r);
|
|
for (const s of r.peers.keys()) sendRoomKey(r, s);
|
|
}
|
|
} else if (msg.t === 'full' || msg.t === 'refused') {
|
|
if (r.ready) return r.ready(new Error(msg.t));
|
|
}
|
|
touched(r);
|
|
};
|
|
const onRoomKey = (r, slot, payload) => {
|
|
const p = r.peers.get(slot);
|
|
if (r.static || !p || keeperSlot(r) !== slot) return;
|
|
const msg = unseal(payload.toString('utf8'));
|
|
if (!msg || msg.rid !== r.rid || msg.from !== p.id || !/^[0-9a-f]{8}$/.test(String(msg.kid))) return;
|
|
if (!verifyParts(p.id, ['roomkey', r.rid, msg.kid, server.id, msg.ts], msg.sig)) return;
|
|
const key = Buffer.from(String(msg.key || ''), 'base64');
|
|
if (key.length !== 32) return;
|
|
useKey(r, msg.kid, key);
|
|
touched(r);
|
|
};
|
|
const openRoomFrame = (r, p, payload) => {
|
|
if (!p || payload.length < 44) return null;
|
|
const kid = payload.subarray(0, 4).toString('hex');
|
|
const key = r.keys.get(kid);
|
|
if (!key) return null;
|
|
let open = p.openers.get(kid);
|
|
if (!open) { open = opener(key); p.openers.set(kid, open); }
|
|
return open(payload.subarray(4));
|
|
};
|
|
const sendRoomData = (r, msg) => {
|
|
if (!r.sealer) return;
|
|
r.out.push(Buffer.concat([Buffer.from([K_DATA]), Buffer.from(r.kid, 'hex'), r.sealer(Buffer.from(JSON.stringify(msg)))]));
|
|
r.beaconAt = Date.now();
|
|
};
|
|
const onRoomAudio = (r, slot, payload) => {
|
|
const p = r.peers.get(slot);
|
|
const plain = openRoomFrame(r, p, payload);
|
|
if (!plain) return;
|
|
if (!p.dec) p.dec = makeDecoder();
|
|
p.queue.push(p.dec.decode(plain));
|
|
if (p.queue.length > MIX_QUEUE) p.queue.shift();
|
|
p.heardAt = Date.now();
|
|
p.mutedAt = 0;
|
|
noteMute(r, p, false);
|
|
};
|
|
const noteMute = (r, p, on, quiet = false) => {
|
|
if (!!p.muted === on) return;
|
|
p.muted = on;
|
|
if (quiet) return touched(r);
|
|
const now = Date.now();
|
|
const announce = now - (p.muteToggleAt || 0) >= REC_TOGGLE_MIN_MS;
|
|
p.muteToggleAt = now;
|
|
if (!announce) return touched(r);
|
|
if (r.notify) playChime(audio, on ? 'mute' : 'unmute');
|
|
pushEvent(r, on ? 'mute' : 'unmute', p.id);
|
|
};
|
|
const noteHand = (r, p, on, at, quiet = false) => {
|
|
if (!!p.hand === on) return;
|
|
p.hand = on;
|
|
const now = Date.now();
|
|
p.handSince = on ? (quiet && Number.isFinite(at) && at > 0 ? Math.min(at, now) : now) : 0;
|
|
if (!on || quiet) return touched(r);
|
|
const announce = now - (p.handToggleAt || 0) >= REC_TOGGLE_MIN_MS;
|
|
p.handToggleAt = now;
|
|
if (!announce) return touched(r);
|
|
if (r.notify) playChime(audio, 'hand');
|
|
pushEvent(r, 'hand', p.id);
|
|
};
|
|
const onRoomData = (r, slot, payload) => {
|
|
const p = r.peers.get(slot);
|
|
const plain = openRoomFrame(r, p, payload);
|
|
if (!plain) return;
|
|
let msg = null;
|
|
try { msg = JSON.parse(plain.toString('utf8')); } catch (_) {}
|
|
if (msg && msg.t === 'codecs') {
|
|
p.opus = Array.isArray(msg.codecs) && msg.codecs.includes('opus');
|
|
if (!p.greeted) { p.greeted = true; sendCodecs(r); }
|
|
return;
|
|
}
|
|
if (msg && msg.t === 'rec') {
|
|
const was = r.recPeers.has(p.id);
|
|
if (!!msg.on === was) return;
|
|
const now = Date.now();
|
|
const announce = now - (p.recToggleAt || 0) >= REC_TOGGLE_MIN_MS;
|
|
p.recToggleAt = now;
|
|
if (msg.on) r.recPeers.add(p.id); else r.recPeers.delete(p.id);
|
|
if (announce) { playChime(audio, msg.on ? 'recStart' : 'recStop'); pushEvent(r, msg.on ? 'recStart' : 'recStop', p.id); }
|
|
else touched(r);
|
|
return;
|
|
}
|
|
if (msg && msg.t === 'hand') {
|
|
p.handBeat = msg.on ? Date.now() : 0;
|
|
noteHand(r, p, !!msg.on, Number(msg.at), msg.beacon === true);
|
|
return;
|
|
}
|
|
if (!msg || msg.t !== 'mute') return;
|
|
p.mutedAt = msg.on ? Date.now() : 0;
|
|
noteMute(r, p, !!msg.on, msg.beacon === true);
|
|
};
|
|
const gridIds = (r) => {
|
|
const now = Date.now();
|
|
const ids = [];
|
|
if (r.cam) ids.push(server.id);
|
|
if (r.stream) ids.push(filmKey(server.id));
|
|
for (const slot of r.order) {
|
|
const p = r.peers.get(slot);
|
|
if (!p) continue;
|
|
if (now - (p.frameAt || 0) < VIDEO_HOLD_MS) ids.push(p.id);
|
|
if (now - (p.filmAt || 0) < VIDEO_HOLD_MS) ids.push(filmKey(p.id));
|
|
}
|
|
return ids;
|
|
};
|
|
const newGrid = () => ({ frames: new Map(), busy: false, dirty: false, timer: null });
|
|
const composeGrid = async (r) => {
|
|
const g = r.grid;
|
|
const ids = gridIds(r).filter(id => g.frames.has(id));
|
|
if (!ids.length) {
|
|
if (r.tag && !g.empty) { g.empty = true; const b = await blankFrame(); if (b && active(r)) publishFrame(r.tag + 'grid', b); }
|
|
return;
|
|
}
|
|
g.empty = false;
|
|
if (ids.length === 1 && ids[0] !== server.id) { if (active(r)) publishFrame(r.tag + 'grid', g.frames.get(ids[0])); return; }
|
|
const size = ids.length <= GRID_LARGE_MAX ? GRID_TILE_LARGE : VIDEO_SIZE;
|
|
const cols = Math.ceil(Math.sqrt(ids.length));
|
|
const rows = Math.ceil(ids.length / cols);
|
|
const tiles = [];
|
|
for (let i = 0; i < ids.length; i++) {
|
|
try {
|
|
let tile = sharpLib(g.frames.get(ids[i])).resize(size.w, size.h, { fit: 'contain', background: '#000000' });
|
|
if (ids[i] === server.id) tile = tile.flop();
|
|
tiles.push({ input: await tile.toBuffer(), left: (i % cols) * size.w, top: Math.floor(i / cols) * size.h });
|
|
} catch (_) {}
|
|
}
|
|
if (!tiles.length) return;
|
|
const jpeg = await sharpLib({ create: { width: cols * size.w, height: rows * size.h, channels: 3, background: '#000000' } }).composite(tiles).jpeg({ quality: 80 }).toBuffer();
|
|
if (active(r)) publishFrame(r.tag + 'grid', jpeg);
|
|
};
|
|
const gridTick = (r) => {
|
|
const g = r && r.grid;
|
|
if (!g || !active(r) || !sharpLib || g.busy || !g.dirty || !frameWatchers.has(r.tag + 'grid')) return;
|
|
g.dirty = false;
|
|
g.busy = true;
|
|
composeGrid(r).catch(() => {}).then(() => { g.busy = false; });
|
|
};
|
|
const gridNote = (r, id, jpeg) => { if (!r || !r.grid) return; if (jpeg) r.grid.frames.set(id, jpeg); else r.grid.frames.delete(id); r.grid.dirty = true; };
|
|
const startGrid = (r) => { if (!sharpLib || !r.grid || r.grid.timer) return; r.grid.timer = setInterval(() => gridTick(r), Math.round(1000 / VIDEO_FPS)); if (r.grid.timer.unref) r.grid.timer.unref(); };
|
|
const stopGrid = (r) => { if (!r || !r.grid) return; clearInterval(r.grid.timer); r.grid.timer = null; r.grid.frames.clear(); r.grid.dirty = false; endFrames(r.tag + 'grid'); };
|
|
const onRoomVideo = (r, slot, payload) => {
|
|
const p = r.peers.get(slot);
|
|
if (!videoAllowed()) return;
|
|
const plain = openRoomFrame(r, p, payload);
|
|
if (!plain) return;
|
|
const film = videoTrack(plain) === 1;
|
|
const key = film ? filmKey(p.id) : p.id;
|
|
const at = film ? 'filmAt' : 'frameAt';
|
|
const timer = film ? 'ftimer' : 'vtimer';
|
|
const asm = film ? 'fasm' : 'vasm';
|
|
if (!p[asm]) p[asm] = videoAssembler();
|
|
const got = p[asm](plain);
|
|
if (got === 'off') { clearTimeout(p[timer]); if (p[at]) { p[at] = 0; endFrames(r.tag + key); gridNote(r, key, null); touched(r); } return; }
|
|
if (!got) return;
|
|
const fresh = !(Date.now() - (p[at] || 0) < VIDEO_HOLD_MS);
|
|
p[at] = Date.now();
|
|
clearTimeout(p[timer]);
|
|
p[timer] = setTimeout(() => { if (active(r)) { endFrames(r.tag + key); gridNote(r, key, null); touched(r); } }, VIDEO_HOLD_MS);
|
|
if (p[timer].unref) p[timer].unref();
|
|
const hold = setTimeout(() => { if (!active(r) || !(Date.now() - (p[at] || 0) < VIDEO_HOLD_MS)) return; publishFrame(r.tag + key, got); gridNote(r, key, got); }, p.queue.length * FRAME_MS);
|
|
if (hold.unref) hold.unref();
|
|
if (fresh) touched(r);
|
|
};
|
|
const sendRoomVideo = (r, plain) => {
|
|
if (!r.sealer || !r.hubVideo) return;
|
|
r.out.push(Buffer.concat([Buffer.from([K_VIDEO]), Buffer.from(r.kid, 'hex'), r.sealer(plain)]));
|
|
};
|
|
const fileStreamOn = () => videoAllowed() && !!streamer;
|
|
const streamFile = (file) => {
|
|
const raw = String(file || '');
|
|
if (!path.isAbsolute(raw)) return null;
|
|
try { const full = fs.realpathSync(raw); return fs.statSync(full).isFile() ? full : null; } catch (_) { return null; }
|
|
};
|
|
const filmKey = (id) => `film:${id}`;
|
|
const videoBudget = (r, chunks) => {
|
|
const bytes = chunks.reduce((n, c) => n + c.length + ROOM_VIDEO_CHUNK_EXTRA, 0);
|
|
const now = Date.now();
|
|
const sent = r.vsent || (r.vsent = []);
|
|
while (sent.length && now - sent[0][0] >= 1000) sent.shift();
|
|
if (sent.reduce((n, e) => n + e[1], 0) + bytes > ROOM_VIDEO_SEND_MAX) return false;
|
|
sent.push([now, bytes]);
|
|
return true;
|
|
};
|
|
const sendRoomJpeg = (r, seq, jpeg, track = 0) => {
|
|
const chunks = videoChunks(seq, jpeg, track);
|
|
if (!videoBudget(r, chunks)) return;
|
|
for (const plain of chunks) sendRoomVideo(r, plain);
|
|
};
|
|
const stopRoomStream = (r, notify = true) => {
|
|
const s = r && r.stream;
|
|
if (!s) return false;
|
|
r.stream = null;
|
|
try { if (s.handle) s.handle.stop(); } catch (_) {}
|
|
endFrames('film:me');
|
|
gridNote(r, filmKey(server.id), null);
|
|
if (room === r) sendRoomVideo(r, videoOff(1));
|
|
if (notify) roomChanged();
|
|
return true;
|
|
};
|
|
const startRoomStream = (r, file, name) => {
|
|
if (!r || r.phase !== 'live' || !r.hubVideo || !fileStreamOn()) return false;
|
|
stopRoomStream(r, false);
|
|
const s = { name: String(name || '').slice(0, 120), pcm: Buffer.alloc(0), seq: 0, handle: null };
|
|
r.stream = s;
|
|
try {
|
|
s.handle = streamer.open(file, {
|
|
onJpeg: (jpeg) => {
|
|
if (r.stream !== s || room !== r || !Buffer.isBuffer(jpeg) || jpeg.length > VIDEO_FRAME_MAX) return;
|
|
s.seq = (s.seq + 1) & 0xffff;
|
|
publishFrame('film:me', jpeg);
|
|
gridNote(r, filmKey(server.id), jpeg);
|
|
sendRoomJpeg(r, s.seq, jpeg, 1);
|
|
},
|
|
onPcm: (pcm) => {
|
|
if (r.stream !== s || !pcm || !pcm.length) return;
|
|
s.pcm = s.pcm.length ? Buffer.concat([s.pcm, pcm]) : Buffer.from(pcm);
|
|
const drop = (s.pcm.length - RATE * 2) & ~1;
|
|
if (drop > 0) s.pcm = s.pcm.subarray(drop);
|
|
},
|
|
onEnd: () => { if (r.stream === s) stopRoomStream(r); }
|
|
});
|
|
} catch (_) { s.handle = null; }
|
|
if (!s.handle || r.stream !== s) { if (r.stream === s) r.stream = null; return false; }
|
|
return true;
|
|
};
|
|
const streamFrame = (r) => {
|
|
const s = r.stream;
|
|
if (!s || s.pcm.length < FRAME_BYTES) return null;
|
|
const f = s.pcm.subarray(0, FRAME_BYTES);
|
|
s.pcm = s.pcm.subarray(FRAME_BYTES);
|
|
return f;
|
|
};
|
|
const onRoomFrame = (r, frame) => {
|
|
if (!active(r)) return;
|
|
const buf = Buffer.isBuffer(frame) ? frame : Buffer.from(frame || []);
|
|
if (!buf.length) return;
|
|
r.received = (r.received || 0) + 1;
|
|
if (r.received - (r.ackedAt || 0) >= ROOM_ACK_EVERY) { r.ackedAt = r.received; r.out.push(ctrlFrame({ t: 'ack', n: r.received })); }
|
|
if (buf[0] === K_CTRL) {
|
|
let msg = null;
|
|
try { msg = JSON.parse(buf.subarray(1).toString('utf8')); } catch (_) {}
|
|
if (msg && typeof msg === 'object') onRoomCtrl(r, msg);
|
|
return;
|
|
}
|
|
if (buf.length < 3) return;
|
|
if (buf[0] === K_AUDIO) onRoomAudio(r, buf[1], buf.subarray(2));
|
|
else if (buf[0] === K_VIDEO) onRoomVideo(r, buf[1], buf.subarray(2));
|
|
else if (buf[0] === K_DATA) onRoomData(r, buf[1], buf.subarray(2));
|
|
else if (buf[0] === K_KEY) onRoomKey(r, buf[1], buf.subarray(2));
|
|
};
|
|
const startRoomMedia = (r) => {
|
|
const player = audio.player();
|
|
const gate = voiceGate();
|
|
const samples = FRAME_BYTES / 2;
|
|
let pending = Buffer.alloc(0);
|
|
const capture = audio.capture((pcm) => {
|
|
if (room !== r) return;
|
|
pending = pending.length ? Buffer.concat([pending, pcm]) : Buffer.from(pcm);
|
|
while (pending.length >= FRAME_BYTES) {
|
|
const mic = pending.subarray(0, FRAME_BYTES);
|
|
pending = pending.subarray(FRAME_BYTES);
|
|
const acc = new Int32Array(samples);
|
|
for (const p of r.peers.values()) { const f = p.queue.shift(); if (f && !silenced.has(p.id)) mixInto(acc, f); }
|
|
const film = streamFrame(r);
|
|
if (film) mixInto(acc, film);
|
|
player.write(clampPcm(acc));
|
|
if (r.rec) {
|
|
const both = Int32Array.from(acc);
|
|
if (!r.muted) mixInto(both, mic);
|
|
const take = clampPcm(both);
|
|
try { fs.writeSync(r.rec.fd, take); r.rec.bytes += take.length; } catch (_) { stopRoomRec(r); }
|
|
if (r.rec && r.rec.bytes >= ROOM_REC_MAX_BYTES) stopRoomRec(r);
|
|
if (r.rec && r.sealer && Date.now() - (r.recBeaconAt || 0) >= MUTE_BEACON_MS) { sendRoomData(r, { t: 'rec', on: true }); r.recBeaconAt = Date.now(); }
|
|
}
|
|
const voiced = gate(mic);
|
|
if (r.muted && r.sealer && Date.now() - (r.beaconAt || 0) >= MUTE_BEACON_MS) sendRoomData(r, { t: 'mute', on: true, beacon: true });
|
|
if (r.hand && r.sealer && Date.now() - (r.handBeaconAt || 0) >= MUTE_BEACON_MS) { sendRoomData(r, { t: 'hand', on: true, at: r.handAt, beacon: true }); r.handBeaconAt = Date.now(); }
|
|
const talk = !r.muted && voiced;
|
|
if (!r.sealer || (!talk && !film)) continue;
|
|
r.sentAt = Date.now();
|
|
let send = mic;
|
|
if (film) { const mix = new Int32Array(samples); mixInto(mix, film); if (talk) mixInto(mix, mic); send = clampPcm(mix); }
|
|
const opus = codecsOffered().includes('opus') && r.peers.size > 0 && [...r.peers.values()].every(p => p.opus);
|
|
if (opus && !r.enc) r.enc = makeEncoder('opus');
|
|
r.out.push(Buffer.concat([Buffer.from([K_AUDIO]), Buffer.from(r.kid, 'hex'), r.sealer(opus ? r.enc.encode(send) : ulawEncode(send))]));
|
|
}
|
|
});
|
|
r.media = { stop() { capture.stop(); player.stop(); } };
|
|
};
|
|
const closeDuplex = (duplex) => {
|
|
if (!duplex) return;
|
|
try { duplex.source(true, () => {}); } catch (_) {}
|
|
try { pull(pull.empty(), duplex.sink); } catch (_) {}
|
|
};
|
|
const leaveRoom = (r, outcome) => {
|
|
if (!r || room !== r) return;
|
|
if (r.ready) { r.ready(new Error('left')); return; }
|
|
stopRoomRec(r);
|
|
room = null;
|
|
clearTimeout(r.timer);
|
|
stopRoomStream(r, false);
|
|
if (r.cam) { r.cam = false; stopCam(); }
|
|
for (const p of r.peers.values()) { clearTimeout(p.vtimer); clearTimeout(p.ftimer); endFrames(p.id); endFrames(filmKey(p.id)); }
|
|
stopGrid(r);
|
|
if (r.media) r.media.stop();
|
|
if (r.enc) { r.enc.free(); r.enc = null; }
|
|
for (const p of r.peers.values()) if (p.dec) p.dec.free();
|
|
try { r.out.push(ctrlFrame({ t: 'bye' })); } catch (_) {}
|
|
try { r.out.end(); } catch (_) {}
|
|
if (outcome) emit({ type: 'roomEnded', room: { rid: r.rid, ref: r.ref, title: r.title, joinedAt: r.joinedAt, endedAt: Date.now(), outcome } });
|
|
roomChanged();
|
|
};
|
|
const openHub = (opts, req, attach) => {
|
|
if (opts.hub === server.id) return attach(hubDuplex(server.id, req));
|
|
const viaRpc = (rpc) => attach(rpc && rpc.phone && typeof rpc.phone.roomHub === 'function' ? rpc.phone.roomHub(req, () => {}) : null);
|
|
const live = liveRpc(opts.hub);
|
|
if (live) return viaRpc(live);
|
|
if (typeof opts.address === 'string' && opts.address) {
|
|
try {
|
|
return server.conn.connect(opts.address, (err, rpc) => {
|
|
const got = !err && rpc && rpc.phone ? rpc : liveRpc(opts.hub);
|
|
return got ? viaRpc(got) : reach(opts.hub, viaRpc);
|
|
});
|
|
} catch (_) {}
|
|
}
|
|
reach(opts.hub, viaRpc);
|
|
};
|
|
let blank = null;
|
|
const blankFrame = async () => {
|
|
if (blank || !sharpLib) return blank;
|
|
try { blank = await sharpLib({ create: { width: GRID_TILE_LARGE.w, height: GRID_TILE_LARGE.h, channels: 3, background: '#000000' } }).jpeg().toBuffer(); } catch (_) { blank = null; }
|
|
return blank;
|
|
};
|
|
const soundKey = (r) => r.tag + 'sound';
|
|
const dropViewer = (r) => {
|
|
if (!r || viewers.get(r.rid) !== r) return;
|
|
viewers.delete(r.rid);
|
|
clearTimeout(r.timer);
|
|
clearInterval(r.mixTimer);
|
|
stopGrid(r);
|
|
endFrames(soundKey(r));
|
|
for (const p of r.peers.values()) { if (p.dec) p.dec.free(); clearTimeout(p.vtimer); clearTimeout(p.ftimer); }
|
|
try { r.out.push(ctrlFrame({ t: 'bye' })); } catch (_) {}
|
|
try { r.out.end(); } catch (_) {}
|
|
};
|
|
const startViewerMedia = (r) => {
|
|
const samples = FRAME_BYTES / 2;
|
|
r.mixTimer = setInterval(() => {
|
|
if (viewers.get(r.rid) !== r) return;
|
|
const acc = new Int32Array(samples);
|
|
for (const p of r.peers.values()) { const f = p.queue.shift(); if (f) mixInto(acc, f); }
|
|
if (frameWatchers.has(soundKey(r))) publishFrame(soundKey(r), clampPcm(acc));
|
|
if (r.sealer && Date.now() - (r.beaconAt || 0) >= MUTE_BEACON_MS) sendRoomData(r, { t: 'mute', on: true, beacon: true });
|
|
}, FRAME_MS);
|
|
if (r.mixTimer.unref) r.mixTimer.unref();
|
|
};
|
|
const staticKey = (rid, secret) => {
|
|
const key = Buffer.from(sodium.crypto_hash_sha256(Buffer.concat([Buffer.from('oasis-room|'), Buffer.from(rid, 'hex'), Buffer.from(String(secret))])));
|
|
return { kid: Buffer.from(sodium.crypto_hash_sha256(key)).subarray(0, 4).toString('hex'), key };
|
|
};
|
|
|
|
if (server.close && typeof server.close.hook === 'function') {
|
|
server.close.hook(function (fn, args) { finish('ended'); leaveRoom(room, 'ended'); stopCam(); for (const v of [...viewers.values()]) dropViewer(v); return fn.apply(this, args); });
|
|
}
|
|
|
|
return {
|
|
ring(raw, cb) {
|
|
const reply = typeof cb === 'function' ? cb : () => {};
|
|
const req = opened(raw);
|
|
const { sender: from, via } = senderOf(this, req);
|
|
reply(null, true);
|
|
if (!from || from === server.id || !req || typeof req !== 'object') return;
|
|
const { callId, to, ephPk, ts, sig } = req;
|
|
dbg('incoming ring from', sid(from), via ? 'via ' + sid(via) : 'direct');
|
|
if (!/^[0-9a-f]{32}$/.test(String(callId)) || to !== server.id || Math.abs(Date.now() - Number(ts)) > CLOCK_SKEW_MS) return;
|
|
const remotePk = ephKey(ephPk);
|
|
if (!remotePk || !verifyParts(from, ['ring', callId, ephPk, to, ts], sig)) return;
|
|
if (!seenCalls.has(callId) && !callerAllowed(from)) return;
|
|
if (!via) directRings.set(callId, Date.now());
|
|
if (call && call.id === callId && call.dir === 'in') {
|
|
if (!via && call.phase === 'incoming' && call.peer === from) call.via = null;
|
|
return;
|
|
}
|
|
if (!firstSeen(callId)) return;
|
|
const ack = (state) => {
|
|
const at = Date.now();
|
|
sendVia(directRings.has(callId) ? null : via, from, 'answer', { callId, from: server.id, ringing: state, ts: at, sig: signParts(keys, ['ringing', callId, state, from, at]) });
|
|
};
|
|
allowedCaller(from, (ok) => {
|
|
if (!ok || !audio) return ack('ringing');
|
|
if (call || room) {
|
|
ack('busy');
|
|
const at = Date.now();
|
|
emit({ type: 'ended', call: { id: callId, peer: from, dir: 'in', startedAt: at, answeredAt: 0, endedAt: at, outcome: 'missed' } });
|
|
return;
|
|
}
|
|
const startedAt = Date.now();
|
|
call = { id: callId, peer: from, dir: 'in', phase: 'incoming', startedAt, ringUntil: startedAt + ringMs, remotePk, via: directRings.has(callId) ? null : via, codec: pickCodec(req.codecs), video: pickVideo(req.codecs, videoOn()) };
|
|
call.tone = startTone(audio, 'ring');
|
|
call.timer = setTimeout(() => { if (call && call.id === callId && call.phase === 'incoming') finish('missed'); }, ringMs);
|
|
changed();
|
|
emit({ type: 'incoming', id: callId, peer: from });
|
|
ack('ringing');
|
|
});
|
|
},
|
|
answer(raw, cb) {
|
|
const req = opened(raw);
|
|
const { sender: from, via } = senderOf(this, req);
|
|
const c = call;
|
|
if (req && (req.ringing === 'ringing' || req.ringing === 'busy')) return ringingAck(c, req, from, cb);
|
|
if (c && c.group) return groupAnswer(c, req, from, via, cb);
|
|
if (!c || c.dir !== 'out' || c.phase !== 'calling' || from !== c.peer || !req || req.callId !== c.id) return cb(new Error('no-call'));
|
|
const calleePk = ephKey(req.ephPk);
|
|
if (!calleePk || !verifyParts(from, ['answer', req.callId, req.ephPk, server.id, req.ts], req.sig)) return cb(new Error('rejected'));
|
|
const path = via ? liveRpc(via) : liveRpc(from);
|
|
if (!path) return cb(new Error('no-connection'));
|
|
c.keys = deriveKeys(c.eph.secretKey, calleePk, c.id, Buffer.from(c.eph.publicKey), calleePk);
|
|
c.codec = pickCodec([req.codec]);
|
|
c.video = pickVideo([req.video]);
|
|
clearTimeout(c.timer);
|
|
stopTone(c);
|
|
c.via = via;
|
|
c.phase = 'connected';
|
|
c.answeredAt = Date.now();
|
|
cb(null, true);
|
|
changed();
|
|
const quiet = () => {};
|
|
startMedia(c, via ? path.phone.relayAudio({ to: c.peer, callId: c.id }, quiet) : path.phone.audio({ callId: c.id }, quiet), 'out');
|
|
if (c.video) startVideo(c, via ? path.phone.relayVideo({ to: c.peer, callId: c.id }, quiet) : path.phone.video({ callId: c.id }, quiet), 'out');
|
|
},
|
|
hangup(raw, cb) {
|
|
const reply = typeof cb === 'function' ? cb : () => {};
|
|
const req = opened(raw);
|
|
const { sender: from } = senderOf(this, req);
|
|
const c = call;
|
|
dbg('hangup received from', sid(from), c ? 'call ' + sid(c.peer) + ' ' + c.phase : 'no call');
|
|
if (c && c.group) {
|
|
const leg = legOf(c, req && req.callId);
|
|
if (leg && from === leg.peer && verifyParts(from, ['hangup', req.callId, server.id, req.ts], req.sig)) dropLeg(c, leg, false);
|
|
return reply(null, true);
|
|
}
|
|
if (c && req && req.callId === c.id && from === c.peer && verifyParts(from, ['hangup', req.callId, server.id, req.ts], req.sig)) {
|
|
if (c.dir === 'in') finish(c.phase === 'incoming' ? 'missed' : 'ended');
|
|
else if (c.phase === 'calling' || c.phase === 'connected') finish(c.phase === 'connected' ? 'ended' : 'noanswer');
|
|
}
|
|
reply(null, true);
|
|
},
|
|
audio(opts) {
|
|
const direct = this && this.id;
|
|
const from = opts && isFeedId(opts.from) ? opts.from : direct;
|
|
const c = call;
|
|
const viaOk = direct === c?.peer || (c?.via && direct === c.via);
|
|
if (!c || c.dir !== 'in' || !c.keys || c.media || from !== c.peer || !viaOk || !opts || opts.callId !== c.id || (c.phase !== 'connecting' && c.phase !== 'connected')) {
|
|
return { source: pull.error(new Error('no-call')), sink: pull.drain(null, () => {}) };
|
|
}
|
|
c.phase = 'connected';
|
|
return startMedia(c, null, 'in');
|
|
},
|
|
relay(req, cb) {
|
|
const reply = typeof cb === 'function' ? cb : () => {};
|
|
const from = this && this.id;
|
|
reply(null, true);
|
|
if (!from || policy().relay === false || !req || typeof req !== 'object' || !isFeedId(req.to) || req.to === from) return;
|
|
if (!['ring', 'answer', 'hangup'].includes(req.method) || typeof req.sealed !== 'string' || !/^[0-9a-f]{32}$/.test(String(req.cid))) return;
|
|
const forwarded = req.hop === 1;
|
|
dbg('relay', req.method, 'from', sid(from), 'to', sid(req.to), forwarded ? 'forwarded' : 'origin', 'target-live=' + !!liveRpc(req.to));
|
|
if (forwarded && !knownPubs().includes(from)) return;
|
|
const msg = { to: req.to, method: req.method, cid: req.cid, sealed: req.sealed, hop: 1 };
|
|
const side = learn(req.cid, req.to, from, forwarded);
|
|
if (side.via === req.to) {
|
|
relayAllowed(req.to, forwarded ? req.to : from, (ok) => {
|
|
const target = ok ? liveRpc(req.to) : null;
|
|
dbg('relay deliver', sid(req.to), ok ? (target ? 'delivering' : 'not connected here') : 'policy refuses');
|
|
if (!target) return;
|
|
try { target.phone[req.method]({ sealed: req.sealed }, () => {}); } catch (_) {}
|
|
});
|
|
return;
|
|
}
|
|
if (side.via && side.via !== from && liveRpc(side.via)) {
|
|
try { liveRpc(side.via).phone.relay(msg, () => {}); } catch (_) {}
|
|
return;
|
|
}
|
|
if (forwarded || req.method !== 'ring') return;
|
|
relayAllowed(from, from, (ok) => {
|
|
if (!ok) return;
|
|
for (const pub of knownPubs().filter(id => id !== from)) {
|
|
try { liveRpc(pub).phone.relay(msg, () => {}); } catch (_) {}
|
|
}
|
|
});
|
|
},
|
|
relayAudio(opts) {
|
|
const from = this && this.id;
|
|
const refuse = { source: pull.error(new Error('no-route')), sink: pull.drain(null, () => {}) };
|
|
if (!from || !opts || !isFeedId(opts.to) || opts.to === from || policy().relay === false) return refuse;
|
|
const forwarded = knownPubs().includes(from) && isFeedId(opts.from);
|
|
const caller = forwarded ? opts.from : from;
|
|
const r = routes.get(String(opts.callId || ''));
|
|
const side = r && r.sides.find(x => x.id === opts.to);
|
|
const direct = liveRpc(opts.to);
|
|
const next = direct || (side && side.via && side.via !== from ? liveRpc(side.via) : null);
|
|
if (!next) return refuse;
|
|
const remote = direct
|
|
? next.phone.audio({ callId: opts.callId, from: caller }, () => {})
|
|
: next.phone.relayAudio({ to: opts.to, callId: opts.callId, from: caller }, () => {});
|
|
return { source: remote.source, sink: remote.sink };
|
|
},
|
|
video(opts) {
|
|
const direct = this && this.id;
|
|
const from = opts && isFeedId(opts.from) ? opts.from : direct;
|
|
const c = call;
|
|
const viaOk = direct === c?.peer || (c?.via && direct === c.via);
|
|
if (!c || c.dir !== 'in' || !c.keys || !c.video || c.vmedia || from !== c.peer || !viaOk || !opts || opts.callId !== c.id || (c.phase !== 'connecting' && c.phase !== 'connected')) {
|
|
return { source: pull.error(new Error('no-call')), sink: pull.drain(null, () => {}) };
|
|
}
|
|
return startVideo(c, null, 'in');
|
|
},
|
|
relayVideo(opts) {
|
|
const from = this && this.id;
|
|
const refuse = { source: pull.error(new Error('no-route')), sink: pull.drain(null, () => {}) };
|
|
if (!from || !opts || !isFeedId(opts.to) || opts.to === from || policy().relay === false || policy().video === false) return refuse;
|
|
const forwarded = knownPubs().includes(from) && isFeedId(opts.from);
|
|
const caller = forwarded ? opts.from : from;
|
|
const r = routes.get(String(opts.callId || ''));
|
|
const side = r && r.sides.find(x => x.id === opts.to);
|
|
const direct = liveRpc(opts.to);
|
|
const next = direct || (side && side.via && side.via !== from ? liveRpc(side.via) : null);
|
|
if (!next) return refuse;
|
|
const remote = direct
|
|
? next.phone.video({ callId: opts.callId, from: caller }, () => {})
|
|
: next.phone.relayVideo({ to: opts.to, callId: opts.callId, from: caller }, () => {});
|
|
return { source: remote.source, sink: remote.sink };
|
|
},
|
|
camera(flag, cb) {
|
|
const on = !!flag;
|
|
const c = call;
|
|
if (c && c.vmedia && !c.group) {
|
|
if (on === !!c.cam) return cb(null, snapshot());
|
|
if (on) {
|
|
if (!startCam((jpeg, seq) => { if (call === c) for (const plain of videoChunks(seq, jpeg)) sendCallVideo(c, plain); })) return cb(new Error('unavailable'));
|
|
c.cam = true;
|
|
} else {
|
|
c.cam = false;
|
|
stopCam();
|
|
sendCallVideo(c, videoOff());
|
|
}
|
|
changed();
|
|
return cb(null, snapshot());
|
|
}
|
|
const r = room;
|
|
if (r && r.phase === 'live') {
|
|
if (on === !!r.cam) return cb(null, roomSnapshot());
|
|
if (on) {
|
|
if (!r.hubVideo || !startCam((jpeg, seq) => { if (room !== r) return; gridNote(r, server.id, jpeg); sendRoomJpeg(r, seq, jpeg, 0); })) return cb(new Error('unavailable'));
|
|
r.cam = true;
|
|
} else {
|
|
r.cam = false;
|
|
stopCam();
|
|
gridNote(r, server.id, null);
|
|
sendRoomVideo(r, videoOff());
|
|
}
|
|
roomChanged();
|
|
return cb(null, roomSnapshot());
|
|
}
|
|
cb(new Error('no-call'));
|
|
},
|
|
stream(opts, cb) {
|
|
const o = opts && typeof opts === 'object' ? opts : {};
|
|
const r = room;
|
|
if (!r || r.phase !== 'live') return cb(new Error('no-room'));
|
|
if (o.stop) { stopRoomStream(r); return cb(null, roomSnapshot()); }
|
|
const file = streamFile(o.file);
|
|
if (!file) return cb(new Error('invalid'));
|
|
if (!startRoomStream(r, file, o.name || path.basename(file))) return cb(new Error('unavailable'));
|
|
roomChanged();
|
|
cb(null, roomSnapshot());
|
|
},
|
|
viewerJoin(opts, cb) {
|
|
const o = opts && typeof opts === 'object' ? opts : {};
|
|
const rid = String(o.rid || '');
|
|
if (!RID.test(rid) || !isFeedId(o.owner) || !isFeedId(o.hub) || typeof o.token !== 'string') return cb(new Error('invalid'));
|
|
if (viewers.has(rid)) return cb(null, true);
|
|
if (room && room.rid === rid) return cb(new Error('busy'));
|
|
const r = {
|
|
rid, ref: String(o.ref || ''), title: '', hub: o.hub, phase: 'joining', joinedAt: Date.now(), muted: true, slot: null, max: ROOM_MAX,
|
|
peers: new Map(), order: [], keys: new Map(), kid: null, sealer: null, static: false, sentAt: 0,
|
|
events: [], notify: false, rec: null, recPeers: new Set(), hand: false, handAt: 0, tag: `pub:${rid}:`, grid: newGrid()
|
|
};
|
|
viewers.set(rid, r);
|
|
r.out = Pushable(() => {});
|
|
r.ready = (err) => {
|
|
r.ready = null;
|
|
clearTimeout(r.timer);
|
|
if (err) { dropViewer(r); return cb(err); }
|
|
startViewerMedia(r);
|
|
startGrid(r);
|
|
cb(null, true);
|
|
};
|
|
r.timer = setTimeout(() => { if (r.ready) r.ready(new Error('unreachable')); }, ROOM_JOIN_MS);
|
|
const attach = (duplex) => {
|
|
if (viewers.get(rid) !== r || !r.ready) return closeDuplex(duplex);
|
|
if (!duplex) return r.ready(new Error('unreachable'));
|
|
pull(r.out, duplex.sink);
|
|
pull(duplex.source, pull.drain((frame) => onRoomFrame(r, frame), () => {
|
|
if (viewers.get(rid) !== r) return;
|
|
if (r.ready) r.ready(new Error('unreachable'));
|
|
else dropViewer(r);
|
|
}));
|
|
};
|
|
openHub(o, { rid, owner: o.owner, token: o.token, viewer: true, ...(RID.test(String(o.roomId || '')) ? { roomId: String(o.roomId) } : {}) }, attach);
|
|
},
|
|
viewerLeave(opts, cb) {
|
|
dropViewer(viewers.get(String((opts && opts.rid) || '')));
|
|
cb(null, true);
|
|
},
|
|
viewerRooms() { return [...viewers.values()].filter(r => r.phase === 'live').map(r => r.rid); },
|
|
publicFeed(opts) {
|
|
const rid = String((opts && opts.rid) || '');
|
|
const kind = opts && opts.kind === 'sound' ? 'sound' : 'grid';
|
|
const r = viewers.get(rid);
|
|
const target = r ? r.tag + kind : '';
|
|
const p = Pushable(() => { const set = frameWatchers.get(target); if (set) { set.delete(p); if (!set.size) frameWatchers.delete(target); } });
|
|
if (!r || r.phase !== 'live' || (kind === 'grid' && !sharpLib)) { p.end(); return p; }
|
|
if (!frameWatchers.has(target)) frameWatchers.set(target, new Set());
|
|
frameWatchers.get(target).add(p);
|
|
if (kind === 'grid') { r.grid.dirty = true; r.grid.empty = false; }
|
|
return p;
|
|
},
|
|
videoFrames(opts) {
|
|
const target = String((opts && opts.peer) || '');
|
|
const p = Pushable(() => { const set = frameWatchers.get(target); if (set) { set.delete(p); if (!set.size) frameWatchers.delete(target); } });
|
|
const now = Date.now();
|
|
const filmOf = target.startsWith('film:') && target !== 'film:me' ? target.slice(5) : null;
|
|
const live = target === 'me'
|
|
? !!cam
|
|
: target === 'film:me' ? !!(room && room.stream)
|
|
: target === 'grid' ? !!(room && sharpLib && gridIds(room).length)
|
|
: filmOf ? !!room && [...room.peers.values()].some(x => x.id === filmOf && now - (x.filmAt || 0) < VIDEO_HOLD_MS)
|
|
: call ? (call.peer === target && !!call.peerCam)
|
|
: room ? [...room.peers.values()].some(x => x.id === target && now - (x.frameAt || 0) < VIDEO_HOLD_MS)
|
|
: false;
|
|
if (!live) { p.end(); return p; }
|
|
if (!frameWatchers.has(target)) frameWatchers.set(target, new Set());
|
|
frameWatchers.get(target).add(p);
|
|
return p;
|
|
},
|
|
|
|
call(target, opts, cb) {
|
|
if (typeof opts === 'function') { cb = opts; opts = {}; }
|
|
const legacy = new Set(Array.isArray(opts && opts.legacy) ? opts.legacy : []);
|
|
if (!audio) return cb(new Error('unavailable'));
|
|
const targets = [...new Set(Array.isArray(target) ? target : [target])];
|
|
if (!targets.length || targets.length > GROUP_MAX || targets.some(t => !isFeedId(t) || t === server.id)) return cb(new Error('invalid'));
|
|
if (call || room) return cb(new Error('busy'));
|
|
if (targets.length > 1) return groupCall(targets, legacy, cb);
|
|
target = targets[0];
|
|
const id = nodeCrypto.randomBytes(16).toString('hex');
|
|
const eph = sodium.crypto_box_keypair();
|
|
const startedAt = Date.now();
|
|
const c = { id, peer: target, dir: 'out', phase: 'calling', startedAt, ringUntil: startedAt + ringMs, eph, paths: [], via: null };
|
|
call = c;
|
|
c.tone = startTone(audio, 'connecting');
|
|
c.timer = setTimeout(() => toVoicemail(c, 'unreachable'), ringMs);
|
|
changed();
|
|
ringPaths(c, target, ringRequest(id, eph, target), () => call === c && c.phase === 'calling', () => reachedCall(c), legacy.has(target));
|
|
cb(null, snapshot());
|
|
},
|
|
accept(cb) {
|
|
const c = call;
|
|
if (!c || c.dir !== 'in' || c.phase !== 'incoming') return cb(new Error('no-call'));
|
|
const eph = sodium.crypto_box_keypair();
|
|
c.keys = deriveKeys(eph.secretKey, c.remotePk, c.id, c.remotePk, Buffer.from(eph.publicKey));
|
|
stopTone(c);
|
|
clearTimeout(c.timer);
|
|
c.phase = 'connecting';
|
|
c.answeredAt = Date.now();
|
|
changed();
|
|
const ts = Date.now();
|
|
const ephPk = b64(eph.publicKey);
|
|
sendVia(c.via || null, c.peer, 'answer', { callId: c.id, from: server.id, ephPk, ts, sig: signParts(keys, ['answer', c.id, ephPk, c.peer, ts]), codec: c.codec, video: c.video }, (err) => {
|
|
if (call !== c) return;
|
|
if (err) return finish('failed');
|
|
if (c.phase === 'connecting') { c.phase = 'connected'; changed(); }
|
|
});
|
|
cb(null, snapshot());
|
|
},
|
|
reject(cb) {
|
|
const c = call;
|
|
if (c && c.dir === 'in' && c.phase === 'incoming') finish('rejected');
|
|
cb(null, snapshot());
|
|
},
|
|
end(cb) {
|
|
const c = call;
|
|
if (!c) return cb(null, null);
|
|
if (c.group) {
|
|
for (const leg of c.legs) if (leg.phase === 'calling' || leg.phase === 'connected') hangupLeg(leg);
|
|
finish(c.answeredAt ? 'ended' : 'cancelled');
|
|
return cb(null, null);
|
|
}
|
|
if (c.dir === 'in' && c.phase === 'incoming') { finish('rejected'); return cb(null, null); }
|
|
if (c.phase === 'calling' || c.phase === 'connecting' || c.phase === 'connected') notifyHangup(c);
|
|
finish(c.phase === 'connected' || c.phase === 'connecting' ? 'ended' : (c.phase === 'calling' ? 'cancelled' : 'noanswer'));
|
|
cb(null, null);
|
|
},
|
|
mute(flag, cb) {
|
|
if (call) { call.muted = !!flag; changed(); }
|
|
cb(null, snapshot());
|
|
},
|
|
silence(opts, cb) {
|
|
const id = opts && opts.id;
|
|
if (!isFeedId(id) || id === server.id) return cb(new Error('invalid'));
|
|
if (opts.on) silenced.add(id); else silenced.delete(id);
|
|
if (call) changed();
|
|
if (room) roomChanged();
|
|
cb(null, { id, on: silenced.has(id) });
|
|
},
|
|
dismiss(cb) {
|
|
const c = call;
|
|
if (c && c.dir === 'out' && (c.phase === 'noanswer' || c.phase === 'recorded')) finish('noanswer');
|
|
cb(null, snapshot());
|
|
},
|
|
recordStop(cb) {
|
|
const c = call;
|
|
if (!c || c.dir !== 'out' || (c.phase !== 'recording' && c.phase !== 'recorded') || !c.rec) return cb(new Error('no-recording'));
|
|
stopRecorder(c);
|
|
const pcm = Buffer.concat(c.rec.chunks);
|
|
c.rec = null;
|
|
if (pcm.length < RATE * 2 * PAM_MIN_MS / 1000) { finish('noanswer'); return cb(new Error('empty')); }
|
|
const file = path.join(os.tmpdir(), `oasis-voice-${c.id}.wav`);
|
|
try { fs.writeFileSync(file, wavFile(pcm), { mode: 0o600 }); } catch (err) { return cb(err); }
|
|
const out = { file, durationMs: Math.round(pcm.length / (RATE * 2) * 1000), peer: c.peer, callId: c.id };
|
|
finish('pam');
|
|
cb(null, out);
|
|
},
|
|
recordCancel(cb) {
|
|
const c = call;
|
|
if (c && (c.phase === 'recording' || c.phase === 'recorded')) {
|
|
stopRecorder(c);
|
|
c.rec = null;
|
|
finish('noanswer');
|
|
}
|
|
cb(null, snapshot());
|
|
},
|
|
roomHub(opts) { return hubDuplex(this && this.id, opts); },
|
|
roomInfo(opts, cb) {
|
|
const r = hubRooms.get(String((opts && opts.rid) || ''));
|
|
cb(null, { count: r ? hubHumans(r) : 0, max: roomMax, video: policy().video !== false });
|
|
},
|
|
roomAdmits(opts, cb) {
|
|
const from = this && this.id;
|
|
if (!from) return cb(null, false);
|
|
relayAllowed(from, from, (ok) => cb(null, !!ok));
|
|
},
|
|
roomJoin(opts, cb) {
|
|
if (!audio) return cb(new Error('unavailable'));
|
|
if (call || room) return cb(new Error('busy'));
|
|
const rid = String((opts && opts.rid) || '');
|
|
if (viewers.has(rid)) dropViewer(viewers.get(rid));
|
|
if (!RID.test(rid) || !isFeedId(opts.owner) || !isFeedId(opts.hub) || typeof opts.token !== 'string') return cb(new Error('invalid'));
|
|
const secrets = (Array.isArray(opts.secrets) ? opts.secrets : []).filter(x => typeof x === 'string' && x);
|
|
const r = {
|
|
rid, ref: String(opts.ref || ''), title: String(opts.title || '').slice(0, 200), hub: opts.hub,
|
|
phase: 'joining', joinedAt: Date.now(), muted: false, slot: null, max: ROOM_MAX, peers: new Map(), order: [],
|
|
keys: new Map(), kid: null, sealer: null, static: secrets.length > 0, sentAt: 0,
|
|
events: [], notify: true, rec: null, recPeers: new Set(), hand: false, handAt: 0, tag: '', grid: newGrid()
|
|
};
|
|
if (r.static) {
|
|
for (const secret of secrets.slice().reverse()) { const k = staticKey(rid, secret); r.keys.set(k.kid, k.key); }
|
|
const first = staticKey(rid, secrets[0]);
|
|
r.kid = first.kid;
|
|
r.sealer = sealer(first.key);
|
|
}
|
|
room = r;
|
|
r.out = Pushable(() => {});
|
|
r.ready = (err) => {
|
|
r.ready = null;
|
|
clearTimeout(r.timer);
|
|
if (err) { leaveRoom(r, null); return cb(err); }
|
|
startRoomMedia(r);
|
|
startGrid(r);
|
|
roomChanged();
|
|
cb(null, roomSnapshot());
|
|
};
|
|
r.timer = setTimeout(() => { if (r.ready) r.ready(new Error('unreachable')); }, ROOM_JOIN_MS);
|
|
const attach = (duplex) => {
|
|
if (room !== r || !r.ready) return closeDuplex(duplex);
|
|
if (!duplex) return r.ready(new Error('unreachable'));
|
|
pull(r.out, duplex.sink);
|
|
pull(duplex.source, pull.drain((frame) => onRoomFrame(r, frame), () => {
|
|
if (room !== r) return;
|
|
if (r.ready) r.ready(new Error('unreachable'));
|
|
else leaveRoom(r, 'lost');
|
|
}));
|
|
};
|
|
openHub(opts, { rid, owner: opts.owner, token: opts.token, ...(RID.test(String(opts.roomId || '')) ? { roomId: String(opts.roomId) } : {}) }, attach);
|
|
},
|
|
roomLeave(cb) {
|
|
leaveRoom(room, 'left');
|
|
cb(null, null);
|
|
},
|
|
roomRecStart(cb) { cb(null, startRoomRec(room) ? roomSnapshot() : null); },
|
|
roomRecStop(cb) { cb(null, stopRoomRec(room) ? roomSnapshot() : null); },
|
|
roomNotify(flag, cb) {
|
|
if (room) { room.notify = !!flag; roomChanged(); }
|
|
cb(null, roomSnapshot());
|
|
},
|
|
roomClearEvents(cb) {
|
|
if (room) { room.events = []; roomChanged(); }
|
|
cb(null, roomSnapshot());
|
|
},
|
|
roomHand(flag, cb) {
|
|
if (room) {
|
|
room.hand = !!flag;
|
|
room.handAt = room.hand ? Date.now() : 0;
|
|
sendRoomData(room, { t: 'hand', on: room.hand, at: room.handAt });
|
|
room.handBeaconAt = Date.now();
|
|
roomChanged();
|
|
}
|
|
cb(null, roomSnapshot());
|
|
},
|
|
roomMute(flag, cb) {
|
|
if (room) { room.muted = !!flag; sendRoomData(room, { t: 'mute', on: room.muted }); roomChanged(); }
|
|
cb(null, roomSnapshot());
|
|
},
|
|
roomCount(opts, cb) {
|
|
const rid = String((opts && opts.rid) || '');
|
|
if (!RID.test(rid) || !isFeedId(opts.hub)) return cb(null, null);
|
|
if (room && room.rid === rid && room.phase === 'live') return cb(null, { count: humansIn(room) + 1, max: room.max });
|
|
if (opts.hub === server.id) { const h = hubRooms.get(rid); return cb(null, { count: h ? hubHumans(h) : 0, max: roomMax }); }
|
|
const rpc = liveRpc(opts.hub);
|
|
if (!rpc || typeof rpc.phone.roomInfo !== 'function') return cb(null, null);
|
|
let done = false;
|
|
const finishPeek = (v) => { if (done) return; done = true; clearTimeout(t); cb(null, v); };
|
|
const t = setTimeout(() => finishPeek(null), ROOM_PEEK_MS);
|
|
try {
|
|
rpc.phone.roomInfo({ rid }, (err, info) => finishPeek(!err && info && Number.isFinite(Number(info.count)) ? { count: Number(info.count), max: Number(info.max) > 0 ? Math.min(ROOM_MAX, Number(info.max)) : ROOM_MAX } : null));
|
|
} catch (_) { finishPeek(null); }
|
|
},
|
|
roomState() { return roomSnapshot(); },
|
|
roomToken(rid) { return RID.test(String(rid || '')) ? signParts(keys, ['room', String(rid)]) : null; },
|
|
roomHubFor(cb) {
|
|
const done = typeof cb === 'function' ? cb : () => {};
|
|
let entries = [];
|
|
try { entries = netFirst(server.conn.query().peersAll()); } catch (_) {}
|
|
const ranked = typeof server.oasisPeerRank === 'function' ? (id) => server.oasisPeerRank([null, { key: id }]) : () => 0;
|
|
const candidates = knownPubs().filter(id => { const rpc = liveRpc(id); return rpc && rpc.phone && typeof rpc.phone.roomHub === 'function'; }).sort((x, y) => ranked(y) - ranked(x));
|
|
const admits = (id, next) => {
|
|
const rpc = liveRpc(id);
|
|
const byGraph = () => relation('isFollowing', id, server.id, next);
|
|
if (!rpc || typeof rpc.phone.roomAdmits !== 'function') return byGraph();
|
|
let settled = false;
|
|
const finishAsk = (fn) => { if (settled) return; settled = true; clearTimeout(t); fn(); };
|
|
const t = setTimeout(() => finishAsk(() => next(false)), ROOM_PEEK_MS);
|
|
try { rpc.phone.roomAdmits({}, (err, ok) => finishAsk(err ? byGraph : () => next(!!ok))); } catch (_) { finishAsk(byGraph); }
|
|
};
|
|
const pick = (i) => {
|
|
if (i >= candidates.length) return done(null, { key: server.id, address: '' });
|
|
admits(candidates[i], (ok) => {
|
|
if (!ok) return pick(i + 1);
|
|
const hit = entries.find(e => Array.isArray(e) && e[0] && e[1] && e[1].key === candidates[i]);
|
|
done(null, { key: candidates[i], address: hit ? hit[0] : '' });
|
|
});
|
|
};
|
|
pick(0);
|
|
},
|
|
state() { return snapshot(); },
|
|
available() { return !!audio; },
|
|
pubs() { return [...pubKeys()]; },
|
|
events() {
|
|
const p = Pushable(() => subscribers.delete(p));
|
|
subscribers.add(p);
|
|
p.push({ type: 'state', state: snapshot() });
|
|
return p;
|
|
}
|
|
};
|
|
}
|
|
};
|
|
|
|
module.exports.setAudioFactory = (factory) => { audioFactory = typeof factory === 'function' ? factory : systemAudio; };
|
|
module.exports.setVideoFactory = (factory) => { videoFactory = typeof factory === 'function' ? factory : systemVideo; };
|
|
module.exports.setStreamFactory = (factory) => { streamFactory = typeof factory === 'function' ? factory : systemStream; };
|
|
module.exports.jpegSplitter = jpegSplitter;
|
|
module.exports.VIDEO_CHUNK = VIDEO_CHUNK;
|
|
module.exports.VIDEO_HOLD_MS = VIDEO_HOLD_MS;
|
|
module.exports.ROOM_VIDEO_BYTES_PER_S = ROOM_VIDEO_BYTES_PER_S;
|
|
module.exports.ulawEncode = ulawEncode;
|
|
module.exports.ulawDecode = ulawDecode;
|
|
module.exports.RING_MS = RING_MS;
|
|
module.exports.GROUP_MAX = GROUP_MAX;
|
|
module.exports.ROOM_MAX = ROOM_MAX;
|
|
module.exports.RING_PER_WINDOW = RING_PER_WINDOW;
|
|
module.exports.chimeMs = chimeMs;
|
|
module.exports.recordingPrefix = recordingPrefix;
|
|
module.exports.VOICEMAIL_MAX_MS = VOICEMAIL_MAX_MS;
|
|
module.exports.SOUND_RATE = RATE;
|