bda63b6f0a
Resolved conflicts in routes.js and share.html: kept the dev tree's superset (ALLOW_LOCAL_LOGIN dev escape, avatar sync, richer login errors) which already includes the incoming production BizGaze-only behavior; took the more descriptive incoming comments. Restored 5 untracked modules (chat, calls, directory, reminders, webhooks) that were missing from disk — required by routes/signaling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
152 lines
10 KiB
JavaScript
152 lines
10 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 { 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.
|
|
function meetingContext(room) {
|
|
const ctx = { groupId: null, meetingId: null, title: 'Meeting' };
|
|
try {
|
|
const sched = 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 = 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).
|
|
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 = 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 = 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).
|
|
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'); }
|
|
|
|
function broadcast(group, evt) { try { for (const mid of R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} }
|
|
|
|
// Post a centered activity line into the group (system sender → no ping on clients).
|
|
function postSystem(group, teamId, text) {
|
|
const id = A.id();
|
|
R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group });
|
|
const m = 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 } });
|
|
}
|
|
|
|
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(); 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');
|
|
let gName = 'Group'; try { const g = 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 });
|
|
return { room, active: true };
|
|
}
|
|
|
|
// Called from signaling when a mesh room empties — ends the group call if this room was one.
|
|
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 = R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)); } } catch (_) {}
|
|
if (call.historyId && teamId) { try { 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".
|
|
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 };
|
|
// Log to history (both participants) so the call shows under Past meetings with its transcript.
|
|
try { const hid = A.id(); 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
|
|
// A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
|
|
const mid = A.id();
|
|
R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' });
|
|
const m = 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 (_) {}
|
|
return { room, active: true };
|
|
}
|
|
|
|
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.historyId && call.teamId) { try { R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
|
|
// "Call ended · duration" activity line in the DM (shown to both) — skipped on decline.
|
|
if (!silent) try {
|
|
const mid = A.id(); const body = '📞 Call ended · ' + fmtDur(now() - call.startedAt);
|
|
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 = 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 (_) {} });
|
|
}
|
|
|
|
// Called from signaling when any mesh room empties.
|
|
function endCallByRoom(room) { endGroupCallByRoom(room); endDmCallByRoom(room); }
|
|
|
|
// Callee declines a 1:1 call: post "Call declined" into the DM, drop the waiting caller, end it.
|
|
function declineDmCall(room, byUser) {
|
|
const key = roomToDmCall.get(room); if (!key) return { ok: false };
|
|
const call = dmCalls.get(key); if (!call) return { ok: false };
|
|
const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy;
|
|
try {
|
|
const mid = A.id();
|
|
R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: '📞 Call declined', msgType: 'call-end' });
|
|
const mm = 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, finalizeTranscript, meetingContext, fmtDur, pairKey };
|