2026-06-23 16:27:59 +05:30
// 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' );
2026-07-28 20:57:56 +05:30
const crypto = require ( 'crypto' );
2026-06-23 16:27:59 +05:30
const R = require ( './repos' );
const A = require ( './auth' );
const CHAT = require ( './chat' );
2026-07-27 22:13:52 +05:30
const PUSH = require ( './push' ); // native push (APNs/FCM/WebPush) so a CLOSED app is notified of calls
2026-07-30 16:11:21 +05:30
const LK = require ( './livekit' ); // mint the callee's LiveKit join token for the native VoIP call payload
2026-06-23 16:27:59 +05:30
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.
2026-07-24 22:06:27 +05:30
async function meetingContext ( room ) {
2026-06-23 16:27:59 +05:30
const ctx = { groupId : null , meetingId : null , title : 'Meeting' };
try {
2026-07-24 21:26:10 +05:30
const sched = await R . scheduledMeetings . byCode ( room );
2026-06-23 16:27:59 +05:30
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 ; }
2026-07-24 21:26:10 +05:30
if ( ctx . groupId && ctx . title === 'Meeting' ) { try { const g = await R . conversations . byId ( ctx . groupId ); if ( g ) ctx . title = g . name || 'Group' ; } catch ( _ ) {} }
2026-06-23 16:27:59 +05:30
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).
2026-07-24 22:06:27 +05:30
async function finalizeTranscript ( room , onlyUserId ) {
2026-06-23 16:27:59 +05:30
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 ) {
2026-07-24 22:06:27 +05:30
const ctx = await meetingContext ( room );
2026-06-23 16:27:59 +05:30
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 ) {
2026-07-24 21:26:10 +05:30
let user = null ; try { user = await R . users . byId ( uid ); } catch ( _ ) {}
2026-06-23 16:27:59 +05:30
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).
2026-07-24 21:26:10 +05:30
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 });
2026-06-23 16:27:59 +05:30
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' ); }
2026-07-24 22:06:27 +05:30
async function broadcast ( group , evt ) { try { for ( const mid of await R . conversations . members ( group )) CHAT . pushToUser ( mid , evt ); } catch ( _ ) {} }
2026-06-23 16:27:59 +05:30
// Post a centered activity line into the group (system sender → no ping on clients).
2026-07-24 22:06:27 +05:30
async function postSystem ( group , teamId , text ) {
2026-06-23 16:27:59 +05:30
const id = A . id ();
2026-07-24 21:26:10 +05:30
await R . messages . send ({ id , teamId , senderId : '__system__' , recipientId : '' , body : text , conversationId : group });
const m = await R . messages . byId ( id );
2026-06-23 16:27:59 +05:30
broadcast ( group , { type : 'chat-message' , message : { id : m . id , from : '__system__' , conversation_id : group , body : m . body , created_at : m . created_at , system : true } });
}
2026-07-24 22:06:27 +05:30
async function startGroupCall ( group , teamId , user ) {
2026-06-23 16:27:59 +05:30
const existing = groupCalls . get ( group );
2026-07-28 22:34:27 +05:30
if ( existing ) return { room : existing . room , uuid : existing . uuid , active : true , already : true };
2026-06-23 16:27:59 +05:30
let room ; do { room = A . numericCode ( 6 ); } while ( meetingRooms . has ( room ));
meetingRooms . set ( room , new Map ());
2026-07-28 20:57:56 +05:30
const call = { room , uuid : crypto . randomUUID (), startedAt : now (), startedBy : user . id , startedByName : user . name || user . email };
2026-06-23 16:27:59 +05:30
// Log the call as a meeting so it appears under Past meetings (history) with the group name.
2026-07-24 21:26:10 +05:30
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 ( _ ) {}
2026-06-23 16:27:59 +05:30
groupCalls . set ( group , call ); roomToGroupCall . set ( room , group ); roomHost . set ( room , user . id ); // creator = host
2026-07-24 22:06:27 +05:30
postSystem ( group , teamId , '📞 ' + call . startedByName + ' started a group call' ). catch (() => {});
2026-07-24 21:26:10 +05:30
let gName = 'Group' ; try { const g = await R . conversations . byId ( group ); if ( g ) gName = g . name || 'Group' ; } catch ( _ ) {}
2026-07-28 22:34:27 +05:30
broadcast ( group , { type : 'group-call' , group , active : true , room , uuid : call . uuid , by : user . id , startedByName : call . startedByName , groupName : gName });
2026-07-28 20:57:56 +05:30
// Notify the OTHER members so a closed app is alerted to the group call — VoIP/CallKit if available,
// else a banner. broadcast() above only reaches connected sockets. Best-effort; never throws.
2026-07-30 16:11:21 +05:30
try { for ( const mid of await R . conversations . members ( group )) { if ( mid !== user . id ) PUSH . sendCallNotification ( mid , { callUUID : call . uuid , room , kind : 'group' , groupId : group , groupName : gName , callerId : user . id , callerName : call . startedByName , title : gName , body : '📞 ' + call . startedByName + ' started a group call' , hasVideo : true , livekitUrl : LK . LIVEKIT_URL , livekitToken : LK . livekitToken ( mid , null , room ) }); } } catch ( _ ) {}
2026-07-28 22:34:27 +05:30
return { room , uuid : call . uuid , active : true };
2026-06-23 16:27:59 +05:30
}
// Called from signaling when a mesh room empties — ends the group call if this room was one.
2026-07-24 22:06:27 +05:30
async function endGroupCallByRoom ( room ) {
2026-06-23 16:27:59 +05:30
const group = roomToGroupCall . get ( room );
if ( ! group ) return ;
const call = groupCalls . get ( group );
roomToGroupCall . delete ( room ); groupCalls . delete ( group ); roomHost . delete ( room );
if ( call ) {
2026-07-24 22:06:27 +05:30
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 ( _ ) {}
2026-07-24 21:26:10 +05:30
if ( call . historyId && teamId ) { try { await R . scheduledMeetings . end ( call . historyId , teamId ); } catch ( _ ) {} } // mark the history row past
2026-07-28 22:34:27 +05:30
broadcast ( group , { type : 'group-call' , group , active : false , room , uuid : call . uuid });
2026-07-29 11:09:07 +05:30
// Stop any CallKit ring on members' killed/backgrounded devices.
try { for ( const mid of await R . conversations . members ( group )) PUSH . sendCallCancel ( mid , call . uuid ); } catch ( _ ) {}
2026-06-23 16:27:59 +05:30
}
}
// 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join".
2026-07-24 22:06:27 +05:30
async function startDmCall ( me , otherId , teamId ) {
2026-06-23 16:27:59 +05:30
const key = pairKey ( me . id , otherId );
const existing = dmCalls . get ( key );
2026-07-28 22:34:27 +05:30
if ( existing ) return { room : existing . room , uuid : existing . uuid , active : true , already : true };
2026-06-23 16:27:59 +05:30
let room ; do { room = A . numericCode ( 6 ); } while ( meetingRooms . has ( room ));
meetingRooms . set ( room , new Map ());
const byName = me . name || me . email ;
2026-07-28 20:57:56 +05:30
const call = { room , uuid : crypto . randomUUID (), startedAt : now (), startedBy : me . id , startedByName : byName , users : [ me . id , otherId ], teamId , answered : false };
2026-06-23 16:27:59 +05:30
// Log to history (both participants) so the call shows under Past meetings with its transcript.
2026-07-24 21:26:10 +05:30
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 ( _ ) {}
2026-06-23 16:27:59 +05:30
dmCalls . set ( key , call ); roomToDmCall . set ( room , key ); roomHost . set ( room , me . id ); // caller = host
2026-07-08 13:20:19 +05:30
// #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 );
2026-06-23 16:27:59 +05:30
// A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
const mid = A . id ();
2026-07-24 21:26:10 +05:30
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 };
2026-06-23 16:27:59 +05:30
try { CHAT . pushToUser ( otherId , { type : 'chat-message' , message : dto }); } catch ( _ ) {}
try { CHAT . pushToUser ( me . id , { type : 'chat-message' , message : dto }); } catch ( _ ) {}
2026-07-28 22:34:27 +05:30
try { CHAT . pushToUser ( otherId , { type : 'dm-call' , active : true , room , uuid : call . uuid , with : me . id , by : me . id , byName }); } catch ( _ ) {}
try { CHAT . pushToUser ( me . id , { type : 'dm-call' , active : true , room , uuid : call . uuid , with : otherId , by : me . id , byName }); } catch ( _ ) {}
2026-07-28 20:57:56 +05:30
// Notify the callee so a CLOSED app still rings — VoIP/CallKit if the device registered a VoIP token,
// else a banner (the CHAT.pushToUser events above only reach a connected socket). Best-effort. On
// reconnect the callee's app also re-shows the invite (replayActiveCalls), so answering works either way.
2026-07-30 16:11:21 +05:30
try { PUSH . sendCallNotification ( otherId , { callUUID : call . uuid , room , kind : 'dm' , callerId : me . id , callerName : byName , title : byName , body : '📞 Incoming call' , hasVideo : false , livekitUrl : LK . LIVEKIT_URL , livekitToken : LK . livekitToken ( otherId , null , room ) }); } catch ( _ ) {}
2026-07-28 22:34:27 +05:30
return { room , uuid : call . uuid , active : true };
2026-06-23 16:27:59 +05:30
}
2026-07-24 22:06:27 +05:30
async function endDmCallByRoom ( room , silent ) {
2026-06-23 16:27:59 +05:30
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 ;
2026-07-08 13:20:19 +05:30
if ( call . ringTimer ) { try { clearTimeout ( call . ringTimer ); } catch ( _ ) {} }
2026-07-24 21:26:10 +05:30
if ( call . historyId && call . teamId ) { try { await R . scheduledMeetings . end ( call . historyId , call . teamId ); } catch ( _ ) {} } // mark history past
2026-07-08 13:20:19 +05:30
// Activity line: a duration ONLY if the call was answered; otherwise "Missed call" (#7/#9).
2026-06-23 16:27:59 +05:30
if ( ! silent ) try {
2026-07-08 13:20:19 +05:30
const mid = A . id (); const body = call . answered ? ( '📞 Call ended · ' + fmtDur ( now () - ( call . answeredAt || call . startedAt ))) : '📞 Missed call' ;
2026-07-24 21:26:10 +05:30
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' };
2026-06-23 16:27:59 +05:30
call . users . forEach (( uid ) => { try { CHAT . pushToUser ( uid , { type : 'chat-message' , message : dto }); } catch ( _ ) {} });
} catch ( _ ) {}
2026-07-28 22:34:27 +05:30
call . users . forEach (( uid , i ) => { try { CHAT . pushToUser ( uid , { type : 'dm-call' , active : false , uuid : call . uuid , with : call . users [ 1 - i ], room }); } catch ( _ ) {} });
2026-07-30 17:20:32 +05:30
// Stop the CallKit ring on a device that's still RINGING (unanswered) — a killed/asleep callee has no WS
// to receive the dm-call above. For an ANSWERED call both sides are awake (the WS event ends it), and a
// cancel push would re-ring the device that just hung up — so only cancel when it was NOT answered.
if ( ! call . answered ) { try { for ( const uid of call . users ) PUSH . sendCallCancel ( uid , call . uuid ); } catch ( _ ) {} }
2026-07-29 22:48:24 +05:30
// Missed-call banner to the callee (a plain notification, like a phone's missed call) when the call ended
// UNANSWERED — timeout or the caller hung up before pickup. Skipped on decline (silent): they chose to.
if ( ! silent && ! call . answered ) {
const callee = call . users . find (( u ) => u !== call . startedBy );
if ( callee ) { try { PUSH . sendToUser ( callee , { title : call . startedByName || 'Missed call' , body : '📞 Missed call' , kind : 'dm' , id : call . startedBy , tag : 'missed:' + room , data : { kind : 'dm' , id : call . startedBy } }); } catch ( _ ) {} }
}
2026-06-23 16:27:59 +05:30
}
2026-07-08 13:20:19 +05:30
// 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 ;
2026-07-30 18:23:21 +05:30
if ( userId && userId !== call . startedBy && ! call . answered ) {
call . answered = true ; call . answeredAt = now ();
if ( call . ringTimer ) { try { clearTimeout ( call . ringTimer ); } catch ( _ ) {} call . ringTimer = null ; }
// Native calls carry media over LiveKit, not our mesh — so the callee's OTHER devices never learn the
// call was picked up here and keep ringing forever (and dismissing that stale ring as a "decline" would
// tear down THIS live call). Tell them it was taken (dismiss the ring, no teardown), and flip the
// caller's UI from "ringing" to "connected".
try { CHAT . pushToUser ( userId , { type : 'call-taken' , room , uuid : call . uuid }); } catch ( _ ) {}
try { CHAT . pushToUser ( call . startedBy , { type : 'call-answered' , room , uuid : call . uuid , by : userId }); } catch ( _ ) {}
}
2026-07-08 13:20:19 +05:30
}
2026-07-27 22:13:52 +05:30
// 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 ) {
2026-07-28 22:34:27 +05:30
try { ws . send ( JSON . stringify ({ type : 'dm-call' , active : true , room : call . room , uuid : call . uuid , with : call . startedBy , by : call . startedBy , byName : call . startedByName })); } catch ( _ ) {}
2026-07-27 22:13:52 +05:30
}
}
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 ( _ ) {}
2026-07-28 22:34:27 +05:30
try { ws . send ( JSON . stringify ({ type : 'group-call' , group , active : true , room : call . room , uuid : call . uuid , by : call . startedBy , startedByName : call . startedByName , groupName : gName })); } catch ( _ ) {}
2026-07-27 22:13:52 +05:30
}
} catch ( _ ) {}
}
2026-06-23 16:27:59 +05:30
// Called from signaling when any mesh room empties.
2026-07-24 22:06:27 +05:30
async function endCallByRoom ( room ) { await endGroupCallByRoom ( room ); await endDmCallByRoom ( room ); }
2026-06-23 16:27:59 +05:30
// Callee declines a 1:1 call: post "Call declined" into the DM, drop the waiting caller, end it.
2026-07-24 22:06:27 +05:30
async function declineDmCall ( room , byUser ) {
2026-06-23 16:27:59 +05:30
const key = roomToDmCall . get ( room ); if ( ! key ) return { ok : false };
const call = dmCalls . get ( key ); if ( ! call ) return { ok : false };
2026-07-30 18:23:21 +05:30
// Already ANSWERED (e.g. a native CallKit pickup on this user's other device, which bypasses our mesh so
// the check below can't see it): a "decline" here is just the stale ring on a second device — dismiss it,
// do NOT tear down the live call.
if ( call . answered && byUser . id !== call . startedBy ) return { ok : true , alreadyAnswered : true };
2026-07-07 23:18:14 +05:30
// #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 }; } }
2026-06-23 16:27:59 +05:30
const callerId = call . users . find (( id ) => id !== byUser . id ) || call . startedBy ;
try {
const mid = A . id ();
2026-07-24 21:26:10 +05:30
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' };
2026-06-23 16:27:59 +05:30
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 };
}
2026-07-27 22:13:52 +05:30
module . exports = { startGroupCall , startDmCall , endGroupCallByRoom , endDmCallByRoom , endCallByRoom , declineDmCall , markDmAnswered , replayActiveCalls , finalizeTranscript , meetingContext , fmtDur , pairKey };