a0b3936799
Calls only notified over the chat WebSocket (CHAT.pushToUser), so a CLOSED app (no live socket) never rang — unlike messages, which also call PUSH.sendToUser. Add PUSH.sendToUser for both DM (startDmCall -> callee) and group (startGroupCall -> other members) so APNs/FCM/WebPush alerts a closed device. To make the alert actionable, add CALLS.replayActiveCalls(userId, ws), invoked from the chat-hello handler: when a socket (re)connects, re-send any dm-call / group-call the user is currently being rung into (the original events fire once at call start and are missed by an app that was closed). Opening the app from the push then re-surfaces the invite so they can answer within the ring window. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
204 lines
15 KiB
JavaScript
204 lines
15 KiB
JavaScript
// Shared group calls: one live call per group. Members join without a code; the call
|
|
// ends (with a duration line in the chat) when the last participant's mesh room empties.
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const R = require('./repos');
|
|
const A = require('./auth');
|
|
const CHAT = require('./chat');
|
|
const PUSH = require('./push'); // native push (APNs/FCM/WebPush) so a CLOSED app is notified of calls
|
|
const { TRANS_DIR } = require('./config');
|
|
const { meetingRooms, groupCalls, roomToGroupCall, dmCalls, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
|
|
const now = () => Date.now();
|
|
const pairKey = (a, b) => [a, b].sort().join('|');
|
|
|
|
// Resolve a room's meeting context (group / scheduled meeting / title) for labelling recordings.
|
|
async function meetingContext(room) {
|
|
const ctx = { groupId: null, meetingId: null, title: 'Meeting' };
|
|
try {
|
|
const sched = await R.scheduledMeetings.byCode(room);
|
|
if (sched) { ctx.meetingId = sched.id; ctx.groupId = sched.group_id || null; ctx.title = sched.title || 'Meeting'; }
|
|
} catch (_) {}
|
|
if (!ctx.groupId) { const gid = roomToGroupCall.get(room); if (gid) ctx.groupId = gid; }
|
|
if (ctx.groupId && ctx.title === 'Meeting') { try { const g = await R.conversations.byId(ctx.groupId); if (g) ctx.title = g.name || 'Group'; } catch (_) {} }
|
|
if (!ctx.groupId && !ctx.meetingId && roomToDmCall.has(room)) ctx.title = 'Direct Call';
|
|
return ctx;
|
|
}
|
|
|
|
// Save the FULL shared conversation transcript as a PRIVATE copy for each subscriber. onlyUserId
|
|
// finalizes just that subscriber (on their leave / opt-out); omit to flush all remaining (room end).
|
|
// Must run BEFORE endCallByRoom (which clears the room→meeting maps meetingContext relies on).
|
|
async function finalizeTranscript(room, onlyUserId) {
|
|
const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; }
|
|
const buf = transcriptBuffers.get(room) || [];
|
|
const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
|
|
if (ids.length && buf.length) {
|
|
const ctx = await meetingContext(room);
|
|
const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; });
|
|
const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
|
|
for (const uid of ids) {
|
|
let user = null; try { user = await R.users.byId(uid); } catch (_) {}
|
|
if (!user) { subs.delete(uid); continue; }
|
|
const id = A.id(); const file = 'm_' + id + '.txt';
|
|
try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
|
|
// groupId null → private to its creator (see canSeeRec / /mrec auth).
|
|
await R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email });
|
|
subs.delete(uid);
|
|
}
|
|
} else { ids.forEach((uid) => subs.delete(uid)); }
|
|
if (!subs.size) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } // last subscriber done
|
|
}
|
|
|
|
function fmtDur(ms) { const s = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(s / 60); return m ? (m + 'm ' + (s % 60) + 's') : (s + 's'); }
|
|
|
|
async function broadcast(group, evt) { try { for (const mid of await R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} }
|
|
|
|
// Post a centered activity line into the group (system sender → no ping on clients).
|
|
async function postSystem(group, teamId, text) {
|
|
const id = A.id();
|
|
await R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group });
|
|
const m = await R.messages.byId(id);
|
|
broadcast(group, { type: 'chat-message', message: { id: m.id, from: '__system__', conversation_id: group, body: m.body, created_at: m.created_at, system: true } });
|
|
}
|
|
|
|
async function startGroupCall(group, teamId, user) {
|
|
const existing = groupCalls.get(group);
|
|
if (existing) return { room: existing.room, active: true, already: true };
|
|
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
|
|
meetingRooms.set(room, new Map());
|
|
const call = { room, startedAt: now(), startedBy: user.id, startedByName: user.name || user.email };
|
|
// Log the call as a meeting so it appears under Past meetings (history) with the group name.
|
|
try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {}
|
|
groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host
|
|
postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call').catch(() => {});
|
|
let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
|
|
broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName });
|
|
// Native push to the OTHER members so a closed app (no live WS) is notified of the group call. The
|
|
// broadcast() above only reaches connected sockets. Best-effort; sendToUser never throws.
|
|
try { for (const mid of await R.conversations.members(group)) { if (mid !== user.id) PUSH.sendToUser(mid, { title: gName, body: '📞 ' + call.startedByName + ' started a group call', kind: 'group', id: group, room, tag: 'call:' + room, data: { kind: 'group', id: group, room } }); } } catch (_) {}
|
|
return { room, active: true };
|
|
}
|
|
|
|
// Called from signaling when a mesh room empties — ends the group call if this room was one.
|
|
async function endGroupCallByRoom(room) {
|
|
const group = roomToGroupCall.get(room);
|
|
if (!group) return;
|
|
const call = groupCalls.get(group);
|
|
roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room);
|
|
if (call) {
|
|
let teamId = call.teamId; try { const g = await R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)).catch(() => {}); } } catch (_) {}
|
|
if (call.historyId && teamId) { try { await R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
|
|
broadcast(group, { type: 'group-call', group, active: false, room });
|
|
}
|
|
}
|
|
|
|
// 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join".
|
|
async function startDmCall(me, otherId, teamId) {
|
|
const key = pairKey(me.id, otherId);
|
|
const existing = dmCalls.get(key);
|
|
if (existing) return { room: existing.room, active: true, already: true };
|
|
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
|
|
meetingRooms.set(room, new Map());
|
|
const byName = me.name || me.email;
|
|
const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false };
|
|
// Log to history (both participants) so the call shows under Past meetings with its transcript.
|
|
try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {}
|
|
dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host
|
|
// #9 (unanswered): if the callee never joins within the ring window, auto-end and mark it missed —
|
|
// so the caller isn't stuck "ringing" forever.
|
|
call.ringTimer = setTimeout(() => {
|
|
if (call.answered) return;
|
|
const peers = meetingRooms.get(room);
|
|
if (peers) { for (const [, p] of peers) { if (p.ws && p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended', reason: 'unanswered' })); } catch (_) {} p.ws._meetingRoom = null; } } meetingRooms.delete(room); }
|
|
endDmCallByRoom(room);
|
|
}, 40000);
|
|
// A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
|
|
const mid = A.id();
|
|
await R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' });
|
|
const m = await R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName };
|
|
try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {}
|
|
try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {}
|
|
try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, with: me.id, by: me.id, byName }); } catch (_) {}
|
|
try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, with: otherId, by: me.id, byName }); } catch (_) {}
|
|
// Native push so a CLOSED app (no live WS) still rings — the CHAT.pushToUser events above only reach a
|
|
// connected socket. Best-effort. On reconnect the callee's app re-shows this invite (replayActiveCalls),
|
|
// so tapping the banner into the app lets them answer while the caller is still ringing.
|
|
try { PUSH.sendToUser(otherId, { title: byName, body: '📞 Incoming call', kind: 'dm', id: me.id, room, tag: 'call:' + room, icon: me.avatar_url || undefined, data: { kind: 'dm', id: me.id, room } }); } catch (_) {}
|
|
return { room, active: true };
|
|
}
|
|
|
|
async function endDmCallByRoom(room, silent) {
|
|
const key = roomToDmCall.get(room); if (!key) return;
|
|
const call = dmCalls.get(key);
|
|
roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room);
|
|
if (!call) return;
|
|
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} }
|
|
if (call.historyId && call.teamId) { try { await R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
|
|
// Activity line: a duration ONLY if the call was answered; otherwise "Missed call" (#7/#9).
|
|
if (!silent) try {
|
|
const mid = A.id(); const body = call.answered ? ('📞 Call ended · ' + fmtDur(now() - (call.answeredAt || call.startedAt))) : '📞 Missed call';
|
|
await R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' });
|
|
const m = await R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' };
|
|
call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
|
|
} catch (_) {}
|
|
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} });
|
|
}
|
|
|
|
// Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end
|
|
// message shows a real duration (from answer) and the unanswered timeout stands down.
|
|
function markDmAnswered(room, userId) {
|
|
const key = roomToDmCall.get(room); if (!key) return;
|
|
const call = dmCalls.get(key); if (!call) return;
|
|
if (userId && userId !== call.startedBy && !call.answered) { call.answered = true; call.answeredAt = now(); if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; } }
|
|
}
|
|
// When a user's chat socket (re)connects, re-send any call they're currently being rung into. The
|
|
// original dm-call / group-call events fire ONCE at call start, so an app that was closed then misses
|
|
// them. This makes the call PUSH actionable: tapping the banner opens the app, the socket connects, and
|
|
// the invite re-appears so they can answer (while the caller is still within the ring window). Sends only
|
|
// to the freshly-connected socket. Best-effort; never throws.
|
|
async function replayActiveCalls(userId, ws) {
|
|
if (!userId || !ws || ws.readyState !== 1) return;
|
|
try {
|
|
for (const [, call] of dmCalls) {
|
|
if (call.answered) continue;
|
|
if (call.users.includes(userId) && call.startedBy !== userId) {
|
|
try { ws.send(JSON.stringify({ type: 'dm-call', active: true, room: call.room, with: call.startedBy, by: call.startedBy, byName: call.startedByName })); } catch (_) {}
|
|
}
|
|
}
|
|
for (const [group, call] of groupCalls) {
|
|
if (call.startedBy === userId) continue;
|
|
let member = false; try { member = await R.conversations.isMember(group, userId); } catch (_) {}
|
|
if (!member) continue;
|
|
let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
|
|
try { ws.send(JSON.stringify({ type: 'group-call', group, active: true, room: call.room, by: call.startedBy, startedByName: call.startedByName, groupName: gName })); } catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
// Called from signaling when any mesh room empties.
|
|
async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDmCallByRoom(room); }
|
|
|
|
// Callee declines a 1:1 call: post "Call declined" into the DM, drop the waiting caller, end it.
|
|
async function declineDmCall(room, byUser) {
|
|
const key = roomToDmCall.get(room); if (!key) return { ok: false };
|
|
const call = dmCalls.get(key); if (!call) return { ok: false };
|
|
// #8: if this user has ALREADY accepted on another device (they're in the room), this is just the
|
|
// ringing invite on a second device — dismiss it silently, do NOT tear down the active call.
|
|
const inRoom = meetingRooms.get(room);
|
|
if (inRoom) { for (const [, p] of inRoom) { if (p.ws && p.ws._meetingUserId === byUser.id) return { ok: true, alreadyJoined: true }; } }
|
|
const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy;
|
|
try {
|
|
const mid = A.id();
|
|
await R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: '📞 Call declined', msgType: 'call-end' });
|
|
const mm = await R.messages.byId(mid); const dto = { id: mm.id, from: byUser.id, to: callerId, conversation_id: null, body: mm.body, created_at: mm.created_at, system: true, evt: 'call-end' };
|
|
CHAT.pushToUser(callerId, { type: 'chat-message', message: dto });
|
|
CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto });
|
|
} catch (_) {}
|
|
// Drop the caller who's still waiting in the (otherwise empty) mesh room.
|
|
const peers = meetingRooms.get(room);
|
|
if (peers) { for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p._meetingRoom = null; } } meetingRooms.delete(room); }
|
|
endDmCallByRoom(room, true); // silent: we already posted "Call declined"
|
|
return { ok: true };
|
|
}
|
|
|
|
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, replayActiveCalls, finalizeTranscript, meetingContext, fmtDur, pairKey };
|