diff --git a/.changeset/videoconf-conference-data-model.md b/.changeset/videoconf-conference-data-model.md new file mode 100644 index 0000000000000..00bbd8db7b07e --- /dev/null +++ b/.changeset/videoconf-conference-data-model.md @@ -0,0 +1,34 @@ +--- +'@rocket.chat/core-services': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/ddp-client': minor +'@rocket.chat/models': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Groundwork for the video conference window: new API, no change to how any call behaves today. + +Conference records gain per-member lifecycle fields (joined, declined, left, last seen, ringing) and the service +gains the operations a call window needs, behind new REST endpoints: + +| Endpoint | What it does | +| --- | --- | +| `POST /v1/video-conference.leave` | Records that the caller left a call, ending it when nobody is left in it | +| `POST /v1/video-conference.heartbeat` | Renews the caller's presence lease, so a participant whose client vanishes is treated as gone | +| `POST /v1/video-conference.decline` | Records that the caller turned a call down, without ending it for anyone else | +| `POST /v1/video-conference.ring` | Rings a member who has not answered yet, again | +| `POST /v1/video-conference.rename` | Renames a running group call, for the person who started it | +| `POST /v1/video-conference.add-participants` | Adds people to a call in progress, and rings them | +| `POST /v1/video-conference.share-chat` | Gives members who cannot read the call's chat access to it | +| `GET /v1/video-conference.joinable` | Lists the calls the caller could still join | + +A cron sweeps presence leases so a call whose participants vanish is closed rather than left running, and a +`video-conference` stream carries per-conference updates to whoever is watching one. + +All of the new behaviour is reserved for providers whose call renders inside Rocket.Chat, identified by an +`embedded` capability. No provider registers that capability yet, so on any existing workspace every one of +these paths is skipped and calls placed through Jitsi, Google Meet, BBB or Pexip behave exactly as before. The +endpoints are additive and no client calls them yet. diff --git a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts index 0f499432d2c2b..8c897a589e10c 100644 --- a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts +++ b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts @@ -20,14 +20,19 @@ export const useVideoConfList = ({ roomId }: { roomId: IRoom['_id'] }) => { return { items: data.map( - (videoConf): VideoConference => ({ - ...videoConf, - _updatedAt: new Date(videoConf._updatedAt), - createdAt: new Date(videoConf.createdAt), - endedAt: videoConf.endedAt ? new Date(videoConf.endedAt) : undefined, - users: videoConf.users.map((user) => ({ - ...user, - ts: new Date(user.ts), + ({ _updatedAt, createdAt, endedAt, users, ...rest }): VideoConference => ({ + ...rest, + _updatedAt: new Date(_updatedAt), + createdAt: new Date(createdAt), + endedAt: endedAt ? new Date(endedAt) : undefined, + users: users.map(({ ts, joinedAt, declinedAt, leftAt, lastSeenAt, ringingAt, ...userRest }) => ({ + ...userRest, + ts: new Date(ts), + joinedAt: joinedAt ? new Date(joinedAt) : undefined, + declinedAt: declinedAt ? new Date(declinedAt) : undefined, + leftAt: leftAt ? new Date(leftAt) : undefined, + lastSeenAt: lastSeenAt ? new Date(lastSeenAt) : undefined, + ringingAt: ringingAt ? new Date(ringingAt) : undefined, })), }), ), diff --git a/apps/meteor/ee/server/configuration/videoConference.ts b/apps/meteor/ee/server/configuration/videoConference.ts index 5d6599e8234aa..629070863f8b8 100644 --- a/apps/meteor/ee/server/configuration/videoConference.ts +++ b/apps/meteor/ee/server/configuration/videoConference.ts @@ -2,7 +2,7 @@ import { VideoConf } from '@rocket.chat/core-services'; import type { IRoom, IUser, VideoConference } from '@rocket.chat/core-typings'; import { VideoConferenceStatus } from '@rocket.chat/core-typings'; import { License } from '@rocket.chat/license'; -import { Rooms, Subscriptions } from '@rocket.chat/models'; +import { Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../server/lib/callbacks'; @@ -26,23 +26,19 @@ Meteor.startup(async () => { }, ); + // Ringing on start is for the rooms where every member is someone who chose to be in a conversation + // with the caller: a DM, handled by the `direct` type above, and a group DM, handled here. Starting a + // call in a channel, private group or team does not ring anyone — the people who want in are told by + // the call itself, and whoever else wants to reach a specific person can ring them from the call. videoConfTypes.registerVideoConferenceType({ type: 'videoconference', ringing: true }, async ({ _id, t }, allowRinging) => { - if (!allowRinging || t === 'l') { + if (!allowRinging || t !== 'd') { return false; } - if (t === 'd') { - const room = await Rooms.findOneById>(_id, { projection: { uids: 1 } }); - if (room && (!room.uids || room.uids.length <= 2)) { - return false; - } - } - - if ((await Subscriptions.countByRoomId(_id)) > 10) { - return false; - } + // A two-person DM is the `direct` type's, so what rings here is a group DM. + const room = await Rooms.findOneById>(_id, { projection: { uids: 1 } }); - return true; + return Boolean(room?.uids && room.uids.length > 2); }); callbacks.add('onJoinVideoConference', async (callId: VideoConference['_id'], userId?: IUser['_id']) => diff --git a/apps/meteor/lib/videoConference/chatAccess.ts b/apps/meteor/lib/videoConference/chatAccess.ts new file mode 100644 index 0000000000000..8c8e0c2ded35a --- /dev/null +++ b/apps/meteor/lib/videoConference/chatAccess.ts @@ -0,0 +1,41 @@ +import type { IRoom, IUser, VideoConferenceChatAccess, VideoConferenceChatAccessMode } from '@rocket.chat/core-typings'; + +/** + * Whether this user can read the conference's chat. Membership of a call grants no room access, so being in a + * call and being able to follow what is said in it are separate questions. + * + * Asked in three places — whether to render the chat at all, whether to offer to share it, and how to mark a + * member in the list — and each had worked it out for itself, from either an array or a `Set`. + */ +export const hasConferenceChatAccess = ( + access: Pick | undefined, + uid: IUser['_id'] | null | undefined, +): boolean => !uid || !access?.membersWithoutAccess.includes(uid); + +/** + * Which way of giving the missing members access should lead — be the primary action offered. + * + * Both give something away: inviting exposes the room's whole history to someone outside it, while moving the + * chat to a discussion leaves the earlier history behind for everyone already there. Exposing a *private* + * room's history is the bigger step, so private rooms and DMs lead with the discussion and public rooms — + * whose history is already open — lead with the invite. A room that can't take new members at all leaves the + * discussion as the only option. + */ +export const chatAccessLeadsWithDiscussion = ({ canInvite, type }: { canInvite: boolean; type: IRoom['t'] }): boolean => + !canInvite || type === 'p' || type === 'd'; + +/** + * The mode to act on, given what the caller asked for and what the room allows. + * + * The caller always asks for one: which way leads is shown to whoever is choosing (see + * `chatAccessLeadsWithDiscussion`), so there is nothing left for this to infer. Returns `null` when the room + * can't do what was asked, which is a refusal rather than a reason to silently do the other thing — that would + * give away history nobody agreed to give away. + */ +export const resolveChatAccessMode = ({ + mode, + canInvite, +}: { + mode: VideoConferenceChatAccessMode; + canInvite: boolean; +}): VideoConferenceChatAccessMode | null => (mode === 'invite' && !canInvite ? null : mode); diff --git a/apps/meteor/lib/videoConference/conferenceName.ts b/apps/meteor/lib/videoConference/conferenceName.ts new file mode 100644 index 0000000000000..96d02d83c9fdb --- /dev/null +++ b/apps/meteor/lib/videoConference/conferenceName.ts @@ -0,0 +1,53 @@ +import type { IRoom, IUser, IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; + +type NameableConference = { + type: VideoConference['type']; + title?: string; + createdBy: Pick & Partial>; + users: (Pick & Partial>)[]; +}; + +const displayName = (person?: Partial>): string => person?.name || person?.username || ''; + +/** + * Who a direct call is *with*, from this viewer's side. Whoever started it, unless that is the viewer themselves, + * in which case it is whoever else is on the call. + * + * The creator rather than "the other member" because a direct call can hold more than two people: someone added + * to a call in a DM has two others to choose from, and the one who matters to them is the one who brought them in. + */ +const otherParty = (call: NameableConference, viewerId: IUser['_id'] | undefined) => + call.createdBy._id !== viewerId ? call.createdBy : call.users.find(({ _id }) => _id !== viewerId); + +/** + * What to call a conference, for the person looking at it. Returns `''` when only the room can answer, leaving + * that to the caller — which is also what keeps the room lookup off the path that doesn't need it. + * + * A direct call has no name of its own, so it is named after a person. Ordinarily that name comes from the + * viewer's own subscription, since a DM is named per side and the name lives there rather than on the room. But + * conference membership grants no room access, so a member added from outside a DM has no subscription to read — + * and the room can't help either: a DM room carries neither `name` nor `fname`, so falling back to it ended in + * `getRoomName`'s last resort, the raw room id. That is what put a hash in front of the person who was invited. + * Naming the call after whoever brought them in answers the question they actually have, which is who is calling. + */ +export const conferenceNameFor = ( + call: NameableConference, + viewerId: IUser['_id'] | undefined, + subscriptionName?: string, + roomType?: IRoom['t'], +): string => { + const isDM = call.type === 'direct' || roomType === 'd'; + + // A group conference has a name of its own, and it wins — except in a DM, where the "title" is + // `room.fname` from the creator's perspective and is wrong for every other viewer. There the + // per-viewer subscription name is the right answer. + if (!isDM && call.type === 'videoconference' && call.title) { + return call.title; + } + + if (subscriptionName) { + return subscriptionName; + } + + return isDM ? displayName(otherParty(call, viewerId)) : ''; +}; diff --git a/apps/meteor/lib/videoConference/constants.ts b/apps/meteor/lib/videoConference/constants.ts index 69e243115be98..772951fc2ace8 100644 --- a/apps/meteor/lib/videoConference/constants.ts +++ b/apps/meteor/lib/videoConference/constants.ts @@ -1,5 +1,30 @@ +import { RING_RECIPIENTS_LIMIT } from '@rocket.chat/core-typings'; + export const availabilityErrors = { NOT_CONFIGURED: 'video-conf-provider-not-configured', NOT_ACTIVE: 'no-active-video-conf-provider', NO_APP: 'no-videoconf-provider-app', }; + +/** + * How many of the people in a call are shown as faces in the sidebar list before the rest become a "+N". + * + * Shared with the server, which slices the joinable payload to it: sending more would be sending a roster nobody + * draws. Two is what fits beside a call's name in a sidebar row without pushing it out — the rest are a count. + */ +export const CALL_FACES_SHOWN = 2; + +/** + * The same, on the preflight — a screen rather than a row, so it has room for more of them before the count takes + * over. The people come from the call window's own copy of the members, so nothing has to travel for these. + */ +export const PREFLIGHT_FACES_SHOWN = 10; + +/** + * How long a conference is kept alive after the last participant leaves, before it is ended. + * Long enough for a reload to land and cancel it, short enough that a call really over doesn't linger. + */ +export const EMPTY_CALL_GRACE_MS = 10_000; + +/** Whether this many recipients is a set worth ringing. See `RING_RECIPIENTS_LIMIT` for why there is a cap. */ +export const shouldRingRecipients = (recipientCount: number): boolean => recipientCount > 0 && recipientCount <= RING_RECIPIENTS_LIMIT; diff --git a/apps/meteor/lib/videoConference/memberStatus.ts b/apps/meteor/lib/videoConference/memberStatus.ts new file mode 100644 index 0000000000000..a56fafe0cfa20 --- /dev/null +++ b/apps/meteor/lib/videoConference/memberStatus.ts @@ -0,0 +1,46 @@ +import type { IVideoConferenceUser } from '@rocket.chat/core-typings'; +import { hasJoinedVideoConference, isInVideoConference, isRingingVideoConferenceMember } from '@rocket.chat/core-typings'; + +/** Where a member stands with the call, as one thing the UI can label them with. */ +export type ConferenceMemberStatus = 'joined' | 'left' | 'declined' | 'invited'; + +type MemberState = Pick; + +/** + * Reduces a membership entry to the one thing worth showing. + * + * The entry accumulates rather than replaces — `joined` never goes back to false, and a decline stays recorded + * after the person changes their mind — so the fields have to be read in order of what happened *last*. Being + * in the call beats everything; having left beats an earlier decline, since they did answer; a decline beats + * simply having been invited. + */ +export const getConferenceMemberStatus = (member: MemberState): ConferenceMemberStatus => { + if (isInVideoConference(member)) { + return 'joined'; + } + + if (member.leftAt) { + return 'left'; + } + + return member.declined ? 'declined' : 'invited'; +}; + +/** + * Whether it makes sense to ring this member *now*. Not while their phone is already ringing — there is nothing + * to ask for — and not while they are in the call. Once they have declined, ignored it or left, ringing them + * back is exactly the point. + */ +export const canRingConferenceMember = (member: MemberState, now?: number): boolean => + getConferenceMemberStatus(member) !== 'joined' && !isRingingVideoConferenceMember(member, now); + +/** + * Whether this member has not been asked to answer *yet* — never rung, never in the call, never declined. + * + * Different from `canRingConferenceMember`, which is about whether ringing them again would make sense: this is + * about a call nobody has been asked about at all. Both sides of the ring need it — the server, to ring the callee + * when the caller finally walks in, and the caller's own preflight, to say so instead of implying a phone is + * already ringing. + */ +export const isUnaskedConferenceMember = (member: Pick): boolean => + !member.ringingAt && !hasJoinedVideoConference(member) && !member.declined; diff --git a/apps/meteor/lib/videoConference/presence.ts b/apps/meteor/lib/videoConference/presence.ts new file mode 100644 index 0000000000000..5f3a2c3930235 --- /dev/null +++ b/apps/meteor/lib/videoConference/presence.ts @@ -0,0 +1,72 @@ +import type { IUser, IVideoConferenceUser, VideoConferenceLeaveReason } from '@rocket.chat/core-typings'; +import { isInVideoConference } from '@rocket.chat/core-typings'; + +/** + * Presence in a call as a lease the call window keeps renewing, rather than a departure it promises to report. + * + * A report is the fast, accurate path and it usually works — but it can only be sent by a live client to a live + * server, and neither is guaranteed. The case that started this: the workspace goes down while the call carries + * on in the provider (LiveKit, Pexip and friends are separate services), people leave during the outage, and + * their leave never reaches anyone. The same hole swallows a crashed tab, a killed browser, a dead battery and a + * `keepalive` fetch that didn't make it. What all of those have in common is that *renewals stop*, which is the + * signal this infers a departure from. + * + * Deliberately provider-agnostic: the renewal comes from our own conference window, which exists whoever runs the + * media — an iframe provider renders inside our page, so our code is alive there too. + */ + +/** + * How often a call window renews its lease. + * + * Well under the lease it renews, because a hidden tab — a call you are listening to while working in another + * window — has its timers throttled to roughly one a minute by every current browser. + */ +export const PRESENCE_HEARTBEAT_MS = 30_000; + +/** + * How long one renewal is good for. + * + * Long enough to survive throttling (two missed ticks at a browser's throttled rate) and a brief network drop, + * short enough that a ghost in the members list is a curiosity rather than a lie. It doubles as the grace period + * a departing member gets before their absence is written, which is why this is also what a restart waits out. + */ +export const PRESENCE_LEASE_MS = 180_000; + +/** The reasons a departure was inferred rather than reported, so a renewal can undo them and a report cannot. */ +export const INFERRED_LEAVE_REASONS: VideoConferenceLeaveReason[] = ['timeout']; + +/** A member whose lease has run out, and the last moment we know they were still in the call. */ +export type ExpiredPresenceLease = { uid: IUser['_id']; leftAt: Date }; + +/** + * The last moment there was evidence this member was in the call. Members who joined before leases existed have + * no renewal to read, so their join stands as the last thing we know — and failing even that, their membership. + */ +const lastEvidence = (user: IVideoConferenceUser): Date => user.lastSeenAt ?? user.joinedAt ?? user.ts; + +/** + * Which members are to be treated as gone, and when they left. + * + * `leftAt` is the last evidence rather than the moment of the sweep, which is the whole point of keeping a + * watermark: stamping "now" on a call recovered after a 20-minute outage would add 20 minutes to everyone's call + * history. The honest answer is "we last saw you before the lights went out", and that is what this returns. + */ +export const expiredPresenceLeases = (users: IVideoConferenceUser[], now: Date, leaseMs = PRESENCE_LEASE_MS): ExpiredPresenceLease[] => + users + .filter((user) => isInVideoConference(user)) + .map((user) => ({ uid: user._id, leftAt: lastEvidence(user) })) + .filter(({ leftAt }) => now.getTime() - leftAt.getTime() >= leaseMs); + +/** + * Whether leases may be acted on yet, given how long this process has been up. + * + * The guard that makes leases correct across a restart. From the database, "everyone left" and "we were not here + * to be told" are the same picture: every lease is expired either way. So a freshly started process waits out a + * full lease before evicting anyone — whoever is still in the call renews within it (their window heartbeats + * every `PRESENCE_HEARTBEAT_MS`, throttled to a minute at worst), and whoever is genuinely gone is still gone + * afterwards, with the departure timestamp they had all along. + * + * In a multi-instance workspace this costs nothing: the instances that stayed up were never absent and keep + * sweeping throughout. + */ +export const isPresenceSweepDue = (uptimeMs: number, leaseMs = PRESENCE_LEASE_MS): boolean => uptimeMs >= leaseMs; diff --git a/apps/meteor/server/api/v1/videoConference.ts b/apps/meteor/server/api/v1/videoConference.ts index b13ee206f474c..8035df8c3c460 100644 --- a/apps/meteor/server/api/v1/videoConference.ts +++ b/apps/meteor/server/api/v1/videoConference.ts @@ -4,9 +4,13 @@ import { ajv, isVideoConfStartProps, isVideoConfJoinProps, - isVideoConfCancelProps, + isVideoConfRingProps, + isVideoConfCallIdProps, isVideoConfInfoProps, isVideoConfListProps, + isVideoConfAddParticipantsProps, + isVideoConfRenameProps, + isVideoConfShareChatProps, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, validateBadRequestErrorResponse, @@ -16,6 +20,7 @@ import { availabilityErrors } from '../../../lib/videoConference/constants'; import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; import { canSendMessageAsync } from '../../lib/authorization/canSendMessage'; import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { canAccessConference } from '../../lib/videoConfAccess'; import { videoConfProviders } from '../../lib/videoConfProviders'; import { API } from '../api'; import { getPaginationItems } from '../lib/getPaginationItems'; @@ -41,11 +46,13 @@ const startResponseSchema = ajv.compile<{ data: VideoConferenceInstructions & { additionalProperties: false, }); -const joinResponseSchema = ajv.compile<{ url: string; providerName: string }>({ +const joinResponseSchema = ajv.compile<{ url: string; providerName: string; callId?: string; rid?: string }>({ type: 'object', properties: { url: { type: 'string' }, providerName: { type: 'string' }, + callId: { type: 'string' }, + rid: { type: 'string' }, success: { type: 'boolean', enum: [true] }, }, required: ['url', 'providerName', 'success'], @@ -59,6 +66,63 @@ const cancelResponseSchema = ajv.compile({ additionalProperties: false, }); +/** + * How every conference endpoint below starts: the call has to exist, and the caller has to be allowed near it. + * + * Both failures are answered the same way — `invalid-params`, deliberately vague about which of the two it was, + * so a stranger can't use the endpoint to learn that a call id is real. Returning the caller's id alongside the + * call is what lets the handlers use it without re-checking that they are signed in. + */ +const loadAccessibleConference = async ( + callId: VideoConference['_id'], + userId: string | undefined, +): Promise<{ call: Omit; userId: string } | undefined> => { + if (!userId) { + return undefined; + } + + const call = await VideoConf.get(callId); + if (!call || !(await canAccessConference(call, userId))) { + return undefined; + } + + return { call, userId }; +}; + +/** + * The conference endpoints answer with one value beside `success`, so their schemas differ in a single property. + */ +const oneValueResponseSchema = (name: string, value: Record) => + ajv.compile({ + type: 'object', + properties: { [name]: value, success: { type: 'boolean', enum: [true] } }, + required: [name, 'success'], + additionalProperties: false, + }); + +const addParticipantsResponseSchema = oneValueResponseSchema<{ added: string[] }>('added', { + type: 'array', + items: { type: 'string' }, + description: 'Ids of the users newly added as members.', +}); + +const joinableResponseSchema = oneValueResponseSchema<{ calls: unknown[] }>('calls', { + type: 'array', + items: { type: 'object' }, + description: 'Calls running now that the caller may join.', +}); + +const ringResponseSchema = oneValueResponseSchema<{ rang: boolean }>('rang', { + type: 'boolean', + description: + 'Whether the member was rung. False when there was nothing to do — they are in the call, their phone is already ringing, or the call has ended.', +}); + +const shareChatResponseSchema = oneValueResponseSchema<{ rid: string }>('rid', { + type: 'string', + description: 'The room the conference chat now lives in.', +}); + const infoResponseSchema = ajv.compile({ type: 'object', properties: { @@ -189,7 +253,7 @@ API.v1.post( return API.v1.failure('invalid-params'); } - if (!(await canAccessRoomIdAsync(call.rid, userId))) { + if (!(await canAccessConference(call, userId))) { return API.v1.failure('invalid-params'); } @@ -206,13 +270,21 @@ API.v1.post( } } - if (!url) { + // Embedded providers (LiveKit) intentionally return an empty url — + // they're rendered inline rather than opened as an external popup. + // Include rid so the client can route the join into its embedded + // provider context without an extra round-trip to look it up. + // For every other provider the url is the whole point of joining, + // so coming back without one is a failure. + if (!url && !videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { return API.v1.failure('failed-to-get-url'); } return API.v1.success({ - url, + url: url ?? '', providerName: call.providerName, + callId: call._id, + rid: call.rid, }); }, ); @@ -221,7 +293,7 @@ API.v1.post( 'video-conference.cancel', { authRequired: true, - body: isVideoConfCancelProps, + body: isVideoConfCallIdProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 }, response: { 200: cancelResponseSchema, @@ -247,6 +319,228 @@ API.v1.post( }, ); +API.v1.post( + 'video-conference.decline', + { + authRequired: true, + body: isVideoConfCallIdProps, + rateLimiterOptions: { numRequestsAllowed: 10, intervalTimeInMS: 60000 }, + response: { + 200: cancelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId } = this.bodyParams; + + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { + return API.v1.failure('invalid-params'); + } + + // Records the decline against the caller's own membership only. Declining is deliberately not a way to + // end someone else's conference, so this takes no target user and never touches the call's status. + await VideoConf.declineCall(conference.userId, callId); + + return API.v1.success(); + }, +); + +API.v1.post( + 'video-conference.leave', + { + authRequired: true, + body: isVideoConfCallIdProps, + // Sent when the call window closes, which a user can do repeatedly across rejoins. + rateLimiterOptions: { numRequestsAllowed: 20, intervalTimeInMS: 60000 }, + response: { + 200: cancelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId } = this.bodyParams; + + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { + return API.v1.failure('invalid-params'); + } + + // Only ever marks the caller as gone. The conference ends as a consequence of nobody being left in it, + // not because one participant asked for it — the same rule declining follows. + await VideoConf.leaveCall(conference.userId, callId); + + return API.v1.success(); + }, +); + +/** + * Renews the caller's presence lease on a call — the conference window saying it is still in it. + * + * The counterpart of `video-conference.leave`, and the reason a lost leave is survivable: leaving is inferred from + * renewals stopping, so nothing has to reach us at the moment someone goes. Provider-agnostic, because the window + * doing the renewing is ours whatever runs the media. + */ +API.v1.post( + 'video-conference.heartbeat', + { + authRequired: true, + body: isVideoConfCallIdProps, + // Renewals are every `PRESENCE_HEARTBEAT_MS`, so twice a minute, plus one whenever the window is brought + // back to the front. The allowance is for that: bursts of attention, not a higher steady rate. + rateLimiterOptions: { numRequestsAllowed: 20, intervalTimeInMS: 60000 }, + response: { + 200: cancelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId } = this.bodyParams; + + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { + return API.v1.failure('invalid-params'); + } + + await VideoConf.renewPresence(conference.userId, callId); + + return API.v1.success(); + }, +); + +API.v1.post( + 'video-conference.ring', + { + authRequired: true, + body: isVideoConfRingProps, + // Ringing again is a deliberate, repeatable act, but not one worth hammering someone with. + rateLimiterOptions: { numRequestsAllowed: 5, intervalTimeInMS: 60000 }, + response: { + 200: ringResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { callId, userId } = this.bodyParams; + + // The same permission `video-conference.start` demands before ringing anyone — having access to a + // conference must not be a way around it. + if (!(await hasPermissionAsync(this.userId, 'videoconf-ring-users'))) { + return API.v1.forbidden('Not allowed'); + } + + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { + return API.v1.failure('invalid-params'); + } + + const rang = await VideoConf.ringMember(conference.userId, callId, userId); + + return API.v1.success({ rang }); + }, +); + +API.v1.post( + 'video-conference.add-participants', + { + authRequired: true, + body: isVideoConfAddParticipantsProps, + rateLimiterOptions: { numRequestsAllowed: 5, intervalTimeInMS: 60000 }, + response: { + 200: addParticipantsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId, users, ring } = this.bodyParams; + + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { + return API.v1.failure('invalid-params'); + } + + // Registers the users as conference members — it deliberately does not put them in any room. Being a + // member authorizes joining the call; whether they can read the chat is surfaced separately. + // Adding is open to anyone with access to the conference; the ring that usually accompanies it needs the + // same permission `video-conference.start` demands, and degrades silently without it — same as `start`. + const added = await VideoConf.addMembers(conference.userId, callId, users, { + // Not ringing unless asked: adding someone to a call in progress is often to have them join when + // they can, and an unrequested ring is an interruption nobody chose. + ring: (ring ?? false) && (await hasPermissionAsync(this.userId, 'videoconf-ring-users')), + }); + + return API.v1.success({ added }); + }, +); + +API.v1.post( + 'video-conference.rename', + { + authRequired: true, + body: isVideoConfRenameProps, + rateLimiterOptions: { numRequestsAllowed: 10, intervalTimeInMS: 60000 }, + response: { + 200: cancelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { callId, title } = this.bodyParams; + + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { + return API.v1.failure('invalid-params'); + } + + // Whether this particular user may *name* the call is the service's call to make — access is only the + // question of whether they may be here at all. Its refusal is an authorization answer, not a failure, + // so it maps to 403 rather than surfacing as an internal error. + try { + await VideoConf.renameCall(conference.userId, callId, title); + } catch (e) { + if (e instanceof Error && e.message === 'error-not-allowed') { + return API.v1.forbidden('Not allowed'); + } + throw e; + } + + return API.v1.success(); + }, +); + +API.v1.post( + 'video-conference.share-chat', + { + authRequired: true, + body: isVideoConfShareChatProps, + rateLimiterOptions: { numRequestsAllowed: 5, intervalTimeInMS: 60000 }, + response: { + 200: shareChatResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { callId, mode } = this.bodyParams; + + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { + return API.v1.failure('invalid-params'); + } + + return API.v1.success({ rid: await VideoConf.shareChatWithMembers(conference.userId, callId, mode) }); + }, +); + API.v1.get( 'video-conference.info', { @@ -261,26 +555,54 @@ API.v1.get( }, async function action() { const { callId } = this.queryParams; - const { userId } = this; - const call = await VideoConf.get(callId); - if (!call) { + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { return API.v1.failure('invalid-params'); } - if (!userId || !(await canAccessRoomIdAsync(call.rid, userId))) { - return API.v1.failure('invalid-params'); - } + const { call, userId } = conference; - const capabilities = await VideoConf.listProviderCapabilities(call.providerName); + // Membership grants no room access, so some members may not be able to read the chat. The conference UI + // surfaces them and offers the remedy, which is why this ships with the conference rather than needing + // its own round trip. + const [capabilities, chatAccess] = await Promise.all([ + VideoConf.listProviderCapabilities(call.providerName), + VideoConf.getChatAccess(userId, callId), + ]); return API.v1.success({ ...(call as VideoConference), capabilities, + chatAccess, }); }, ); +API.v1.get( + 'video-conference.joinable', + { + authRequired: true, + // Discovery is event-driven: a per-user `video-conference` event — a ring, an arrival, a call ending — + // is what sends a client back here. The poll behind it is a fallback for what an event cannot cover, + // a missed message or a reconnection, so this has to tolerate a steady trickle as well as bursts. + rateLimiterOptions: { numRequestsAllowed: 30, intervalTimeInMS: 60000 }, + response: { + 200: joinableResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { userId } = this; + if (!userId) { + return API.v1.failure('invalid-params'); + } + + return API.v1.success({ calls: await VideoConf.listJoinableCalls(userId) }); + }, +); + API.v1.get( 'video-conference.list', { diff --git a/apps/meteor/server/cron/videoConferences.ts b/apps/meteor/server/cron/videoConferences.ts index 69a588cfdde3e..021176df3ae00 100644 --- a/apps/meteor/server/cron/videoConferences.ts +++ b/apps/meteor/server/cron/videoConferences.ts @@ -4,6 +4,8 @@ import { VideoConferenceStatus } from '@rocket.chat/core-typings'; import { cronJobs } from '@rocket.chat/cron'; import { VideoConference as VideoConferenceModel } from '@rocket.chat/models'; +import { isPresenceSweepDue } from '../../lib/videoConference/presence'; + // 24 hours const VIDEO_CONFERENCE_TTL = 24 * 60 * 60 * 1000; @@ -17,8 +19,37 @@ async function runVideoConferences(): Promise { await Promise.all(calls.map((callId) => VideoConf.setStatus(callId, VideoConferenceStatus.EXPIRED))); } +/** + * Sweeps presence leases: everyone whose call window has stopped renewing is treated as having left. + * + * Frequent because it is what recovers a call after an outage, and cheap because the work is proportional to the + * number of *open* calls, which is normally none. + */ +async function runPresenceSweep(readyForMs: number): Promise { + // A restart cannot tell "everyone left" from "we were not here to be told" — both leave every lease expired. + // So a fresh process waits out one full lease, by which time anyone still in a call has renewed theirs. + // Measured from when the job was registered, not from process launch: a slow boot can eat the whole lease + // before any client had a chance to heartbeat. + if (!isPresenceSweepDue(readyForMs)) { + return; + } + + await VideoConf.expirePresenceLeases(); +} + export async function videoConferencesCron(): Promise { void runVideoConferences(); - return cronJobs.add('VideoConferences', '0 */3 * * *', async () => runVideoConferences()); + await cronJobs.add('VideoConferences', '0 */3 * * *', async () => runVideoConferences()); + + // A cron expression, not an interval phrase: the scheduler parses these, and an unparseable schedule leaves + // `nextRunAt` on the moment it just ran — which is a job that re-runs on every scheduler tick forever. + // + // Not run here on the way past, unlike the expiry above: at startup the guard inside it would reject it + // anyway, and that is exactly the point. + // + // Monotonic time, not the wall clock: an NTP correction or a manual clock change must not be able to age the + // process past the grace period in an instant — or hold it forever under it. + const registeredAt = performance.now(); + return cronJobs.add('VideoConferencePresence', '* * * * *', async () => runPresenceSweep(performance.now() - registeredAt)); } diff --git a/apps/meteor/server/lib/videoConfAccess.ts b/apps/meteor/server/lib/videoConfAccess.ts new file mode 100644 index 0000000000000..6e8f577eebffb --- /dev/null +++ b/apps/meteor/server/lib/videoConfAccess.ts @@ -0,0 +1,38 @@ +import type { VideoConference } from '@rocket.chat/core-typings'; + +import { canAccessRoomIdAsync } from './authorization/canAccessRoom'; + +/** + * Whether someone is allowed near a conference at all — the one rule, for every endpoint that answers about one. + * + * Being in the call and being able to read its chat are separate things, so this accepts either: membership of + * the conference, or access to a room the conference lives in. + * + * Membership covers people added from outside the room — they were added to the *call*, not to a room, so there + * is no subscription to check. This is the case that makes the distinction load-bearing rather than theoretical: + * a conference started in a DM and joined by a third person gives that person no access to the DM, by design, and + * checking the room instead of the membership refuses them their own call. The room checks cover everyone who can + * already see the conversation: `rid` is the room the call started in, and `discussionRid` the discussion its + * chat may have moved to, whose members may have no access to the parent room. + * + * It lives here rather than beside one set of endpoints because more than one set needs it, and two versions of + * "may this person be here" drift into two different answers for the same person. + */ +export const canAccessConference = async ( + call: Pick, + userId: string | undefined, +): Promise => { + if (!userId) { + return false; + } + + if (call.users.some(({ _id }) => _id === userId)) { + return true; + } + + if (await canAccessRoomIdAsync(call.rid, userId)) { + return true; + } + + return !!call.discussionRid && canAccessRoomIdAsync(call.discussionRid, userId); +}; diff --git a/apps/meteor/server/modules/listeners/listeners.module.ts b/apps/meteor/server/modules/listeners/listeners.module.ts index 332aea56d5eac..7d472d428a1d4 100644 --- a/apps/meteor/server/modules/listeners/listeners.module.ts +++ b/apps/meteor/server/modules/listeners/listeners.module.ts @@ -181,6 +181,10 @@ export class ListenersModule { .catch((err) => logger.error({ msg: 'Failed to refresh status visibility', err, targets })); }); + service.onEvent('video-conference.updated', ({ callId }) => { + notifications.notifyVideoConferenceUpdated(callId); + }); + service.onEvent('presence.status', ({ user }) => { const { _id, username, name, status, statusText, statusSource, statusExpiresAt, roles } = user; if (!status || !username) { diff --git a/apps/meteor/server/modules/notifications/notifications.module.ts b/apps/meteor/server/modules/notifications/notifications.module.ts index 65be495c191d9..b28dd332f9860 100644 --- a/apps/meteor/server/modules/notifications/notifications.module.ts +++ b/apps/meteor/server/modules/notifications/notifications.module.ts @@ -1,11 +1,12 @@ import { Authorization, MediaCall, VideoConf, Settings } from '@rocket.chat/core-services'; import type { ISubscription, IOmnichannelRoom, IUser, IUserDataEvent, PresenceSource, PresenceStatusCode } from '@rocket.chat/core-typings'; import type { StreamerCallbackArgs, StreamKeys, StreamNames } from '@rocket.chat/ddp-client'; -import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { Rooms, Subscriptions, Users, VideoConference } from '@rocket.chat/models'; import type { ImporterProgress } from '../../lib/import/classes/ImporterProgress'; import { SystemLogger } from '../../lib/logger/system'; import { emit, StreamPresence } from '../../lib/notifications/core/lib/Presence'; +import { canAccessConference } from '../../lib/videoConfAccess'; import { getCachedUserForPublication } from '../streamer/publication-user-cache'; import { Streamer as StreamerModule } from '../streamer/streamer.module'; import type { IStreamer, IStreamerConstructor } from '../streamer/types'; @@ -47,6 +48,8 @@ export class NotificationsModule { public readonly streamPresence: IStreamer<'user-presence'>; + public readonly streamVideoConference: IStreamer<'video-conference'>; + constructor(private Streamer: IStreamerConstructor) { this.streamAll = new this.Streamer('notify-all'); this.streamLogged = new this.Streamer('notify-logged'); @@ -91,6 +94,7 @@ export class NotificationsModule { this.streamUser = new this.Streamer('notify-user'); this.streamLocal = new this.Streamer('local'); + this.streamVideoConference = new this.Streamer('video-conference'); } configure(): void { @@ -459,6 +463,27 @@ export class NotificationsModule { } }); + this.streamVideoConference.allowWrite('none'); + // Conference membership authorizes following the call — members may have no access to the room it + // originated in — and so does access to a room the chat lives in. `canAccessConference` is the same rule + // the REST endpoints apply, shared so the stream and the endpoints cannot drift into different answers + // for the same person: membership alone would refuse a room member who opens the conference before their + // join lands, and a refused subscription is never retried. + this.streamVideoConference.allowRead(async function (eventName) { + const user = await getCachedUserForPublication(this); + if (!user) { + return false; + } + + const [callId] = eventName.split('/'); + const call = await VideoConference.findOneById(callId, { projection: { users: 1, rid: 1, discussionRid: 1 } }); + if (!call) { + return false; + } + + return canAccessConference(call, user._id); + }); + this.streamLocal.serverOnly = true; this.streamLocal.allowRead('none'); this.streamLocal.allowEmit('all'); @@ -527,6 +552,11 @@ export class NotificationsModule { progressUpdated(progress: { rate: number } | ImporterProgress): void { this.streamImporters.emit('progress', progress); } + + /** Tells whoever is watching this conference that it changed and is worth reading again. */ + notifyVideoConferenceUpdated(callId: string): void { + this.streamVideoConference.emit(`${callId}/updated`); + } } type ExtractNotifyUserEventName< diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index fc63702ff1cd9..690299210e903 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -2,7 +2,7 @@ import { Apps } from '@rocket.chat/apps'; import type { AppVideoConfProviderManager } from '@rocket.chat/apps/dist/server/managers/AppVideoConfProviderManager'; import type { VideoConfData, VideoConfDataExtended } from '@rocket.chat/apps-engine/definition/videoConfProviders'; import type { IVideoConfService, VideoConferenceJoinOptions } from '@rocket.chat/core-services'; -import { api, ServiceClassInternal, Room } from '@rocket.chat/core-services'; +import { api, ServiceClassInternal, Message, Presence, Room } from '@rocket.chat/core-services'; import type { IDirectVideoConference, ILivechatVideoConference, @@ -19,14 +19,20 @@ import type { IStats, VideoConference, VideoConferenceCapabilities, + JoinableVideoConference, + VideoConferenceChatAccess, + VideoConferenceChatAccessMode, VideoConferenceCreateData, Optional, ExternalVideoConference, IVoIPVideoConference, } from '@rocket.chat/core-typings'; import { + UserStatus, VideoConferenceStatus, + hasJoinedVideoConference, isDirectVideoConference, + isInVideoConference, isGroupVideoConference, isLivechatVideoConference, } from '@rocket.chat/core-typings'; @@ -40,14 +46,21 @@ import type * as UiKit from '@rocket.chat/ui-kit'; import { Meteor } from 'meteor/meteor'; import { MongoInternals } from 'meteor/mongo'; -import { availabilityErrors } from '../../../lib/videoConference/constants'; +import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { resolveChatAccessMode } from '../../../lib/videoConference/chatAccess'; +import { conferenceNameFor } from '../../../lib/videoConference/conferenceName'; +import { availabilityErrors, CALL_FACES_SHOWN, EMPTY_CALL_GRACE_MS, shouldRingRecipients } from '../../../lib/videoConference/constants'; +import { canRingConferenceMember, isUnaskedConferenceMember } from '../../../lib/videoConference/memberStatus'; +import { expiredPresenceLeases, INFERRED_LEAVE_REASONS } from '../../../lib/videoConference/presence'; import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; +import { hasAtLeastOnePermissionAsync } from '../../lib/authorization/hasPermission'; import { callbacks } from '../../lib/callbacks'; import { i18n } from '../../lib/i18n'; import { isRoomCompatibleWithVideoConfRinging } from '../../lib/isRoomCompatibleWithVideoConfRinging'; import { RocketChatAssets } from '../../lib/media/assets'; import { sendMessage } from '../../lib/messages/sendMessage'; +import { follow } from '../../lib/messaging/threads/functions'; import { metrics } from '../../lib/metrics/lib/metrics'; import { Push } from '../../lib/notifications/push/push'; import PushNotification from '../../lib/notifications/push-config/lib/PushNotification'; @@ -59,6 +72,7 @@ import { getUserAvatarURL } from '../../lib/utils/getUserAvatarURL'; import { getUserPreference } from '../../lib/utils/lib/getUserPreference'; import { videoConfProviders } from '../../lib/videoConfProviders'; import { videoConfTypes } from '../../lib/videoConfTypes'; +import { addUsersToRoomMethod } from '../../meteor-methods/rooms/addUsersToRoom'; import { settings } from '../../settings'; const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; @@ -125,7 +139,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf createdBy: caller, rid, providerName, - } as VideoConferenceCreateData; + }; if (data.type === 'videoconference') { data.title = title; @@ -495,6 +509,32 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf void api.broadcast('room.video-conference', { rid, callId }); } + /** + * Whether the provider runs the call inside Rocket.Chat rather than at a page of its own. + * + * A question asked all over this file — of a call, of a provider name, positively and negatively — and one + * worth asking through a name, because "embedded" is a claim about where the media runs and half the rules + * here turn on it. + */ + private isEmbeddedProvider(providerName: string): boolean { + return videoConfProviders.getProviderCapabilities(providerName)?.embedded === true; + } + + /** Whether the provider supports a chat that outlives the call — see `maybeCreateDiscussion`. */ + private supportsPersistentChat(providerName: string): boolean { + return videoConfProviders.getProviderCapabilities(providerName)?.persistentChat === true; + } + + /** + * Tells anyone watching the conference that something about it moved — its membership, its chat's room, or who + * can read that chat. Whichever it was, the answer on the other side is to read the conference again, so this + * is one signal rather than three: the call window needs it to know whether it is still waiting on anyone, and + * a participant's chat panel needs it to follow the chat. + */ + private notifyConferenceUpdate(callId: VideoConference['_id']): void { + void api.broadcast('video-conference.updated', { callId }); + } + private async endCall(callId: VideoConference['_id']): Promise { const call = await this.getUnfiltered(callId); if (!call) { @@ -505,13 +545,26 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await this.runVideoConferenceChangedEvent(call._id); this.notifyVideoConfUpdate(call.rid, call._id); + if (this.isEmbeddedProvider(call.providerName)) { + await this.notifyCallAndRoomUsers(call, 'end', { + callId: call._id, + rid: call.rid, + uid: call.createdBy._id, + }); + + // Ending the call ends it for whoever was still in it, and each of them is owed their status back. Nobody + // else reports their departure: the call is over, so there is no leave left to arrive. Only embedded joins + // claim busy in the first place, so only they have anything to give back. + await Promise.all(call.users.filter(isInVideoConference).map(({ _id }) => this.releaseBusyForCall(_id))); + } + if (call.type === 'direct') { return this.endDirectCall(call); } } private async expireCall(callId: VideoConference['_id']): Promise { - const call = await VideoConferenceModel.findOneById>(callId, { projection: { messages: 1 } }); + const call = await this.getUnfiltered(callId); if (!call) { return; } @@ -531,8 +584,11 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf }).toArray(); for (const subscription of subscriptions) { - // Skip notifying users that already joined the call - if (call.users.find(({ _id }) => _id === subscription.u._id)) { + // Skip notifying users that already joined the call. Actually joined: an embedded callee is on the + // roster from the moment they are called, so mere membership would swallow the 'end' that is meant + // to stop their ringing. + const member = call.users.find(({ _id }) => _id === subscription.u._id); + if (member && hasJoinedVideoConference(member)) { continue; } @@ -577,6 +633,14 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } private async validateProvider(providerName: string): Promise { + // Embedded (built-in) providers like LiveKit are registered by core + // only when their prerequisites are satisfied (e.g. VideoConf_LiveKit_ + // Enabled + URL + API key + secret). Their presence in the registry + // IS the "fully configured" signal. Going through the apps-engine + // manager would fail because there's no app behind them. + if (this.isEmbeddedProvider(providerName)) { + return; + } const manager = await this.getProviderManager(); const configured = await manager.isFullyConfigured(providerName).catch(() => false); if (!configured) { @@ -752,19 +816,37 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await this.runNewVideoConferenceEvent(callId); + const isEmbedded = this.isEmbeddedProvider(providerName); + + // Being called makes you a member, exactly as being added to a group conference does. Without this the + // callee only appears once they answer, so nothing can tell "still ringing" from "nobody was called", + // and a call they missed leaves them no history entry. Embedded only: a non-embedded callee has always + // entered `users` by answering, and putting them there earlier would rewrite the call history their + // clients build from it. + if (isEmbedded) { + await this.addAbsentMember(callId, calleeId); + } + await this.maybeCreateDiscussion(callId, user); const call = (await this.getUnfiltered(callId)) as IDirectVideoConference | null; if (!call) { throw new Error('failed-to-create-direct-call'); } - const url = await this.generateNewUrl(call); - await VideoConferenceModel.setUrlById(callId, url); + // Embedded providers (LiveKit) don't have an external URL to open — + // the call is rendered inline. Skip URL generation for them. + if (!isEmbedded) { + const url = await this.generateNewUrl(call); + await VideoConferenceModel.setUrlById(callId, url); + } const messageId = await this.createMessage(call, user); call.messages.started = messageId; await VideoConferenceModel.setMessageById(callId, 'started', messageId); + // Auto-follow the thread for anyone who joined between call creation and message creation. + await this.autoFollowCallThreadForAllParticipants(call); + // After 40 seconds if the status is still "calling", we cancel the call automatically. setTimeout(async () => { try { @@ -783,7 +865,11 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } }, 40000); - await this.sendPushNotification(call, calleeId); + // A non-embedded call rings the callee's phone now, at creation, as it always has. Embedded calls hold + // the push back until the caller actually enters the call — see `ringCalleeOnCallerArrival`. + if (!isEmbedded) { + await this.sendPushNotification(call, calleeId); + } return { type: 'direct', @@ -792,6 +878,28 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf }; } + /** + * Everyone the end of a call concerns, which is not the same as everyone in its room. + * + * Being added to a conference grants no room access — the third person in a DM call is a member of the call + * with no subscription to the DM it started in — so a broadcast that walks subscriptions alone never reached + * them, and their window went on showing a call that had ended. Their own membership is added to the room's, + * the way `assignDiscussionToConference` does it when the chat moves. + * + * Only the end is broadcast this way. `ring` and `started` are about a call appearing in a room, and someone + * outside that room learns of it by being rung rather than by watching the room. + */ + private async notifyCallAndRoomUsers( + call: AtLeast, + action: string, + params: { uid: IUser['_id']; rid: IRoom['_id']; callId: VideoConference['_id'] }, + ): Promise { + const roomMemberIds = (await Subscriptions.findByRoomId(call.rid, { projection: { 'u._id': 1 } }).toArray()).map(({ u }) => u._id); + const recipients = new Set([...roomMemberIds, ...call.users.map(({ _id }) => _id)]); + + recipients.forEach((userId) => this.notifyUser(userId, action, params)); + } + private async notifyUsersOfRoom( rid: IRoom['_id'], uid: IUser['_id'], @@ -834,16 +942,25 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('failed-to-create-group-call'); } - const url = await this.generateNewUrl(call); - await VideoConferenceModel.setUrlById(callId, url); - - call.url = url; + // Embedded providers (LiveKit) render the call inline in Rocket.Chat — + // no URL handoff. Skip both URL generation and ringing notifications: + // the call shows up as an "active call" banner in the room and other + // participants tap to join. No incoming-call sound/modal. + const isEmbedded = this.isEmbeddedProvider(providerName); + if (!isEmbedded) { + const url = await this.generateNewUrl(call); + await VideoConferenceModel.setUrlById(callId, url); + call.url = url; + } const messageId = await this.createMessage(call, useAppUser ? undefined : user); call.messages.started = messageId; await VideoConferenceModel.setMessageById(callId, 'started', messageId); - if (call.ringing) { + // Auto-follow the thread for anyone who joined between call creation and message creation. + await this.autoFollowCallThreadForAllParticipants(call); + + if (call.ringing && !isEmbedded) { await this.notifyUsersOfRoom(rid, user._id, 'ring', { callId, rid, uid: call.createdBy._id }); } @@ -879,6 +996,9 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf call.messages.started = messageId; await VideoConferenceModel.setMessageById(callId, 'started', messageId); + // Auto-follow the thread for anyone who joined between call creation and message creation. + await this.autoFollowCallThreadForAllParticipants(call); + return { type: 'livechat', callId, @@ -894,6 +1014,22 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await this.runOnUserJoinEvent(call._id, user as IVideoConferenceUser); + // Embedded providers (LiveKit) don't return a URL — the client mounts the call inline via the embedded + // provider's React tree, so the empty string is what tells it there is nothing to open. The roster + // entry is the `onJoinVideoConference` callback's doing, fired above for every provider alike. + if (this.isEmbeddedProvider(call.providerName)) { + if (user) { + await this.notifyUsersOfRoom(call.rid, user._id, 'started', { + callId: call._id, + rid: call.rid, + uid: call.createdBy._id, + }); + + this.notifyUser(user._id, 'started', { callId: call._id, rid: call.rid, uid: call.createdBy._id }); + } + return ''; + } + return this.getUrl(call, user, options); } @@ -926,7 +1062,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf _id: call._id, type: call.type, rid: call.rid, - createdBy: call.createdBy as Required, + createdBy: call.createdBy, title, providerData: call.providerData, discussionRid: call.discussionRid, @@ -993,7 +1129,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf type: call.type, rid: call.rid, url: call.url, - createdBy: call.createdBy as Required, + createdBy: call.createdBy, providerData: { ...(call.providerData || {}), ...{ customCallTitle: await this.getCallTitleForUser(call, user?._id) }, @@ -1026,6 +1162,13 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('video-conf-provider-unavailable'); } + // Embedded (built-in) providers have no apps-engine app behind them, + // so the provider-manager dispatch would be a no-op at best and + // throw at worst. Skip the lifecycle hook for them. + if (this.isEmbeddedProvider(call.providerName)) { + return; + } + return (await this.getProviderManager()).onNewVideoConference(call.providerName, call); } @@ -1044,6 +1187,10 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('video-conf-provider-unavailable'); } + if (this.isEmbeddedProvider(call.providerName)) { + return; + } + return (await this.getProviderManager()).onVideoConferenceChanged(call.providerName, call); } @@ -1062,6 +1209,10 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('video-conf-provider-unavailable'); } + if (this.isEmbeddedProvider(call.providerName)) { + return; + } + return (await this.getProviderManager()).onUserJoin(call.providerName, call, user); } @@ -1075,26 +1226,716 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await this.addUserToDiscussion(call.discussionRid, _id); } - if (call.users.find((user) => user._id === _id)) { + // The whole join-side lifecycle below only exists for embedded providers. A non-embedded call (Jitsi, + // Meet, ...) has no leave, no heartbeat and no sweep — nothing would ever undo what gets claimed here — + // so for those a join must do what it always did: record the member, and nothing else. + const isEmbedded = this.isEmbeddedProvider(call.providerName); + + // A user is in one call at a time, and this is where that becomes true rather than hoped for. A window that + // dies without reporting its departure — a crash, a killed tab — otherwise leaves its user counted as + // present forever, which both misreports them and keeps a finished call listed as occupied. + if (isEmbedded) { + await this.leaveOtherCalls(call._id, _id); + } + + // Already in the call — nothing to record. This asks about presence, not about having joined at some + // point: a member who joined and left is joined-ever but absent, and returning here would leave their + // `leftAt` in place, reporting them as gone while they are back on the call. + const member = call.users.find((user) => user._id === _id); + if (member && isInVideoConference(member)) { return; } - await VideoConferenceModel.addUserById(call._id, { _id, username, name, avatarETag, ts }); + // Both writes are idempotent, and both are needed: the first covers someone who wasn't a member yet + // (it no-ops for an existing member), the second marks them present. Running both also closes the race + // where two joins land between the read above and the write. + await VideoConferenceModel.addMemberById(call._id, { _id, username, name, avatarETag, ts }); + await VideoConferenceModel.setUserJoinedById(call._id, _id, ts); + this.notifyConferenceUpdate(call._id); + + // In a call is busy, for as long as it lasts. Embedded only: the claim is released by leaving, by the + // heartbeat sweep or by the call ending, and a non-embedded call has none of those — the claim would + // outrank whatever status the user sets by hand, leaving them busy forever. + if (isEmbedded) { + await this.claimBusyForCall(_id); + } + + // When persistent chat is in "thread" mode, auto-follow the call's chat + // thread so the participant receives thread notifications for messages + // sent during the call. `follow` uses $addToSet and is idempotent. + await this.autoFollowCallThread(call, _id); if (call.type === 'direct') { - return this.updateDirectCall(call as IDirectVideoConference, _id); + // The ring-on-arrival dance belongs to the embedded flow, where the caller sits on a preflight screen + // first; a non-embedded direct call already rang its callee (and pushed) when it was created. + const rang = isEmbedded ? await this.ringCalleeOnCallerArrival(call, _id) : false; + + return this.updateDirectCall(call, _id, { pushed: rang }); } this.notifyVideoConfUpdate(call.rid, call._id); } + /** + * Registers users as members of the conference without touching any room. Membership is what authorizes + * joining the call, so this is how someone outside the conference's room gets in — reading the chat is a + * separate concern, surfaced in the UI rather than decided here. + */ + public async addMembers( + uid: IUser['_id'], + callId: VideoConference['_id'], + usernames: NonNullable[], + { ring = true }: { ring?: boolean } = {}, + ): Promise { + const call = await VideoConferenceModel.findOneById(callId, { projection: { rid: 1, users: 1, endedAt: 1 } }); + if (!call) { + throw new Error('invalid-video-conference'); + } + + // A finished call is not something to add people to — and certainly not something to ring them into. + // Same answer `ringMember` gives: nobody was added. + if (call.endedAt) { + return []; + } + + const users = await Users.find>>( + { username: { $in: usernames } }, + { projection: { username: 1, name: 1, avatarETag: 1 } }, + ).toArray(); + + const added: IUser['_id'][] = []; + const ts = new Date(); + + for (const user of users) { + // Already associated with the call — leave their entry (and any `joinedAt`) untouched. + if (call.users.some(({ _id }) => _id === user._id)) { + continue; + } + + await VideoConferenceModel.addMemberById(callId, { ...user, ts }); + added.push(user._id); + } + + if (added.length) { + this.notifyVideoConfUpdate(call.rid, callId); + this.notifyConferenceUpdate(callId); + } + + // The list being rung is just the people added, and the endpoint caps a single add at the ringing limit — + // so unlike starting a call in a large room, an add can always ring. Whether it does is the adder's to + // say: someone added to carry on later is not someone to interrupt now. + if (ring && shouldRingRecipients(added.length)) { + await this.ringUsers(callId, call.rid, uid, added); + } + + return added; + } + + /** + * Records that a user dismissed the call instead of joining. + * + * This only writes to the member's entry — it never ends the conference, which is what separates + * declining a conference from rejecting a 1:1 call. A member who declines can still join afterwards. + * + * Someone rung as a room member has no entry yet, so one is created for them: without it there would be + * nowhere to record the decline. + */ + public async declineCall(uid: IUser['_id'], callId: VideoConference['_id']): Promise { + const call = await VideoConferenceModel.findOneById(callId, { projection: { rid: 1, users: 1 } }); + if (!call) { + throw new Error('invalid-video-conference'); + } + + if (!call.users.some(({ _id }) => _id === uid) && !(await this.addAbsentMember(callId, uid))) { + throw new Error('invalid-user'); + } + + await VideoConferenceModel.setUserDeclinedById(callId, uid); + this.notifyVideoConfUpdate(call.rid, callId); + this.notifyConferenceUpdate(callId); + } + + /** + * Rings one member who isn't in the call, again. + * + * A ring is one-shot, so the caller of a call nobody picked up needs a way to try again — and adding the + * same person a second time won't do it, since they are already a member. One member at a time, because + * that is the shape of the act: someone specific didn't pick up. Says whether the ring went out. + * + * A member who already left is rung too: they were there and are not now, which is exactly the case + * "call them back" is for. Someone already in the call is never rung, and neither is someone whose phone is + * ringing right now: there is nothing more to ask of them. The caller can't ring themselves. + */ + public async ringMember(uid: IUser['_id'], callId: VideoConference['_id'], memberId: IUser['_id']): Promise { + const call = await VideoConferenceModel.findOneById(callId, { projection: { rid: 1, users: 1, endedAt: 1 } }); + if (!call) { + throw new Error('invalid-video-conference'); + } + + if (call.endedAt) { + return false; + } + + const member = call.users.find(({ _id }) => _id === memberId); + if (memberId === uid || !member || !canRingConferenceMember(member)) { + return false; + } + + await this.ringUsers(callId, call.rid, uid, [memberId]); + + return true; + } + + /** + * Associates a user with the call without marking them present — being a member is not being in the call. + * + * Two paths need it: being called, and declining a call you were only rung about as a room member. In both, + * the person has to exist on the call before anything — an answer, a decline, a history row — can be recorded + * against them. Says whether it found the user, which is the only thing the two callers disagree about. + */ + private async addAbsentMember(callId: VideoConference['_id'], uid: IUser['_id']): Promise { + const user = await Users.findOneById>>(uid, { + projection: { username: 1, name: 1, avatarETag: 1 }, + }); + if (!user) { + return false; + } + + await VideoConferenceModel.addMemberById(callId, user); + return true; + } + + /** Leaves every other call this user is still counted as being in. See `addUserToCall`. */ + private async leaveOtherCalls(callId: VideoConference['_id'], uid: IUser['_id']): Promise { + // Asking the database for "still in it" rather than reading every membership and sifting in memory. + // The status predicate names the exact statuses the partial index is filtered on, which is what lets the + // planner use it; `endedAt` stays because it is the actual liveness rule (everything that ends a call sets + // both), so the semantics don't hang on the index's filter. + const others = await VideoConferenceModel.find( + { + _id: { $ne: callId }, + status: { $in: [VideoConferenceStatus.CALLING, VideoConferenceStatus.STARTED] }, + endedAt: { $exists: false }, + users: { $elemMatch: { _id: uid, joined: { $ne: false }, leftAt: { $exists: false } } }, + }, + { projection: { _id: 1 } }, + ).toArray(); + + // One at a time in practice, so the cost is a read that usually finds nothing. + await Promise.all(others.map(({ _id }) => this.leaveCall(uid, _id))); + } + + /** + * The calls that are running right now and that this user may join. + * + * This is how a call is reached without having caught its ring — which matters because a ring is one-shot and + * a conference started in a room with more than ten subscribers rings nobody at all. + * + * Nothing new is stored to answer it: the conference records already hold membership, liveness and the room. + * The scan is over *running* conferences rather than over this user's rooms, so its cost follows how many + * calls are in progress — few — rather than how many rooms the user is in. + * + * A call is offered when the user is a member of it, or is in the room it belongs to. Room *membership* rather + * than room *access*: a public channel is readable by anyone, and a call in a channel the user never joined + * has no business in their sidebar. + * + * Calls nobody is in are left out. A conference only stops when someone ends it or the expiry cron reaches it, + * so without this an abandoned one would be advertised as joinable for a day. + */ + public async listJoinableCalls(uid: IUser['_id']): Promise { + // The status predicate matches the partial index's filter so the scan can be served by it; `endedAt` is + // still the liveness rule itself. + const running = await VideoConferenceModel.find( + { status: { $in: [VideoConferenceStatus.CALLING, VideoConferenceStatus.STARTED] }, endedAt: { $exists: false } }, + // `createdBy` is here because naming a direct call needs it — a call is named after a person, and for a + // member with no subscription that person is whoever started it. + { projection: { rid: 1, discussionRid: 1, users: 1, title: 1, type: 1, createdAt: 1, createdBy: 1 }, sort: { createdAt: -1 } }, + ).toArray(); + + const occupied = running.filter(({ users }) => users.some(isInVideoConference)); + + // One query for every room in play. It decides both halves of the answer: whether the user is in the room, + // and — for a direct message, which has no name of its own — what to call it, since a DM is named after the + // other person and that name lives on each side's own subscription. + const rids = [...new Set(occupied.flatMap(({ rid, discussionRid }) => [rid, discussionRid].filter((id): id is string => !!id)))]; + const subscriptions = new Map( + rids.length + ? (await Subscriptions.findByUserIdAndRoomIds(uid, rids, { projection: { rid: 1, name: 1, fname: 1, t: 1 } }).toArray()).map( + (sub) => [sub.rid, sub], + ) + : [], + ); + + const joinable = occupied.filter((call) => { + if (call.users.some(({ _id }) => _id === uid)) { + return true; + } + + return subscriptions.has(call.rid) || (!!call.discussionRid && subscriptions.has(call.discussionRid)); + }); + + return Promise.all( + joinable.map(async (call) => { + const member = call.users.find(({ _id }) => _id === uid); + const present = call.users.filter(isInVideoConference); + const subscription = subscriptions.get(call.discussionRid || call.rid) ?? subscriptions.get(call.rid); + + return { + callId: call._id, + // The room is the last resort, and only for a call named after a room in the first place — a + // direct call is named after a person, including for a member who has no subscription to read + // one from. `getRoomName` ends at the raw room id, which is nobody's idea of a name. + name: + conferenceNameFor(call, uid, subscription?.fname || subscription?.name, subscription?.t) || (await this.getRoomName(call.rid)), + createdAt: call.createdAt, + usersCount: present.length, + // A few of them travel with the call so the list can show faces. Capped here rather than at the + // reader, because a call in a busy channel would otherwise send a roster to draw three avatars. + participants: present.slice(0, CALL_FACES_SHOWN).map(({ _id, username, name }) => ({ _id, username, name })), + joined: !!member && isInVideoConference(member), + declined: !!member?.declined, + // Whether that ring is still live is the reader's to decide, so the moment is what travels. + ...(member?.ringingAt && { ringingAt: member.ringingAt }), + }; + }), + ); + } + + /** + * Rings a set of members: the in-product ring, the desktop notification that reaches someone who isn't + * looking at the app, and the record of when it happened — which is what lets every client tell a phone that + * is ringing now from one that was rung and ignored. + */ + private async ringUsers(callId: VideoConference['_id'], rid: IRoom['_id'], uid: IUser['_id'], memberIds: IUser['_id'][]): Promise { + memberIds.forEach((memberId) => this.notifyUser(memberId, 'ring', { callId, rid, uid })); + await VideoConferenceModel.setUsersRingingById(callId, memberIds); + this.notifyConferenceUpdate(callId); + + // The ring only reaches a client that is on screen, and it is one-shot. A desktop notification is what + // reaches someone who isn't looking at the app. + await this.notifyUsersAddedToConference(uid, memberIds, callId, rid); + } + + /** + * Rings the other side of a direct call when its caller arrives in it. + * + * Creating the call is not asking anyone to answer it: the caller lands on the preflight screen first, and + * being rung into a call whose caller is still choosing a camera means answering to an empty room. So the + * ring waits for them to actually enter — which is this moment. + * + * Only members who have never been rung, so rejoining doesn't ring anyone again; the call window's own + * "ring again" is how a second attempt is asked for. + * + * Says whether it rang, because the caller arriving is also what starts the call — and starting a direct + * call pushes everyone in the room. Both would reach the same phone, a moment apart, about one call. + */ + private async ringCalleeOnCallerArrival(call: IDirectVideoConference, uid: IUser['_id']): Promise { + if (call.createdBy._id !== uid) { + return false; + } + + const absent = call.users.filter((user) => user._id !== uid && isUnaskedConferenceMember(user)); + if (!absent.length) { + return false; + } + + await this.ringUsers( + call._id, + call.rid, + uid, + absent.map(({ _id }) => _id), + ); + + // The in-product ring only reaches a client that is on screen; a direct call is also worth a push. + await Promise.all(absent.map(({ _id }) => this.sendPushNotification(call, _id))); + + return true; + } + + /** + * Records that a member left the call, and ends the conference once nobody is left in it. + * + * This is what gives a conference an end at all for providers that never report one — closing the call + * window is the only signal there is. Ending it is what writes everyone's call history, so without this a + * call sits at `STARTED` until the expiry cron notices it a day later. + * + * Leaving is not declining and not un-joining: membership and `joined` both stand, so the member keeps their + * history entry and can rejoin. + * + * The call is not ended the moment it empties. `pagehide` fires on a reload just as it does on a close, and + * the two are indistinguishable from it — so ending on the spot meant refreshing the call window killed the + * call. Instead the emptiness is confirmed after a grace period, which a rejoin cancels by simply being back + * in the call. That also absorbs a network blip taking the window down for a moment. + */ + public async leaveCall(uid: IUser['_id'], callId: VideoConference['_id']): Promise { + const call = await VideoConferenceModel.findOneById(callId, { + projection: { rid: 1, users: 1, endedAt: 1, providerName: 1, createdBy: 1 }, + }); + if (!call || call.endedAt) { + return; + } + + // Already recorded as gone, so there is nothing to record and nobody to tell. Leaving is reported more + // than once by design — the call window says so as it closes, and whatever opened it says so again if + // that window vanished without managing to — and re-stamping would move a departure that already + // happened and broadcast a roster change nothing changed. + const member = call.users.find(({ _id }) => _id === uid); + if (!member || member.leftAt) { + return; + } + + const leftAt = new Date(); + await VideoConferenceModel.setUserLeftById(callId, uid, leftAt); + this.notifyVideoConfUpdate(call.rid, callId); + this.notifyConferenceUpdate(callId); + + if (this.isEmbeddedProvider(call.providerName)) { + // Only the leaver's own devices are told 'end', so their other windows stop showing a call they are no + // longer in. Never the room: one member leaving is not the call ending — a reload fires a leave too — + // and a room-wide 'end' from here would dismiss everyone else's ringing popup and silence the caller's + // outgoing ring while the call still runs. The room-wide 'end' belongs to `endCall`, which the grace + // period below reaches once the call has actually emptied. + this.notifyUser(uid, 'end', { callId: call._id, rid: call.rid, uid: call.createdBy._id }); + + // Out of the call, so back to whatever status they had before it. Only embedded joins claim busy, + // so only they have a claim to end. + await this.releaseBusyForCall(uid); + } + + // Decide on the state we just wrote rather than the one we read, so the member who is leaving is counted + // as gone. Reading again would be a second round trip for the same answer. + const remaining = call.users.map((member) => (member._id === uid ? { ...member, leftAt } : member)); + if (remaining.some(isInVideoConference)) { + return; + } + + setTimeout(() => { + void this.endCallIfEmpty(callId).catch((err) => logger.error({ msg: 'Failed to end an empty conference', callId, err })); + }, EMPTY_CALL_GRACE_MS); + } + + /** + * Says the user is busy for as long as they are in a call, without overwriting the status they chose. + * + * A *claim* rather than a status. `internal` is the strongest source there is, so busy is what shows for as long + * as the call lasts; the status it displaced is stashed and handed back when the claim ends, which is how someone + * who set themselves away before the call is away again after it. A status the user sets *during* the call is + * queued the same way rather than displayed — the call is not overruled while it is happening, and their latest + * intent is what they are left with once it ends. + * + * Ended by id, so it can end in any order relative to a voice call's own claim: two `internal` claims stash for + * each other rather than one clobbering the other. + * + * Nothing here is allowed to break a call. Presence is a courtesy; joining is not. + */ + private async claimBusyForCall(uid: IUser['_id']): Promise { + try { + const user = await Users.findOneById(uid, { projection: { language: 1 } }); + const lng = user?.language || settings.get('Language') || 'en'; + + await Presence.setActiveState(uid, { + statusDefault: UserStatus.BUSY, + statusText: i18n.t('Presence_status_on_a_call', { lng }), + statusSource: 'internal', + statusId: this.name, + }); + } catch (err) { + logger.warn({ msg: 'Failed to mark a user busy for a call', uid, err }); + } + } + + /** Gives the user their own status back. A no-op if something with a stronger claim has taken over since. */ + private async releaseBusyForCall(uid: IUser['_id']): Promise { + try { + await Presence.endActiveState(uid, this.name); + } catch (err) { + logger.warn({ msg: 'Failed to restore a user status after a call', uid, err }); + } + } + + /** + * Renews a member's presence lease: their call window telling us it is still in the call. + * + * Provider-agnostic by construction — the conference window is ours whoever runs the media, so this is the one + * presence signal that exists for every provider. See `lib/videoConference/presence` for why presence is a + * lease rather than a reported departure. + * + * A renewal can also *revive* an inferred departure — the sweep gave up on this window while it was in fact + * alive, and this heartbeat is the correction. The revival has to undo what the eviction did: the sweep + * released their busy claim and told every watcher the roster shrank, so coming back re-claims busy (embedded + * only, exactly as joining does) and announces the roster again. An ordinary renewal changes nothing anyone + * can see, so it stays free of extra writes and notifications. + */ + public async renewPresence(uid: IUser['_id'], callId: VideoConference['_id']): Promise { + // Whether this renewal revived anything is the model's answer, decided in the same atomic step as the + // write itself — a separate read would race the member reporting a leave in between, and would happily + // call a heartbeat against an *ended* call a revival: the final throttled heartbeat of the very window + // whose expiry ended the call would then re-claim busy with no release path left to ever undo it. + const renewal = await VideoConferenceModel.renewUserPresenceById(callId, uid, new Date(), INFERRED_LEAVE_REASONS); + + // Nothing matched (the call ended, the member is unknown, or they reported leaving) or nothing was + // revived: nothing to undo, so no side effects at all. + if (!renewal?.revived) { + return; + } + + if (this.isEmbeddedProvider(renewal.providerName)) { + await this.claimBusyForCall(uid); + } + this.notifyConferenceUpdate(callId); + this.notifyVideoConfUpdate(renewal.rid, callId); + } + + /** + * Marks everyone whose presence lease has run out as having left, and ends the calls that empties. + * + * This is the durable half of leaving. `leaveCall` is the reported half: accurate, immediate, and impossible + * to rely on — it needs a live client talking to a live server, so it is lost exactly when the workspace goes + * down under a call that carries on in the provider. It is also lost by a crashed tab or a closed laptop, and + * the grace period `leaveCall` schedules for an emptied call is an in-process timer that a restart discards. + * Leases cover all of it, because their evidence lives in the database rather than in anyone's memory. + * + * Departures are stamped with the last evidence we had, never with the moment of the sweep — see + * `expiredPresenceLeases`. Callers must respect `isPresenceSweepDue` first: right after a restart every lease + * looks expired whether or not anyone actually left. + */ + public async expirePresenceLeases(now = new Date()): Promise { + for await (const call of VideoConferenceModel.findActiveWithMembers()) { + try { + // Presence leases only apply to embedded providers, whose call window is ours and sends heartbeats. + // Non-embedded providers (Jitsi, Meet, Pexip) open in an iframe/popup we don't control — no heartbeat + // is sent, so every lease would look expired and the sweep would end every call after 3 minutes. + // Those calls are cleaned up by the 24-hour TTL cron instead, exactly as they were before leases existed. + if (!this.isEmbeddedProvider(call.providerName)) { + continue; + } + + const expired = expiredPresenceLeases(call.users, now); + if (!expired.length) { + continue; + } + + for (const { uid, leftAt } of expired) { + logger.info({ msg: 'Presence lease expired', callId: call._id, uid, leftAt }); + await VideoConferenceModel.setUserLeftById(call._id, uid, leftAt, 'timeout'); + // Whoever stopped renewing is not in a call any more, whatever their client failed to say — and a + // status left on busy by a crashed tab is exactly the kind of thing nobody thinks to fix by hand. + await this.releaseBusyForCall(uid); + } + + this.notifyVideoConfUpdate(call.rid, call._id); + this.notifyConferenceUpdate(call._id); + + // No second grace period: the lease *was* the grace period, and it is far longer than the one a + // reported departure gets. Anyone who came back renewed it and is not in `expired` at all. + const remaining = call.users.filter(({ _id }) => !expired.some((lease) => lease.uid === _id)); + if (!remaining.some(isInVideoConference)) { + await this.endCall(call._id); + } + } catch (err) { + // One unreachable provider or one malformed call must not stop the sweep for every other call. + logger.error({ msg: 'Failed to expire presence leases for a conference', callId: call._id, err }); + } + } + } + + /** Ends a conference only if it is still empty — a rejoin inside the grace period is what cancels it. */ + private async endCallIfEmpty(callId: VideoConference['_id']): Promise { + const call = await VideoConferenceModel.findOneById(callId, { projection: { users: 1, endedAt: 1 } }); + if (!call || call.endedAt || call.users.some(isInVideoConference)) { + return; + } + + await this.endCall(callId); + } + + /** + * Where the conference's chat lives and which members can't read it, because membership deliberately grants + * no room access. Surfacing them is the point: the choice of how to fix it is offered once it actually + * matters, rather than being forced on whoever adds a participant — so this also reports what that choice + * is, since it depends on the room and on who is asking. + * + * Access isn't always a subscription question — a plain public channel is readable by anyone, so + * `getMembersWithoutRoomAccess` answers both that and the plain private-room case from one `Subscriptions` + * read instead of one authorization call per member. A team-owned, discussion, or ABAC-attributed room can + * grant access through paths a room+subscriptions read can't see (team membership, the parent room's own + * rules, an ABAC decision), so those still ask per member — getting one of those wrong is worse than the + * extra reads, and conferences are small. + */ + public async getChatAccess(uid: IUser['_id'], callId: VideoConference['_id']): Promise { + return (await this.resolveChatAccess(uid, callId)).access; + } + + /** + * `getChatAccess`, plus the *usernames* of the members it decided about. + * + * The public shape carries ids, because that is what a client matches against the members it already holds. A + * room invite needs usernames — and they were in hand while the ids were being worked out, so resolving the + * access doesn't have to read the conference a second time to find them. + */ + private async resolveChatAccess( + uid: IUser['_id'], + callId: VideoConference['_id'], + ): Promise<{ access: VideoConferenceChatAccess; usernamesWithoutAccess: NonNullable[] }> { + const call = await VideoConferenceModel.findOneById(callId, { projection: { rid: 1, discussionRid: 1, users: 1 } }); + if (!call) { + throw new Error('invalid-video-conference'); + } + + const rid = call.discussionRid || call.rid; + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Error('invalid-room'); + } + + const membersWithoutAccess = await this.getMembersWithoutRoomAccess( + room, + call.users.map(({ _id }) => _id), + ); + const withoutAccess = new Set(membersWithoutAccess); + + return { + access: { + rid, + name: room.fname || room.name || '', + type: room.t, + membersWithoutAccess, + // Ask the room whether it can take new members rather than testing for a DM: the room type owns that + // rule, and it accounts for cases a `t === 'd'` check would miss, like a federated DM that *can* grow. + canInvite: await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.INVITE, uid), + }, + usernamesWithoutAccess: call.users + .filter(({ _id }) => withoutAccess.has(_id)) + .map(({ username }) => username) + .filter((username): username is string => !!username), + }; + } + + /** + * A team-owned public channel can be read by any team member without them ever having subscribed to it, a + * discussion inherits its access from the parent room it was split off from, and a room carrying ABAC + * attributes can bypass subscriptions entirely — none of that is visible from this room's own subscriptions, + * so those keep asking `canAccessRoomIdAsync` once per member, exactly as before. + * + * Everything else reduces to one `Subscriptions` read for every member at once: a plain public channel (no + * team) is readable by anyone unless banned from it specifically, and a plain private room (group or DM) is + * readable only by whoever holds an actual, non-invited subscription to it. + */ + private async getMembersWithoutRoomAccess( + room: Pick, + memberIds: IUser['_id'][], + ): Promise { + if (!memberIds.length) { + return []; + } + + if ((room.t === 'c' && room.teamId) || room.prid || room.abacAttributes?.length) { + const access = await Promise.all(memberIds.map(async (_id) => ({ _id, allowed: await canAccessRoomIdAsync(room._id, _id) }))); + return access.filter(({ allowed }) => !allowed).map(({ _id }) => _id); + } + + const subscriptions = await Subscriptions.findByRoomIdAndUserIds(room._id, memberIds, { + projection: { 'u._id': 1, 'status': 1 }, + }).toArray(); + const statusByMember = new Map(subscriptions.map(({ u, status }) => [u._id, status])); + + if (room.t === 'c') { + return memberIds.filter((_id) => statusByMember.get(_id) === 'BANNED'); + } + + // A subscription with a `status` (invited, banned) doesn't count as one: only an existing, plain + // subscription does, the same as `canAccessRoomIdAsync` would find for a private room. + return memberIds.filter((_id) => !statusByMember.has(_id) || statusByMember.get(_id) !== undefined); + } + + /** + * Names a running group conference, for the person who started it. + * + * The name is what the provider is told to call the meeting and what the call is listed as everywhere it + * appears, so it is worth being able to set it once the call exists rather than only in the instant it is + * created. Only the creator: a title everyone in the call could rewrite is a title nobody can rely on. + * + * A direct call has no title of its own — it is named after the other person — so there is nothing to set. + */ + public async renameCall(uid: IUser['_id'], callId: VideoConference['_id'], title: string): Promise { + const call = await VideoConferenceModel.findOneById(callId, { + projection: { type: 1, rid: 1, createdBy: 1, endedAt: 1 }, + }); + if (!call || call.endedAt || !isGroupVideoConference(call)) { + throw new Error('error-invalid-video-conf'); + } + + if (call.createdBy._id !== uid) { + throw new Error('error-not-allowed'); + } + + const name = title.trim(); + if (!name) { + throw new Error('error-invalid-name'); + } + + await VideoConferenceModel.setTitleById(callId, name); + // Both streams: the room's message block shows the name, and so does the call window, which watches the + // conference rather than the room it started in. + this.notifyConferenceUpdate(callId); + this.notifyVideoConfUpdate(call.rid, callId); + } + + /** + * Gives every member who can't read the chat access to it, either by bringing them into the room — which + * exposes its whole history — or by moving the chat to a discussion. Both are lossy in different ways, so + * the caller says which; nothing here infers one. Returns the room the chat now lives in. + */ + public async shareChatWithMembers( + uid: IUser['_id'], + callId: VideoConference['_id'], + mode: VideoConferenceChatAccessMode, + ): Promise { + const { + access: { rid, membersWithoutAccess, canInvite }, + usernamesWithoutAccess: usernames, + } = await this.resolveChatAccess(uid, callId); + if (!membersWithoutAccess.length) { + return rid; + } + + const resolved = resolveChatAccessMode({ mode, canInvite }); + if (!resolved) { + throw new Error('error-not-allowed'); + } + + if (resolved === 'discussion') { + // The same rules regular discussion creation enforces: this path calls `createRoom` directly, so + // nothing downstream would ask. A refusal, not a fallback to inviting — see `resolveChatAccessMode`. + if ( + !settings.get('Discussion_enabled') || + !(await hasAtLeastOnePermissionAsync(uid, ['start-discussion', 'start-discussion-other-user'], rid)) + ) { + throw new Error('error-not-allowed'); + } + + // Moving the chat to a discussion announces the conference itself changed, which is what makes every + // participant's panel follow the chat to its new room. + return this.createConferenceDiscussionWithParticipants(uid, callId, usernames); + } + + const invitedRid = await this.addUsersToConferenceRoom(uid, callId, usernames); + + // Inviting leaves the conference record untouched — only who can read the chat changed — so nothing else + // tells the participants to look again. Without this their notice stays up until a reload. + this.notifyConferenceUpdate(callId); + + return invitedRid; + } + private async addAnonymousUser(call: Optional): Promise { await VideoConferenceModel.increaseAnonymousCount(call._id); } - private async updateDirectCall(call: IDirectVideoConference, newUserId: IUser['_id']): Promise { - // If it's an user that hasn't joined yet - if (call.ringing && !call.users.find(({ _id }) => _id === newUserId)) { + private async updateDirectCall(call: IDirectVideoConference, newUserId: IUser['_id'], { pushed = false } = {}): Promise { + // If it's an user that hasn't joined yet — a member who was added but never joined still counts as not + // having joined, so the ring must keep going for them. + if (call.ringing && !call.users.some(({ _id, joined }) => _id === newUserId && hasJoinedVideoConference({ joined }))) { this.notifyUser(call.createdBy._id, 'join', { rid: call.rid, uid: newUserId, callId: call._id }); if (newUserId !== call.createdBy._id) { this.notifyUser(newUserId, 'join', { rid: call.rid, uid: newUserId, callId: call._id }); @@ -1111,7 +1952,12 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf this.notifyVideoConfUpdate(call.rid, call._id); await this.runVideoConferenceChangedEvent(call._id); - await this.sendAllPushNotifications(call._id); + + // Unless the callee's phone has just been pushed by the ring on the caller's arrival: this is that same + // arrival, so the bulk push would be the second notification about one call in as many moments. + if (!pushed) { + await this.sendAllPushNotifications(call._id); + } } private isPersistentChatEnabled(): boolean { @@ -1122,8 +1968,67 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return settings.get('VideoConf_Enable_Persistent_Chat') && settings.get('Discussion_enabled') && !encryptionEnforced; } + /** + * Where a call's persistent chat lives. + * + * The setting that chooses is registered by the PR that introduces the call window, because that is the only + * thing a mode other than `main_room` describes. Unregistered — which is every workspace until then — this + * answers `main_room`, the discussion off the room that persistent chat has always created. + */ + private getPersistentChatMode(): 'thread' | 'main_room' { + return (settings.get('VideoConf_Persistent_Chat_Mode') as 'thread' | 'main_room') || 'main_room'; + } + + /** + * Auto-follow the call's chat thread for a single user. Called when a + * participant joins the call, so they receive thread notifications for + * messages posted during the conference. Only applies when persistent + * chat is enabled in "thread" mode and the started message already + * exists. The underlying `follow` is idempotent ($addToSet). + */ + private async autoFollowCallThread(call: Optional, uid: IUser['_id']): Promise { + if (!this.isPersistentChatEnabled() || this.getPersistentChatMode() !== 'thread') { + return; + } + + // Same rule as `maybeCreateDiscussion`: persistent chat is only acted on for a provider that declares + // support for it — a Jitsi call must not start following threads because the setting is on. + if (!this.supportsPersistentChat(call.providerName)) { + return; + } + + if (!call.messages.started) { + return; + } + + await follow({ tmid: call.messages.started, uid }); + } + + /** + * Auto-follow the call's chat thread for every participant already in + * the call. Called when `messages.started` is first set (i.e. the + * thread parent message has just been created) so that any user who + * joined before the message existed gets subscribed retroactively. + */ + private async autoFollowCallThreadForAllParticipants(call: VideoConference): Promise { + if (!this.isPersistentChatEnabled() || this.getPersistentChatMode() !== 'thread') { + return; + } + + // Same rule as `maybeCreateDiscussion`: only for a provider that declares persistent chat support. + if (!this.supportsPersistentChat(call.providerName)) { + return; + } + + if (!call.messages.started || !call.users.length) { + return; + } + + await Promise.all(call.users.map(({ _id }) => follow({ tmid: call.messages.started!, uid: _id }))); + } + private async maybeCreateDiscussion(callId: VideoConference['_id'], createdBy?: IUser): Promise { - if (!this.isPersistentChatEnabled()) { + if (!this.isPersistentChatEnabled() || this.getPersistentChatMode() !== 'main_room' || !settings.get('Discussion_enabled')) { return; } @@ -1140,21 +2045,230 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } // If the call provider does not explicitly support persistent chat, do not create discussions - if (!videoConfProviders.getProviderCapabilities(call.providerName)?.persistentChat) { + if (!this.supportsPersistentChat(call.providerName)) { return; } + await this.createDiscussionForConference(this.getDiscussionDisplayName(), call, createdBy); + } + + private getDiscussionDisplayName(): string { const name = settings.get('VideoConf_Persistent_Chat_Discussion_Name') || i18n.t('[date] Video Call Chat'); - let displayName; const date = new Date().toISOString().substring(0, 10); - if (name.includes('[date]')) { - displayName = name.replace('[date]', date); - } else { - displayName = `${date} ${name}`; + return name.includes('[date]') ? name.replace('[date]', date) : `${date} ${name}`; + } + + // Creates a discussion off the conference's room and points the conference's `discussionRid` at it so + // the chat continues there without exposing the parent room's history to the new participants. For a + // DM (which can't grow past two people) the discussion keeps the DM members; for other rooms it keeps + // the room's current members. In both cases the newly selected users are added. + private async createConferenceDiscussionWithParticipants( + uid: IUser['_id'], + callId: VideoConference['_id'], + usernames: NonNullable[], + ): Promise { + const [call, user] = await Promise.all([ + VideoConferenceModel.findOneById(callId, { projection: { rid: 1, discussionRid: 1 } }), + Users.findOneById(uid), + ]); + if (!call) { + throw new Error('invalid-video-conference'); + } + if (!user) { + throw new Error('invalid-user'); } - await this.createDiscussionForConference(displayName, call, createdBy); + // Build from the room the chat is *currently* in, not the room the call started in — otherwise a second + // discussion would be derived from the original room and silently drop everyone added since the first + // one. One read also covers the walk up to the top-level room: `prid` is only set for a discussion. + const baseRoom = await Rooms.findOneById>(call.discussionRid || call.rid, { + projection: { t: 1, usernames: 1, prid: 1, teamId: 1 }, + }); + if (!baseRoom) { + throw new Error('invalid-room'); + } + + const parent = baseRoom.prid ? await this.getRoomForDiscussion(baseRoom.prid) : baseRoom; + const type = await roomCoordinator.getRoomDirectives(parent.t).getDiscussionType(parent); + if (!type) { + throw new Error('error-invalid-discussion-type'); + } + + // Carry over the current participants so they keep the chat: DMs expose them on the room doc, while + // channels/groups read them from the room's subscriptions (the conference's `users` list only holds + // people who already joined the call, so it's not a good proxy for the room's members). The newly + // selected users are added on top. + const existingMembers = + baseRoom.t === 'd' + ? baseRoom.usernames || [] + : (await Subscriptions.findByRoomIdWhenUsernameExists(baseRoom._id, { projection: { 'u.username': 1 } }).toArray()) + .map((subscription) => subscription.u.username) + .filter((username): username is string => !!username); + const members = [...new Set([...existingMembers, ...usernames])].filter(Boolean); + + const name = this.getDiscussionDisplayName(); + + const discussion = await createRoom( + type, + Random.id(), + user, + members, + false, + false, + { + fname: name, + prid: parent._id, + encrypted: false, + }, + { + creator: user._id, + }, + ); + + // Leave a "discussion created" pointer in the parent room so its members can follow along. + await Message.saveSystemMessage('discussion-created', parent._id, name, user, { drid: discussion._id }); + + // The conference's `rid` always stays the original room; the chat to display is driven by + // `discussionRid`. This sets it and announces the change so participants follow along. + await this.assignDiscussionToConference(callId, discussion._id); + + // Let the newly invited users know with a desktop notification; clicking it opens the discussion. + await this.notifyUsersInvitedToConference(user, usernames, callId, discussion); + + return discussion._id; + } + + // Adds the users to the conference's active room, so they get its history — the counterpart to + // `createConferenceDiscussionWithParticipants`. + private async addUsersToConferenceRoom( + uid: IUser['_id'], + callId: VideoConference['_id'], + usernames: NonNullable[], + ): Promise { + const [call, user] = await Promise.all([ + VideoConferenceModel.findOneById(callId, { projection: { rid: 1, discussionRid: 1 } }), + Users.findOneById(uid), + ]); + if (!call) { + throw new Error('invalid-video-conference'); + } + if (!user) { + throw new Error('invalid-user'); + } + + // The active conference room is the discussion when one was created, otherwise the original room. + const rid = call.discussionRid || call.rid; + + const room = await Rooms.findOneById>(rid, { + projection: { t: 1, name: 1, fname: 1 }, + }); + if (!room) { + throw new Error('invalid-room'); + } + + await addUsersToRoomMethod(uid, { rid, users: usernames }, user); + + // Let the added users know with a desktop notification; clicking it opens the room. + await this.notifyUsersInvitedToConference(user, usernames, callId, room); + + return rid; + } + + /** + * Tells people about a conference through the desktop, for the case the in-product ring can't reach: a + * backgrounded tab, or no client open at all. Clicking focuses the app, and the "Join call" action joins the + * conference itself. + * + * Whether it carries a **room** is the one thing that matters here, because that is what makes the click + * navigate. Someone invited *into* the room can be sent there; someone merely added to the call cannot — + * membership grants no room access, so the room behind the call may be one they can't open. + */ + private async notifyUsersAboutConference({ + recipients, + sender, + callId, + rid, + title, + room, + }: { + recipients: Pick[]; + sender: AtLeast; + callId: VideoConference['_id']; + rid: IRoom['_id']; + title: string; + /** Given only when the recipients can open it, which is what lets the notification navigate there. */ + room?: AtLeast; + }): Promise { + for (const recipient of recipients) { + const text = i18n.t('You_were_invited_to_a_conference', { lng: recipient.language }); + + void api.broadcast('notify.desktop', recipient._id, { + title, + text, + // Keep it on screen until acted on — a call is worth interrupting for. + requireInteraction: true, + actions: [{ action: 'join', title: i18n.t('Join_call', { lng: recipient.language }) }], + payload: { + _id: callId, + rid, + sender: { _id: sender._id, username: sender.username as string, name: sender.name }, + ...(room && { type: room.t, name: room.name }), + conferenceId: callId, + message: { msg: text }, + // The ringing popup plays the ringtone. Left unset this would also play the new-message sound, + // so a call announced itself as a message arriving. + audioNotificationValue: 'none', + }, + }); + } + } + + /** Tells the users just added to a conference that it is ringing for them. */ + private async notifyUsersAddedToConference( + adderId: IUser['_id'], + memberIds: IUser['_id'][], + callId: VideoConference['_id'], + rid: IRoom['_id'], + ): Promise { + const [adder, members] = await Promise.all([ + Users.findOneById>(adderId, { projection: { username: 1, name: 1 } }), + Users.find>({ _id: { $in: memberIds } }, { projection: { language: 1 } }).toArray(), + ]); + + if (!adder) { + return; + } + + await this.notifyUsersAboutConference({ + recipients: members, + sender: adder, + callId, + rid, + title: adder.name || adder.username || '', + }); + } + + /** Tells the users just invited into the conference's room about it; clicking takes them to that room. */ + private async notifyUsersInvitedToConference( + inviter: AtLeast, + usernames: NonNullable[], + callId: VideoConference['_id'], + room: AtLeast, + ): Promise { + const invitedUsers = await Users.find>( + { username: { $in: usernames } }, + { projection: { language: 1 } }, + ).toArray(); + + await this.notifyUsersAboutConference({ + recipients: invitedUsers, + sender: inviter, + callId, + rid: room._id, + title: room.fname || room.name || '', + room, + }); } private async getRoomForDiscussion( @@ -1222,7 +2336,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('invalid-room-id'); } - const call = await VideoConferenceModel.findOneById(callId, { projection: { users: 1, messages: 1 } }); + const call = await VideoConferenceModel.findOneById(callId, { projection: { rid: 1, users: 1, messages: 1 } }); if (!call) { return; } @@ -1233,8 +2347,24 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await VideoConferenceModel.setDiscussionRidById(callId, rid); } - if (room) { - await Promise.all(call.users.map(({ _id }) => this.addUserToDiscussion(room._id, _id))); + try { + if (room) { + // Everyone involved with the call should land in the new discussion: the conference's members + // (including any added from outside the room) plus the original room's members, who were part of + // the conversation before the chat moved. Members who never joined the call are included on + // purpose — the discussion is where they catch up. + const roomMemberIds = (await Subscriptions.findByRoomId(call.rid, { projection: { 'u._id': 1 } }).toArray()).map(({ u }) => u._id); + const recipients = new Set([...call.users.map(({ _id }) => _id), ...roomMemberIds]); + + await Promise.all([...recipients].map((uid) => this.addUserToDiscussion(room._id, uid))); + } + } finally { + // Tell every participant's client that the conference's chat moved, so an open conference view + // can follow it. + this.notifyConferenceUpdate(callId); + // Also refresh the in-room conference message block, which listens on `notify-room/videoconf` + // (the same channel used when users join), so its "Join discussion" button updates. + this.notifyVideoConfUpdate(call.rid, callId); } } diff --git a/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfCancelProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfCancelProps.spec.ts deleted file mode 100644 index d3874927ae7a8..0000000000000 --- a/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfCancelProps.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { isVideoConfCancelProps } from '@rocket.chat/rest-typings'; -import { assert } from 'chai'; - -describe('VideoConfCancelProps (definition/rest/v1)', () => { - describe('isVideoConfCancelProps', () => { - it('should be a function', () => { - assert.isFunction(isVideoConfCancelProps); - }); - it('should return false when provided anything that is not an VideoConfCancelProps', () => { - assert.isFalse(isVideoConfCancelProps(undefined)); - assert.isFalse(isVideoConfCancelProps(null)); - assert.isFalse(isVideoConfCancelProps('')); - assert.isFalse(isVideoConfCancelProps(123)); - assert.isFalse(isVideoConfCancelProps({})); - assert.isFalse(isVideoConfCancelProps([])); - assert.isFalse(isVideoConfCancelProps(new Date())); - assert.isFalse(isVideoConfCancelProps(new Error())); - }); - it('should return false if callId is not provided to VideoConfCancelProps', () => { - assert.isFalse(isVideoConfCancelProps({})); - }); - - it('should accept a callId with nothing else', () => { - assert.isTrue( - isVideoConfCancelProps({ - callId: 'callId', - }), - ); - }); - - it('should return false when extra parameters are provided to VideoConfCancelProps', () => { - assert.isFalse( - isVideoConfCancelProps({ - callId: 'callId', - extra: 'extra', - }), - ); - }); - }); -}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfShareChatProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfShareChatProps.spec.ts new file mode 100644 index 0000000000000..7f4554f476624 --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfShareChatProps.spec.ts @@ -0,0 +1,26 @@ +import { isVideoConfShareChatProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +/** + * What this schema says that the others don't: a `mode` is required, and has to be one this server can actually + * act on. The "rejects a non-object", "requires the id" and "refuses extra properties" cases are + * `type: 'object'`, `required` and `additionalProperties: false` doing their job — ajv's, not ours. + */ +describe('isVideoConfShareChatProps', () => { + // The caller shows which way it is sharing rather than letting the endpoint pick, so a body without one is + // an incomplete request, not a request for the default. + it('rejects a callId with no mode', () => { + assert.isFalse(isVideoConfShareChatProps({ callId: 'callId' })); + }); + + it('accepts either way of sharing the chat', () => { + assert.isTrue(isVideoConfShareChatProps({ callId: 'callId', mode: 'invite' })); + assert.isTrue(isVideoConfShareChatProps({ callId: 'callId', mode: 'discussion' })); + }); + + // Silently doing the other thing would give away history nobody agreed to give away. + it('rejects a mode it cannot act on', () => { + assert.isFalse(isVideoConfShareChatProps({ callId: 'callId', mode: 'whatever' })); + assert.isFalse(isVideoConfShareChatProps({ callId: 'callId', mode: '' })); + }); +}); diff --git a/apps/meteor/tests/unit/lib/videoConference/chatAccess.spec.ts b/apps/meteor/tests/unit/lib/videoConference/chatAccess.spec.ts new file mode 100644 index 0000000000000..8120fc551db91 --- /dev/null +++ b/apps/meteor/tests/unit/lib/videoConference/chatAccess.spec.ts @@ -0,0 +1,42 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; + +import { chatAccessLeadsWithDiscussion, resolveChatAccessMode } from '../../../../lib/videoConference/chatAccess'; + +describe('videoConference chat access', () => { + describe('chatAccessLeadsWithDiscussion', () => { + it('leads with the invite for a public channel, whose history is already open', () => { + expect(chatAccessLeadsWithDiscussion({ canInvite: true, type: 'c' })).to.be.false; + }); + + it('leads with the discussion where the invite would expose a history that was not open', () => { + expect(chatAccessLeadsWithDiscussion({ canInvite: true, type: 'p' })).to.be.true; + expect(chatAccessLeadsWithDiscussion({ canInvite: true, type: 'd' })).to.be.true; + }); + + it('leads with the discussion whenever the room cannot take new members, whatever its type', () => { + for (const type of ['c', 'p', 'd', 'l'] as IRoom['t'][]) { + expect(chatAccessLeadsWithDiscussion({ canInvite: false, type })).to.be.true; + } + }); + }); + + describe('resolveChatAccessMode', () => { + it('honours an explicit choice the room can carry out', () => { + expect(resolveChatAccessMode({ mode: 'invite', canInvite: true })).to.equal('invite'); + expect(resolveChatAccessMode({ mode: 'discussion', canInvite: true })).to.equal('discussion'); + }); + + // Which of the two leads is the modal's business, not this one's: a discussion asked for by someone + // looking at a room that could have taken the members instead is still a discussion. + it('honours a discussion even for a room that could have taken the members instead', () => { + expect(resolveChatAccessMode({ mode: 'discussion', canInvite: true })).to.equal('discussion'); + }); + + // Falling back to the discussion would move the whole conversation on the strength of a request the + // room can't honour. Refusing leaves the decision with whoever asked. + it('refuses an invite the room cannot take, rather than quietly doing the other thing', () => { + expect(resolveChatAccessMode({ mode: 'invite', canInvite: false })).to.be.null; + }); + }); +}); diff --git a/apps/meteor/tests/unit/lib/videoConference/conferenceName.spec.ts b/apps/meteor/tests/unit/lib/videoConference/conferenceName.spec.ts new file mode 100644 index 0000000000000..4d25565f9d66b --- /dev/null +++ b/apps/meteor/tests/unit/lib/videoConference/conferenceName.spec.ts @@ -0,0 +1,72 @@ +import { expect } from 'chai'; + +import { conferenceNameFor } from '../../../../lib/videoConference/conferenceName'; + +const rodrigo = { _id: 'rodrigo', name: 'Rodrigo Nascimento', username: 'rodrigo.nascimento' }; +const alice = { _id: 'alice', name: 'Alice', username: 'alice' }; +const cleiton = { _id: 'cleiton', name: 'Cleiton', username: 'cleiton' }; + +/** A call in a DM between Rodrigo and Alice, started by Rodrigo, with Cleiton added from outside. */ +const dmCall = { + type: 'direct' as const, + createdBy: rodrigo, + users: [alice, rodrigo, cleiton], +}; + +describe('conferenceNameFor', () => { + // The bug this exists for. Cleiton is a member of the call and has no subscription to the DM it happens in, by + // design — so the name fell through to the room, a DM room has neither `name` nor `fname`, and the last resort + // was the raw room id. What he needs to know is who is calling. + it('names a direct call after whoever started it, for someone added from outside', () => { + expect(conferenceNameFor(dmCall, 'cleiton')).to.equal('Rodrigo Nascimento'); + }); + + // The creator is not the answer for the creator: they know who they are. + it('names it after the other person when the viewer started it', () => { + expect(conferenceNameFor(dmCall, 'rodrigo')).to.equal('Alice'); + }); + + // Where a subscription exists it is the better answer: a DM is named per side, and that is where the name lives. + it("prefers the reader's own subscription", () => { + expect(conferenceNameFor(dmCall, 'alice', 'Rodrigo N.')).to.equal('Rodrigo N.'); + }); + + it('names a group conference by its title, whoever is asking', () => { + const group = { type: 'videoconference' as const, title: 'Sprint planning', createdBy: rodrigo, users: [rodrigo, alice] }; + + expect(conferenceNameFor(group, 'alice')).to.equal('Sprint planning'); + expect(conferenceNameFor(group, 'alice', 'the-channel')).to.equal('Sprint planning'); + }); + + // A non-ringing DM call is `type: 'videoconference'` with a title set from `room.fname`. That title is the + // creator's view of the room, so Alice sees her own name — wrong. When the room is a DM, the per-viewer + // subscription name wins over the title. + it('prefers the subscription name over the title in a DM room', () => { + const nonRingingDm = { type: 'videoconference' as const, title: 'Alice', createdBy: rodrigo, users: [rodrigo] }; + + expect(conferenceNameFor(nonRingingDm, 'alice', 'Rodrigo Nascimento', 'd')).to.equal('Rodrigo Nascimento'); + expect(conferenceNameFor(nonRingingDm, 'rodrigo', 'Alice', 'd')).to.equal('Alice'); + }); + + // Without a subscription (member added from outside), fall back to the other-party logic even in a DM + // videoconference, so the member sees the name of whoever started the call. + it('names a DM videoconference after the creator when no subscription exists', () => { + const nonRingingDm = { type: 'videoconference' as const, title: 'Alice', createdBy: rodrigo, users: [rodrigo] }; + + expect(conferenceNameFor(nonRingingDm, 'cleiton', undefined, 'd')).to.equal('Rodrigo Nascimento'); + }); + + // A room-named call is the caller's to resolve, because only the caller can read the room — and making that + // explicit is what keeps the room lookup off the path that doesn't need it. + it('answers with nothing when only the room can say', () => { + const untitled = { type: 'videoconference' as const, createdBy: rodrigo, users: [rodrigo, alice] }; + + expect(conferenceNameFor(untitled, 'alice')).to.equal(''); + }); + + it('falls back to a username when a member has no name', () => { + const call = { ...dmCall, createdBy: { _id: 'rodrigo', username: 'rodrigo.nascimento' } }; + + expect(conferenceNameFor(call, 'cleiton')).to.equal('rodrigo.nascimento'); + }); +}); diff --git a/apps/meteor/tests/unit/lib/videoConference/memberStatus.spec.ts b/apps/meteor/tests/unit/lib/videoConference/memberStatus.spec.ts new file mode 100644 index 0000000000000..ce3451256e238 --- /dev/null +++ b/apps/meteor/tests/unit/lib/videoConference/memberStatus.spec.ts @@ -0,0 +1,118 @@ +import { RING_RECIPIENTS_LIMIT, hasJoinedVideoConference } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; + +import { shouldRingRecipients } from '../../../../lib/videoConference/constants'; +import { + canRingConferenceMember, + getConferenceMemberStatus, + isUnaskedConferenceMember, +} from '../../../../lib/videoConference/memberStatus'; + +const at = new Date('2026-08-02T10:00:00.000Z'); + +// `users[]` entries were only ever written on join before membership existed, so anything without the flag is +// historical data describing someone who did join. Reading those as "not joined" would make every past +// conference look empty. Every predicate below inherits that rule, which is why it is stated once, here. +describe('hasJoinedVideoConference', () => { + it('reads an explicit flag either way', () => { + expect(hasJoinedVideoConference({ joined: true })).to.be.true; + expect(hasJoinedVideoConference({ joined: false })).to.be.false; + }); + + it('treats an entry predating the flag as joined', () => { + expect(hasJoinedVideoConference({})).to.be.true; + expect(hasJoinedVideoConference({ joined: undefined })).to.be.true; + }); +}); + +describe('getConferenceMemberStatus', () => { + it('reports a member who is in the call as joined', () => { + expect(getConferenceMemberStatus({ joined: true })).to.equal('joined'); + }); + + it('reports a member who was added and has not answered as invited', () => { + expect(getConferenceMemberStatus({ joined: false })).to.equal('invited'); + }); + + it('reports a member who dismissed the call as declined', () => { + expect(getConferenceMemberStatus({ joined: false, declined: true })).to.equal('declined'); + }); + + it('reports a member who joined and left as left', () => { + expect(getConferenceMemberStatus({ joined: true, leftAt: at })).to.equal('left'); + }); + + // The entry accumulates rather than replaces, so the fields have to be read in order of what happened last. + it('prefers having joined over an earlier decline', () => { + expect(getConferenceMemberStatus({ joined: true, declined: true, declinedAt: at } as never)).to.equal('joined'); + }); + + it('prefers having left over an earlier decline, since they did answer', () => { + expect(getConferenceMemberStatus({ joined: true, declined: true, leftAt: at })).to.equal('left'); + }); +}); + +describe('canRingConferenceMember', () => { + const now = at.getTime(); + + it('will not ring someone already in the call', () => { + expect(canRingConferenceMember({ joined: true }, now)).to.be.false; + }); + + it('rings anyone who is not', () => { + expect(canRingConferenceMember({ joined: false }, now)).to.be.true; + expect(canRingConferenceMember({ joined: false, declined: true }, now)).to.be.true; + expect(canRingConferenceMember({ joined: true, leftAt: at }, now)).to.be.true; + }); + + // There is nothing to ask for while their phone is already ringing. + it('will not ring a member who is being rung right now', () => { + expect(canRingConferenceMember({ joined: false, ringingAt: new Date(now - 3_000) }, now)).to.be.false; + }); + + // A ring is one-shot and stops on its own, with nothing to announce that it has — so the offer comes back. + it('rings a member whose ring has since run out', () => { + expect(canRingConferenceMember({ joined: false, ringingAt: new Date(now - 30_000) }, now)).to.be.true; + }); + + // Declining is an answer: the phone has stopped, so calling back is immediately on the table. + it('rings a member who declined the ring that is still inside its window', () => { + const ringingAt = new Date(now - 3_000); + expect(canRingConferenceMember({ joined: false, ringingAt, declined: true, declinedAt: new Date(now - 1_000) }, now)).to.be.true; + }); + + // A decline recorded *before* this ring says nothing about it — they were rung again since. + it('will not ring a member whose decline predates the current ring', () => { + expect( + canRingConferenceMember({ joined: false, ringingAt: new Date(now - 3_000), declined: true, declinedAt: new Date(now - 60_000) }, now), + ).to.be.false; + }); +}); + +describe('isUnaskedConferenceMember', () => { + it('is true for someone nobody has asked yet', () => { + expect(isUnaskedConferenceMember({ joined: false })).to.be.true; + }); + + // All three mean they have been asked: their phone rang, they answered, or they turned it down. + it('is false once they have been rung, joined, or declined', () => { + expect(isUnaskedConferenceMember({ joined: false, ringingAt: at })).to.be.false; + expect(isUnaskedConferenceMember({ joined: true })).to.be.false; + expect(isUnaskedConferenceMember({ joined: false, declined: true })).to.be.false; + }); +}); + +describe('shouldRingRecipients', () => { + // A ring is a broadcast per recipient, so past a point the batch is not worth sending and rings nobody. + it('rings a list up to the limit, and none beyond it', () => { + expect(shouldRingRecipients(1)).to.be.true; + expect(shouldRingRecipients(RING_RECIPIENTS_LIMIT)).to.be.true; + expect(shouldRingRecipients(RING_RECIPIENTS_LIMIT + 1)).to.be.false; + }); + + // Nobody to ring is not the same as a list small enough to ring — it saves a pointless broadcast when + // every user in an add turned out to be a member already. + it('rings nobody for an empty list', () => { + expect(shouldRingRecipients(0)).to.be.false; + }); +}); diff --git a/apps/meteor/tests/unit/lib/videoConference/presence.spec.ts b/apps/meteor/tests/unit/lib/videoConference/presence.spec.ts new file mode 100644 index 0000000000000..bf810e9127eb9 --- /dev/null +++ b/apps/meteor/tests/unit/lib/videoConference/presence.spec.ts @@ -0,0 +1,88 @@ +import type { IVideoConferenceUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; + +import { + PRESENCE_HEARTBEAT_MS, + PRESENCE_LEASE_MS, + expiredPresenceLeases, + isPresenceSweepDue, +} from '../../../../lib/videoConference/presence'; + +const ts = new Date('2026-08-02T10:00:00.000Z'); +const at = (offsetMs: number) => new Date(ts.getTime() + offsetMs); + +const member = (overrides: Partial & Pick): IVideoConferenceUser => ({ + username: `${overrides._id}.user`, + name: overrides._id, + avatarETag: null, + ts, + joined: true, + joinedAt: ts, + ...overrides, +}); + +describe('expiredPresenceLeases', () => { + it('keeps a member whose lease is still good', () => { + const users = [member({ _id: 'fresh', lastSeenAt: at(PRESENCE_LEASE_MS) })]; + + expect(expiredPresenceLeases(users, at(PRESENCE_LEASE_MS + 1))).to.deep.equal([]); + }); + + it('gives up on a member who stopped renewing', () => { + const users = [member({ _id: 'gone', lastSeenAt: ts })]; + + expect(expiredPresenceLeases(users, at(PRESENCE_LEASE_MS))).to.deep.equal([{ uid: 'gone', leftAt: ts }]); + }); + + // The whole reason a watermark is kept rather than just a flag. A call recovered twenty minutes after the + // workspace went down must not add twenty minutes to everyone's call history: the last evidence is the honest + // answer, and it happens to land at about the moment the lights went out. + it('dates the departure from the last evidence, not from the sweep', () => { + const lastSeenAt = at(60_000); + const users = [member({ _id: 'gone', lastSeenAt })]; + + const [expired] = expiredPresenceLeases(users, at(20 * 60_000)); + + expect(expired.leftAt).to.deep.equal(lastSeenAt); + }); + + // A conference that was already running when leases arrived has members with nothing but a join to go on. + // Reading that as the last evidence is what lets the sweep clear ghosts left behind by the old behaviour. + it('falls back to the join, then to the membership, for entries written before leases existed', () => { + const joinedAt = at(60_000); + + expect(expiredPresenceLeases([member({ _id: 'old', joinedAt })], at(60_000 + PRESENCE_LEASE_MS))).to.deep.equal([ + { uid: 'old', leftAt: joinedAt }, + ]); + expect(expiredPresenceLeases([member({ _id: 'older', joinedAt: undefined })], at(PRESENCE_LEASE_MS))).to.deep.equal([ + { uid: 'older', leftAt: ts }, + ]); + }); + + // Presence is joined-and-not-left, so the two states that aren't presence have no lease to lose. Reporting + // them would rewrite a departure the member reported themselves with a later, invented one. + it('ignores members who never joined or have already left', () => { + const users = [ + member({ _id: 'invited', joined: false, joinedAt: undefined }), + member({ _id: 'left', lastSeenAt: ts, leftAt: at(1_000) }), + ]; + + expect(expiredPresenceLeases(users, at(PRESENCE_LEASE_MS * 2))).to.deep.equal([]); + }); +}); + +describe('isPresenceSweepDue', () => { + // The guard that makes leases survivable across a restart: from the database, "everyone left" and "we were not + // here to be told" are the same picture, and only the clock tells them apart. + it('holds off until a process has been up for a full lease', () => { + expect(isPresenceSweepDue(0)).to.be.false; + expect(isPresenceSweepDue(PRESENCE_LEASE_MS - 1)).to.be.false; + expect(isPresenceSweepDue(PRESENCE_LEASE_MS)).to.be.true; + }); + + // The grace period is only useful if everyone still in a call gets to renew inside it — several times over, + // since a browser throttles a hidden window's timers to roughly one a minute. + it('waits long enough for a surviving window to renew', () => { + expect(PRESENCE_LEASE_MS / PRESENCE_HEARTBEAT_MS).to.be.at.least(3); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/videoConfAccess.spec.ts b/apps/meteor/tests/unit/server/lib/videoConfAccess.spec.ts new file mode 100644 index 0000000000000..d687f333ffaec --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/videoConfAccess.spec.ts @@ -0,0 +1,54 @@ +import type { VideoConference } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const canAccessRoomIdAsyncMock = sinon.stub(); + +const { canAccessConference } = p.noCallThru().load('../../../../server/lib/videoConfAccess', { + './authorization/canAccessRoom': { canAccessRoomIdAsync: canAccessRoomIdAsyncMock }, +}); + +type Call = Pick; + +const callWith = (memberIds: string[], overrides: Partial = {}): Call => + ({ + rid: 'room1', + users: memberIds.map((_id) => ({ _id })), + ...overrides, + }) as Call; + +describe('canAccessConference', () => { + beforeEach(() => { + canAccessRoomIdAsyncMock.reset(); + canAccessRoomIdAsyncMock.resolves(false); + }); + + // The regression this exists for. A conference started in a DM, joined by a third person: they are a member of + // the call and have no subscription to the DM, by design. Checking the room instead of the membership refused + // them the credentials for their own call — they saw themselves alone with controls that did nothing. + it('admits a member who has no access to the call’s room', async () => { + expect(await canAccessConference(callWith(['dm-one', 'dm-two', 'added']), 'added')).to.be.true; + expect(canAccessRoomIdAsyncMock.called, 'membership settles it without asking about the room').to.be.false; + }); + + it('admits someone who can see the room the call started in', async () => { + canAccessRoomIdAsyncMock.withArgs('room1', 'onlooker').resolves(true); + + expect(await canAccessConference(callWith(['host']), 'onlooker')).to.be.true; + }); + + // A conference's chat can move to a discussion whose members have no access to the parent room, so that + // discussion is its own way in. + it('admits someone who can see the discussion the chat moved to', async () => { + canAccessRoomIdAsyncMock.withArgs('discussion1', 'discussion-member').resolves(true); + + expect(await canAccessConference(callWith(['host'], { discussionRid: 'discussion1' }), 'discussion-member')).to.be.true; + }); + + it('refuses a stranger, and anyone not signed in', async () => { + expect(await canAccessConference(callWith(['host']), 'stranger')).to.be.false; + expect(await canAccessConference(callWith(['host']), undefined)).to.be.false; + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/addUserToCall.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/addUserToCall.spec.ts new file mode 100644 index 0000000000000..1dabc289e15c1 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/addUserToCall.spec.ts @@ -0,0 +1,150 @@ +import type { VideoConference } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { + buildGroupCall, + buildMember, + cloneFixture, + commonServiceStubs, + providerCapabilities, + resetAll, + ringedUserIds, +} from './testHarness'; + +/** + * What joining a call does *besides* recording the join, and for whom. + * + * The join-side lifecycle — leaving other calls, claiming busy, following the chat thread, ringing the callee — + * exists for embedded providers, which have a leave, a heartbeat and a sweep to undo all of it. A non-embedded + * provider has none of those, so its join must look exactly as it always has: the member is added, and nothing + * else happens. This suite pins both sides of that line; `Presence` and `follow` are observable here because the + * shared harness has no stubs for them. + */ +const proxyquire = require('proxyquire'); + +let fixture: VideoConference; + +const PresenceMock = { + setActiveState: sinon.stub().resolves(true), + endActiveState: sinon.stub().resolves(true), +}; + +const followStub = sinon.stub().resolves(); + +const VideoConferenceModelMock = { + findOneById: sinon.stub().callsFake(async () => cloneFixture(fixture)), + addMemberById: sinon.stub().resolves(), + setUserJoinedById: sinon.stub().resolves(), + setStatusById: sinon.stub().resolves(), + setRingingById: sinon.stub().resolves(), + setUsersRingingById: sinon.stub().resolves(), + find: sinon.stub().returns({ toArray: async () => [] }), +}; + +const UsersMock = { findOneById: sinon.stub().resolves({ _id: 'joiner', username: 'joiner.user', name: 'Joiner', avatarETag: null }) }; + +const broadcastStub = sinon.stub().resolves(); + +const { VideoConfService } = proxyquire.noCallThru().load('../../../../../server/services/video-conference/service', { + ...commonServiceStubs, + '@rocket.chat/core-services': { + api: { broadcast: broadcastStub }, + ServiceClassInternal: class { + onEvent() { + /* no-op */ + } + }, + Message: { saveSystemMessage: sinon.stub().resolves() }, + Room: { addUserToRoom: sinon.stub().resolves() }, + Presence: PresenceMock, + }, + '@rocket.chat/models': { + Users: UsersMock, + VideoConference: VideoConferenceModelMock, + Rooms: { findOneById: sinon.stub().resolves(null) }, + Messages: { setBlocksById: sinon.stub().resolves() }, + Subscriptions: { + findByRoomIdAndNotUserId: sinon.stub().returns({ toArray: sinon.stub().resolves([]), forEach: sinon.stub().resolves() }), + }, + }, + '../../lib/messaging/threads/functions': { follow: followStub }, + // Persistent chat fully on, in thread mode: what proves the *provider* gate below is the gate that held. + // Discussions have to be on for that, and the E2E keys stay off, since enforced encryption on private rooms + // switches persistent chat back off. + '../../settings': { + settings: { + get: (key: string) => + ( + ({ VideoConf_Enable_Persistent_Chat: true, VideoConf_Persistent_Chat_Mode: 'thread', Discussion_enabled: true }) as Record< + string, + unknown + > + )[key], + }, + }, +}); + +describe('VideoConfService.addUserToCall provider gating', () => { + let service: any; + + beforeEach(() => { + service = new VideoConfService(); + providerCapabilities.current = undefined; + resetAll( + PresenceMock.setActiveState, + PresenceMock.endActiveState, + followStub, + VideoConferenceModelMock.findOneById, + VideoConferenceModelMock.addMemberById, + VideoConferenceModelMock.setUserJoinedById, + VideoConferenceModelMock.find, + broadcastStub, + ); + }); + + afterEach(() => { + providerCapabilities.current = undefined; + }); + + // The invariant the gating exists for: a Jitsi/Meet/BBB join must have exactly the effects it had before the + // embedded lifecycle existed — the member is recorded, and nothing else fires. + it('only records the member for a non-embedded provider: no other-call sweep, no busy claim, no follow, no ring', async () => { + fixture = buildGroupCall([buildMember({ _id: 'host' })], { messages: { started: 'msg1' } }); + + await service.addUser('call1', 'joiner'); + + expect(VideoConferenceModelMock.addMemberById.calledWith('call1')).to.be.true; + expect(VideoConferenceModelMock.setUserJoinedById.calledWith('call1', 'joiner')).to.be.true; + + expect(VideoConferenceModelMock.find.called, 'queried for other calls to leave').to.be.false; + expect(PresenceMock.setActiveState.called, 'claimed busy').to.be.false; + expect(followStub.called, 'followed the call thread').to.be.false; + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); + + // The other side of the line, so a regression can't pass by never firing the lifecycle for anyone. + it('runs the whole lifecycle for an embedded provider that supports persistent chat', async () => { + providerCapabilities.current = { embedded: true, persistentChat: true }; + fixture = buildGroupCall([buildMember({ _id: 'host' })], { messages: { started: 'msg1' } }); + + await service.addUser('call1', 'joiner'); + + expect(VideoConferenceModelMock.setUserJoinedById.calledWith('call1', 'joiner')).to.be.true; + expect(VideoConferenceModelMock.find.called, 'queried for other calls to leave').to.be.true; + expect(PresenceMock.setActiveState.calledWith('joiner'), 'claimed busy').to.be.true; + expect(followStub.calledWith({ tmid: 'msg1', uid: 'joiner' }), 'followed the call thread').to.be.true; + }); + + // Fix for thread auto-follow firing for providers that never declared persistent chat support: the setting + // being on is not enough — the provider has to be able to honor it, same as the discussion path. + it('does not follow the thread for an embedded provider without the persistentChat capability', async () => { + providerCapabilities.current = { embedded: true }; + fixture = buildGroupCall([buildMember({ _id: 'host' })], { messages: { started: 'msg1' } }); + + await service.addUser('call1', 'joiner'); + + expect(VideoConferenceModelMock.setUserJoinedById.calledWith('call1', 'joiner')).to.be.true; + expect(followStub.called).to.be.false; + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts new file mode 100644 index 0000000000000..6029d6e2a1269 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts @@ -0,0 +1,277 @@ +import type { IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; +import { UserStatus } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { buildGroupCall, buildMember, cloneFixture, commonServiceStubs, providerCapabilities, resetAll } from './testHarness'; +import { PRESENCE_LEASE_MS } from '../../../../../lib/videoConference/presence'; + +/** + * The claim this suite is about is made through `Presence`, which the shared harness has no stub for — so this one + * assembles `@rocket.chat/core-services` itself rather than teaching every other spec about presence. + */ +const proxyquire = require('proxyquire'); + +let fixture: VideoConference; + +const PresenceMock = { + setActiveState: sinon.stub().resolves(true), + endActiveState: sinon.stub().resolves(true), +}; + +const VideoConferenceModelMock = { + findOneById: sinon.stub().callsFake(async () => cloneFixture(fixture)), + findActiveWithMembers: sinon.stub().callsFake(() => ({ + async *[Symbol.asyncIterator]() { + yield cloneFixture(fixture); + }, + })), + addMemberById: sinon.stub().resolves(), + setUserJoinedById: sinon.stub().resolves(), + setUserLeftById: sinon.stub().callsFake(async (_callId: string, uid: string, leftAt: Date) => { + const member = fixture.users.find((user) => user._id === uid); + if (member) { + (member as IVideoConferenceUser).leftAt = leftAt; + } + }), + // Mirrors the model's atomic contract: no match (ended call, unknown member, reported leave) answers `null`; + // a match renews the lease and says whether it revived an inferred departure — all in one step. + renewUserPresenceById: sinon.stub().callsFake(async (_callId: string, uid: string) => { + if (fixture.endedAt) { + return null; + } + const member = fixture.users.find((user) => user._id === uid); + if (!member || (member.leftAt && member.leftReason !== 'timeout')) { + return null; + } + const revived = !!member.leftAt && member.leftReason === 'timeout'; + delete member.leftAt; + delete member.leftReason; + member.lastSeenAt = new Date(); + return { revived, rid: fixture.rid, providerName: fixture.providerName }; + }), + setDataById: sinon.stub().callsFake(async (_callId: string, data: Partial) => { + Object.assign(fixture, data); + }), + setStatusById: sinon.stub().resolves(), + find: sinon.stub().returns({ toArray: async () => [] }), +}; + +const UsersMock = { findOneById: sinon.stub().resolves({ _id: 'joiner', language: 'en' }) }; + +const broadcastStub = sinon.stub().resolves(); + +const { VideoConfService } = proxyquire.noCallThru().load('../../../../../server/services/video-conference/service', { + ...commonServiceStubs, + '@rocket.chat/core-services': { + api: { broadcast: broadcastStub }, + ServiceClassInternal: class { + onEvent() { + /* no-op */ + } + }, + Message: { saveSystemMessage: sinon.stub().resolves() }, + Room: { addUserToRoom: sinon.stub().resolves() }, + Presence: PresenceMock, + }, + '@rocket.chat/models': { + Users: UsersMock, + VideoConference: VideoConferenceModelMock, + Rooms: { findOneById: sinon.stub().resolves(null) }, + Messages: { setBlocksById: sinon.stub().resolves() }, + Subscriptions: { + findByRoomIdAndNotUserId: sinon.stub().returns({ toArray: sinon.stub().resolves([]), forEach: sinon.stub().resolves() }), + findByRoomId: sinon.stub().returns({ toArray: sinon.stub().resolves([]) }), + }, + }, + '../../../lib/videoConference/constants': { availabilityErrors: {}, shouldRingRecipients: () => false, CALL_FACES_SHOWN: 2 }, +}); + +const ts = new Date('2026-08-02T10:00:00.000Z'); +const at = (offsetMs: number) => new Date(ts.getTime() + offsetMs); + +describe('VideoConfService presence while in a call', () => { + let service: any; + + beforeEach(() => { + service = new VideoConfService(); + resetAll( + PresenceMock.setActiveState, + PresenceMock.endActiveState, + VideoConferenceModelMock.setUserLeftById, + VideoConferenceModelMock.setUserJoinedById, + VideoConferenceModelMock.renewUserPresenceById, + broadcastStub, + ); + PresenceMock.setActiveState.resolves(true); + // The busy claim only exists for embedded providers — they are the ones with a leave/sweep to release it. + providerCapabilities.current = { embedded: true }; + }); + + afterEach(() => { + providerCapabilities.current = undefined; + }); + + // Being in a call is being busy, and saying so is what stops people ringing someone mid-conversation. + it('claims busy when a member joins', async () => { + fixture = buildGroupCall([buildMember({ _id: 'host' })]); + + await service.addUser('call1', 'joiner'); + + expect(PresenceMock.setActiveState.calledOnce).to.be.true; + const [uid, claim] = PresenceMock.setActiveState.firstCall.args; + expect(uid).to.equal('joiner'); + expect(claim).to.include({ statusDefault: UserStatus.BUSY, statusSource: 'internal', statusId: 'video-conference' }); + }); + + // A *claim* rather than a status: the presence service keeps whatever it displaced and hands it back, so the + // status someone chose before the call is the status they have after it. Ending by id is what lets a voice call's + // own claim end in either order relative to this one. + it('ends the claim by id when they leave, so their own status returns', async () => { + fixture = buildGroupCall([buildMember({ _id: 'other' }), buildMember({ _id: 'leaver' })]); + + await service.leaveCall('leaver', 'call1'); + + expect(PresenceMock.endActiveState.calledWith('leaver', 'video-conference')).to.be.true; + }); + + // The departure nobody reported: a crashed tab leaves a status on busy, which is exactly the sort of thing + // nobody thinks to put right by hand. + it('ends the claim for a member whose presence lease ran out', async () => { + fixture = buildGroupCall([buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) })]); + + await service.expirePresenceLeases(at(0)); + + expect(PresenceMock.endActiveState.calledWith('gone', 'video-conference')).to.be.true; + }); + + // When the call itself ends there is no leave left to arrive, so everyone still in it is owed their status back. + it('ends the claim for everyone still in a call that ends', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'present' }), + buildMember({ _id: 'left-earlier', leftAt: at(-60_000) }), + buildMember({ _id: 'never-joined', joined: false, joinedAt: undefined }), + ]); + + await service.endCall('call1'); + + const released = PresenceMock.endActiveState.args.map((args: any[]) => args[0] as string); + expect(released).to.include('present'); + expect(released).to.not.include('left-earlier'); + expect(released).to.not.include('never-joined'); + }); + + // Presence is a courtesy. A presence service that is down, or slow, or unlicensed must not be able to stop + // someone joining a call. + it('lets the join through when presence cannot be set', async () => { + fixture = buildGroupCall([buildMember({ _id: 'host' })]); + PresenceMock.setActiveState.rejects(new Error('presence is unavailable')); + + await service.addUser('call1', 'joiner'); + + expect(VideoConferenceModelMock.setUserJoinedById.calledWith('call1', 'joiner')).to.be.true; + }); + + // The sweep can be wrong: it gave up on a window that was only throttled, released the busy claim and told + // everyone the roster shrank. The heartbeat that revives the member is the correction, so it has to undo + // both — an ordinary renewal, by contrast, changes nothing anyone can see and must stay silent. + describe('when a heartbeat revives an inferred departure', () => { + const conferenceUpdates = (): number => broadcastStub.args.filter((args: unknown[]) => args[0] === 'video-conference.updated').length; + + it('re-claims busy for the revived member', async () => { + fixture = buildGroupCall([buildMember({ _id: 'reviver', leftAt: at(-60_000), leftReason: 'timeout' })]); + + await service.renewPresence('reviver', 'call1'); + + expect(VideoConferenceModelMock.renewUserPresenceById.calledOnce).to.be.true; + expect(PresenceMock.setActiveState.calledWith('reviver')).to.be.true; + expect(conferenceUpdates()).to.be.greaterThan(0); + }); + + it('changes nothing visible on an ordinary renewal', async () => { + fixture = buildGroupCall([buildMember({ _id: 'present', lastSeenAt: at(0) })]); + + await service.renewPresence('present', 'call1'); + + expect(VideoConferenceModelMock.renewUserPresenceById.calledOnce).to.be.true; + expect(PresenceMock.setActiveState.called).to.be.false; + expect(conferenceUpdates()).to.equal(0); + }); + + // A reported departure is the member's own word, and a stale heartbeat racing in behind it must not carry + // any of the revival's side effects. The model's answer is atomic with the write, so even a leave reported + // between the heartbeat arriving and the write landing answers "no match" — never a revival. + it('does nothing when the departure was reported, however the heartbeat races it', async () => { + fixture = buildGroupCall([buildMember({ _id: 'choseToLeave', leftAt: at(-60_000) })]); + + await service.renewPresence('choseToLeave', 'call1'); + + expect(PresenceMock.setActiveState.called).to.be.false; + expect(conferenceUpdates()).to.equal(0); + const member = fixture.users.find((user) => user._id === 'choseToLeave'); + expect(member?.leftAt, 'a reported departure must never be cleared by a heartbeat').to.not.be.undefined; + }); + + // The worst shape of a late heartbeat: the lease expiry that evicted this member is what emptied and ended + // the call, and the busy release that came with ending it was the last one there would ever be. Reviving + // them now would regenerate a member inside an ENDED conference and claim busy with no release path left. + it('does nothing at all for a heartbeat against a call that already ended', async () => { + fixture = buildGroupCall([buildMember({ _id: 'tooLate', leftAt: at(-60_000), leftReason: 'timeout' })], { + endedAt: at(-30_000), + }); + + await service.renewPresence('tooLate', 'call1'); + + expect(PresenceMock.setActiveState.called).to.be.false; + expect(conferenceUpdates()).to.equal(0); + const member = fixture.users.find((user) => user._id === 'tooLate'); + expect(member?.leftAt, 'the departure must stand — the call is over').to.not.be.undefined; + }); + + // The busy claim is the embedded lifecycle's; the roster correction is everyone's. + it('announces the revival without claiming busy for a non-embedded provider', async () => { + providerCapabilities.current = undefined; + fixture = buildGroupCall([buildMember({ _id: 'reviver', leftAt: at(-60_000), leftReason: 'timeout' })]); + + await service.renewPresence('reviver', 'call1'); + + expect(PresenceMock.setActiveState.called).to.be.false; + expect(conferenceUpdates()).to.be.greaterThan(0); + }); + }); + + // A non-embedded provider (Jitsi, Meet, ...) has no leave, no heartbeat and no sweep — nothing would ever + // release the claim, so a single Jitsi call would leave the user stuck on Busy forever. + describe('for a non-embedded provider', () => { + beforeEach(() => { + providerCapabilities.current = undefined; + }); + + it('never claims busy on join, but still records the join', async () => { + fixture = buildGroupCall([buildMember({ _id: 'host' })]); + + await service.addUser('call1', 'joiner'); + + expect(PresenceMock.setActiveState.called).to.be.false; + expect(VideoConferenceModelMock.setUserJoinedById.calledWith('call1', 'joiner')).to.be.true; + }); + + // No claim was ever made, so there is nothing to release — and releasing anyway would end a claim some + // other feature (a voice call) legitimately holds. + it('does not release anything on leave', async () => { + fixture = buildGroupCall([buildMember({ _id: 'other' }), buildMember({ _id: 'leaver' })]); + + await service.leaveCall('leaver', 'call1'); + + expect(PresenceMock.endActiveState.called).to.be.false; + }); + + it('does not release anything when the call ends', async () => { + fixture = buildGroupCall([buildMember({ _id: 'present' })]); + + await service.endCall('call1'); + + expect(PresenceMock.endActiveState.called).to.be.false; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/declineCall.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/declineCall.spec.ts new file mode 100644 index 0000000000000..044fde968fe06 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/declineCall.spec.ts @@ -0,0 +1,141 @@ +import type { IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; +import { VideoConferenceStatus, isInVideoConference } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { buildGroupCall, buildMember, cloneFixture, createService, resetAll } from './testHarness'; + +// Mirrors `ringing.spec.ts`'s approach: `fixture` is the single canonical record and +// `VideoConference.findOneById` hands out a clone of it on every call, regardless of projection. +let fixture: VideoConference; + +const VideoConferenceModelMock = { + findOneById: sinon.stub().callsFake(async () => cloneFixture(fixture)), + // Mirrors the model: an entry is pushed as *not* present, whatever the caller passed. Getting this wrong + // would leave fixture members with no `joined` flag at all, which every reader treats as joined. + addMemberById: sinon.stub().callsFake(async (_callId: string, member: IVideoConferenceUser) => { + fixture.users.push({ ...member, joined: false }); + }), + setUserDeclinedById: sinon.stub().callsFake(async (_callId: string, uid: string) => { + const member = fixture.users.find((user) => user._id === uid); + if (member) { + (member as IVideoConferenceUser).declined = true; + } + }), + setDataById: sinon.stub().resolves(), + setStatusById: sinon.stub().resolves(), +}; + +// `declineCall` reads `Users.findOneById` only for someone with no existing `users[]` entry (a room member +// rung who never had a membership entry created for them). +const UsersMock = { + findOneById: sinon.stub().resolves({ _id: 'roomMember', username: 'roomMember.user', name: 'Room Member' }), +}; + +const broadcastStub = sinon.stub().resolves(); + +const VideoConfService = createService({ + broadcast: broadcastStub, + models: { Users: UsersMock, VideoConference: VideoConferenceModelMock }, +}); + +// The one broadcast `declineCall` sends besides the room update: `video-conference.updated`, which is what tells +// an open call window to re-read the conference — and so its membership. +const conferenceUpdatedCalls = (): { callId: string }[] => + broadcastStub.args.filter(([channel]) => channel === 'video-conference.updated').map(([, payload]) => payload as { callId: string }); + +describe('VideoConfService.declineCall', () => { + let service: any; + + beforeEach(() => { + service = new VideoConfService(); + resetAll( + VideoConferenceModelMock.findOneById, + VideoConferenceModelMock.addMemberById, + VideoConferenceModelMock.setUserDeclinedById, + VideoConferenceModelMock.setDataById, + VideoConferenceModelMock.setStatusById, + UsersMock.findOneById, + broadcastStub, + ); + VideoConferenceModelMock.findOneById.callsFake(async () => cloneFixture(fixture)); + VideoConferenceModelMock.addMemberById.callsFake(async (_callId: string, member: IVideoConferenceUser) => { + fixture.users.push({ ...member, joined: false }); + }); + VideoConferenceModelMock.setUserDeclinedById.callsFake(async (_callId: string, uid: string) => { + const member = fixture.users.find((user) => user._id === uid); + if (member) { + (member as IVideoConferenceUser).declined = true; + } + }); + UsersMock.findOneById.resolves({ _id: 'roomMember', username: 'roomMember.user', name: 'Room Member' }); + broadcastStub.resolves(); + }); + + it("records the decline on the caller's own entry", async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'decliner', joined: false, joinedAt: undefined })]); + + await service.declineCall('decliner', 'call1'); + + expect(VideoConferenceModelMock.setUserDeclinedById.calledOnceWith('call1', 'decliner')).to.be.true; + const decliner = fixture.users.find((user) => user._id === 'decliner'); + expect(decliner?.declined).to.be.true; + }); + + // This is what separates declining a conference from rejecting a 1:1 call — a decline must never end the + // conference or write call history. + it('never ends the conference: no endedAt/status write, no call-history insert', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'decliner', joined: false, joinedAt: undefined })]); + + await service.declineCall('decliner', 'call1'); + + expect(fixture.endedAt).to.be.undefined; + expect(fixture.status).to.equal(VideoConferenceStatus.STARTED); + expect(VideoConferenceModelMock.setDataById.called).to.be.false; + expect(VideoConferenceModelMock.setStatusById.called).to.be.false; + }); + + // A user rung as a room member (never added to `users[]`) has no membership entry — one must be created so + // there is somewhere to record the decline. + it('creates an entry for someone who has none, without marking them present', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' })]); + UsersMock.findOneById.resolves({ _id: 'roomMember', username: 'roomMember.user', name: 'Room Member' }); + + await service.declineCall('roomMember', 'call1'); + + expect(VideoConferenceModelMock.addMemberById.calledOnce).to.be.true; + const [callId, member] = VideoConferenceModelMock.addMemberById.firstCall.args; + expect(callId).to.equal('call1'); + expect(member).to.include({ _id: 'roomMember' }); + + // Turning a call down is not being in it. + const created = fixture.users.find(({ _id }) => _id === 'roomMember'); + expect(created && isInVideoConference(created)).to.be.false; + + expect(VideoConferenceModelMock.setUserDeclinedById.calledOnceWith('call1', 'roomMember')).to.be.true; + }); + + // A member who declines can still join afterwards — the existing entry's `joined` flag must be left alone, + // only `declined` changes. + it("leaves an existing entry's joined flag alone", async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'decliner', joined: false, joinedAt: undefined })]); + + await service.declineCall('decliner', 'call1'); + + expect(VideoConferenceModelMock.addMemberById.called).to.be.false; + const decliner = fixture.users.find((user) => user._id === 'decliner'); + expect(decliner?.joined).to.equal(false); + expect(decliner?.declined).to.be.true; + }); + + // Open call windows re-read the conference's membership off this broadcast. + it('announces the change with an updated broadcast', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'decliner', joined: false, joinedAt: undefined })]); + + await service.declineCall('decliner', 'call1'); + + const updates = conferenceUpdatedCalls(); + expect(updates).to.have.length(1); + expect(updates[0]).to.deep.equal({ callId: 'call1' }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts new file mode 100644 index 0000000000000..bd3e244310f93 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts @@ -0,0 +1,180 @@ +import type { IVideoConferenceUser, VideoConference, VideoConferenceCapabilities } from '@rocket.chat/core-typings'; +import { VideoConferenceStatus } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { buildGroupCall, buildMember, cloneFixture, createService, resetAll } from './testHarness'; +import { PRESENCE_LEASE_MS } from '../../../../../lib/videoConference/presence'; + +const ts = new Date('2026-08-02T10:00:00.000Z'); +const at = (offsetMs: number) => new Date(ts.getTime() + offsetMs); + +/** The one canonical record, as in `leaveCall.spec`: reads are copies of it and the write stubs mutate it. */ +let fixture: VideoConference; + +const markMemberLeft = async (_callId: string, uid: string, leftAt: Date) => { + const member = fixture.users.find((user) => user._id === uid); + if (member) { + (member as IVideoConferenceUser).leftAt = leftAt; + } +}; + +const VideoConferenceModelMock = { + findOneById: sinon.stub().callsFake(async () => cloneFixture(fixture)), + // A real cursor is async-iterable, which is how the sweep walks it. + findActiveWithMembers: sinon.stub().callsFake(() => ({ + async *[Symbol.asyncIterator]() { + yield cloneFixture(fixture); + }, + })), + setUserLeftById: sinon.stub().callsFake(markMemberLeft), + setDataById: sinon.stub().callsFake(async (_callId: string, data: Partial) => { + Object.assign(fixture, data); + }), + setStatusById: sinon.stub().callsFake(async (_callId: string, status: VideoConference['status']) => { + fixture.status = status; + }), +}; + +const UsersMock = { findOneById: sinon.stub().resolves(null) }; + +const VideoConfService = createService({ + models: { + Users: UsersMock, + VideoConference: VideoConferenceModelMock, + }, + overrides: { + '../../lib/videoConfProviders': { + videoConfProviders: { + getProviderCapabilities: () => ({ embedded: true }), + }, + }, + }, +}); + +describe('VideoConfService.expirePresenceLeases', () => { + let service: any; + + beforeEach(() => { + service = new VideoConfService(); + resetAll( + VideoConferenceModelMock.findOneById, + VideoConferenceModelMock.findActiveWithMembers, + VideoConferenceModelMock.setUserLeftById, + VideoConferenceModelMock.setDataById, + VideoConferenceModelMock.setStatusById, + ); + // `resetAll` only clears history — restore the behaviours the tests below replace. + VideoConferenceModelMock.findActiveWithMembers.callsFake(() => ({ + async *[Symbol.asyncIterator]() { + yield cloneFixture(fixture); + }, + })); + VideoConferenceModelMock.setUserLeftById.resetBehavior(); + VideoConferenceModelMock.setUserLeftById.callsFake(markMemberLeft); + }); + + // The case this exists for: the workspace was down while the call carried on in the provider, so the leave + // never reached anyone. Nothing was reported and nothing had to be — the missing renewals are the evidence. + it('marks a member whose lease ran out as having left', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'staying', lastSeenAt: at(0) }), + buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) }), + ]); + + await service.expirePresenceLeases(at(0)); + + expect(VideoConferenceModelMock.setUserLeftById.calledOnce).to.be.true; + const [callId, uid, leftAt, reason] = VideoConferenceModelMock.setUserLeftById.firstCall.args; + expect({ callId, uid, reason }).to.deep.equal({ callId: 'call1', uid: 'gone', reason: 'timeout' }); + expect(leftAt).to.deep.equal(at(-PRESENCE_LEASE_MS)); + }); + + it('leaves a member who is still renewing alone', async () => { + fixture = buildGroupCall([buildMember({ _id: 'staying', lastSeenAt: at(-1_000) })]); + + await service.expirePresenceLeases(at(0)); + + expect(VideoConferenceModelMock.setUserLeftById.called).to.be.false; + expect(fixture.status).to.equal(VideoConferenceStatus.STARTED); + }); + + // The call has to *end*, not just empty: ending is what writes everyone's history, and a call left open is a + // call the room keeps offering to join. No second grace period — the lease was one, and a long one. + it('ends the call when the last lease expires', async () => { + fixture = buildGroupCall([buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) })]); + + await service.expirePresenceLeases(at(0)); + + expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); + }); + + it('keeps the call open while anyone is still in it', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'staying', lastSeenAt: at(0) }), + buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) }), + ]); + + await service.expirePresenceLeases(at(0)); + + expect(fixture.status).to.equal(VideoConferenceStatus.STARTED); + }); + + // One call that throws must not stop the sweep before it reaches the next one — otherwise a single bad + // record keeps every later call's members present forever, which is the situation the sweep exists to fix. + it('carries on to the next call when one of them fails', async () => { + fixture = buildGroupCall([buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) })]); + const second = buildGroupCall([buildMember({ _id: 'gone2', lastSeenAt: at(-PRESENCE_LEASE_MS) })], { _id: 'call2' }); + VideoConferenceModelMock.findActiveWithMembers.callsFake(() => ({ + async *[Symbol.asyncIterator]() { + yield cloneFixture(fixture); + yield cloneFixture(second); + }, + })); + VideoConferenceModelMock.setUserLeftById.onFirstCall().rejects(new Error('the write failed')); + + await service.expirePresenceLeases(at(0)); + + expect(VideoConferenceModelMock.setUserLeftById.calledWith('call2', 'gone2')).to.be.true; + }); + + describe('non-embedded providers', () => { + const NonEmbeddedModelMock = { + findActiveWithMembers: sinon.stub().callsFake(() => ({ + async *[Symbol.asyncIterator]() { + yield cloneFixture(fixture); + }, + })), + setUserLeftById: sinon.stub().resolves(), + setDataById: sinon.stub().resolves(), + setStatusById: sinon.stub().resolves(), + }; + + const NonEmbeddedService = createService({ + models: { VideoConference: NonEmbeddedModelMock }, + overrides: { + '../../lib/videoConfProviders': { + videoConfProviders: { + getProviderCapabilities: (): VideoConferenceCapabilities => ({}), + }, + }, + }, + }); + + let nonEmbeddedService: any; + + beforeEach(() => { + nonEmbeddedService = new NonEmbeddedService(); + resetAll(NonEmbeddedModelMock.setUserLeftById, NonEmbeddedModelMock.setDataById, NonEmbeddedModelMock.setStatusById); + }); + + it('skips calls from non-embedded providers (Jitsi, Meet, etc.)', async () => { + fixture = buildGroupCall([buildMember({ _id: 'jitsiUser', lastSeenAt: at(-PRESENCE_LEASE_MS * 2) })]); + + await nonEmbeddedService.expirePresenceLeases(at(0)); + + expect(NonEmbeddedModelMock.setUserLeftById.called).to.be.false; + expect(fixture.status).to.equal(VideoConferenceStatus.STARTED); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/getChatAccess.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/getChatAccess.spec.ts new file mode 100644 index 0000000000000..33096e5938043 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/getChatAccess.spec.ts @@ -0,0 +1,141 @@ +import type { IRoom, VideoConference } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { buildGroupCall, buildMember, cloneFixture, createService, resetAll } from './testHarness'; + +// `getChatAccess` reads the conference once, then decides per room whether it can answer from a single +// `Subscriptions` read or has to fall back to asking `canAccessRoomIdAsync` once per member — so each test +// configures a `fixture` (the conference) and a `room` (returned by `Rooms.findOneById`), and asserts both the +// resulting `membersWithoutAccess`/`canInvite` and which of the two access paths actually ran. +let fixture: VideoConference; +let room: Pick; + +const VideoConferenceModelMock = { + findOneById: sinon.stub().callsFake(async () => cloneFixture(fixture)), +}; + +const RoomsMock = { + findOneById: sinon.stub().callsFake(async () => ({ ...room })), +}; + +const canAccessRoomIdAsyncStub = sinon.stub(); + +const findByRoomIdAndUserIdsStub = sinon.stub(); +const SubscriptionsMock = { + findByRoomIdAndUserIds: findByRoomIdAndUserIdsStub, +}; + +// `canInvite` comes from the room directives, not from access itself — a DM can't take new members, anything +// else can, which is enough to tell the two apart without pulling in the real `roomCoordinator`. +const allowMemberActionStub = sinon.stub().callsFake(async (targetRoom: Pick) => targetRoom.t !== 'd'); + +// The two room-access paths and the invite rule are what this suite is about, so those come from the spec +// rather than from the harness's inert defaults. +const VideoConfService = createService({ + models: { + VideoConference: VideoConferenceModelMock, + Rooms: RoomsMock, + Subscriptions: SubscriptionsMock, + }, + overrides: { + '../../lib/authorization/canAccessRoom': { canAccessRoomIdAsync: canAccessRoomIdAsyncStub }, + '../../lib/rooms/roomCoordinator': { + roomCoordinator: { getRoomDirectives: () => ({ allowMemberAction: allowMemberActionStub, getDiscussionType: () => 'p' }) }, + }, + }, +}); + +describe('VideoConfService.getChatAccess', () => { + let service: any; + + beforeEach(() => { + service = new VideoConfService(); + resetAll( + VideoConferenceModelMock.findOneById, + RoomsMock.findOneById, + canAccessRoomIdAsyncStub, + findByRoomIdAndUserIdsStub, + allowMemberActionStub, + ); + VideoConferenceModelMock.findOneById.callsFake(async () => cloneFixture(fixture)); + RoomsMock.findOneById.callsFake(async () => ({ ...room })); + canAccessRoomIdAsyncStub.reset(); + findByRoomIdAndUserIdsStub.reset(); + findByRoomIdAndUserIdsStub.returns({ toArray: sinon.stub().resolves([]) }); + allowMemberActionStub.callsFake(async (targetRoom: Pick) => targetRoom.t !== 'd'); + }); + + // A plain public channel (no team) is readable by anyone — the whole point of the fast path is that this + // case costs one `Subscriptions` read (to catch a ban), never a per-member authorization call. + it('treats every member of a public channel as having access, except one explicitly banned', async () => { + fixture = buildGroupCall([buildMember({ _id: 'alice' }), buildMember({ _id: 'bob' }), buildMember({ _id: 'banned' })]); + room = { _id: 'room1', t: 'c', name: 'general', fname: 'general' }; + findByRoomIdAndUserIdsStub.returns({ + toArray: sinon.stub().resolves([{ u: { _id: 'banned' }, status: 'BANNED' }]), + }); + + const result = await service.getChatAccess('caller', 'call1'); + + expect(result.membersWithoutAccess).to.deep.equal(['banned']); + expect(result.type).to.equal('c'); + expect(canAccessRoomIdAsyncStub.called).to.be.false; + expect(findByRoomIdAndUserIdsStub.calledOnce).to.be.true; + expect(findByRoomIdAndUserIdsStub.firstCall.args[0]).to.equal('room1'); + expect(findByRoomIdAndUserIdsStub.firstCall.args[1].sort()).to.deep.equal(['alice', 'banned', 'bob']); + expect(result.canInvite).to.be.true; + }); + + // A public channel that belongs to a private team can be read through team membership alone, with no + // subscription to this specific channel — a case a `Subscriptions`-only read can't see. This has to keep + // asking the real per-member check rather than guessing from this room's own subscriptions. + it('falls back to asking per member for a public channel that belongs to a team', async () => { + fixture = buildGroupCall([buildMember({ _id: 'teamMember' }), buildMember({ _id: 'outsider' })]); + room = { _id: 'room1', t: 'c', name: 'team-channel', fname: 'team-channel', teamId: 'team1' }; + canAccessRoomIdAsyncStub.callsFake(async (_rid: string, uid: string) => uid === 'teamMember'); + + const result = await service.getChatAccess('caller', 'call1'); + + expect(result.membersWithoutAccess).to.deep.equal(['outsider']); + expect(canAccessRoomIdAsyncStub.callCount).to.equal(2); + expect(canAccessRoomIdAsyncStub.args.map(([, uid]) => uid).sort()).to.deep.equal(['outsider', 'teamMember']); + expect(findByRoomIdAndUserIdsStub.called).to.be.false; + }); + + // A private group only grants access to its subscribers — this is the other shape the fast path covers, + // one `Subscriptions` read standing in for what would otherwise be a `canAccessRoomIdAsync` call each. + it('treats only subscribed members of a private group as having access', async () => { + fixture = buildGroupCall([buildMember({ _id: 'member' }), buildMember({ _id: 'invited' }), buildMember({ _id: 'stranger' })]); + room = { _id: 'room1', t: 'p', name: 'private-group', fname: 'private-group' }; + findByRoomIdAndUserIdsStub.returns({ + toArray: sinon.stub().resolves([ + { u: { _id: 'member' }, status: undefined }, + { u: { _id: 'invited' }, status: 'INVITED' }, + ]), + }); + + const result = await service.getChatAccess('caller', 'call1'); + + expect(result.membersWithoutAccess.sort()).to.deep.equal(['invited', 'stranger']); + expect(canAccessRoomIdAsyncStub.called).to.be.false; + expect(findByRoomIdAndUserIdsStub.calledOnce).to.be.true; + expect(result.canInvite).to.be.true; + }); + + // A DM behaves like any other private room for access — subscription required — but can't take new + // members, which is what `canInvite` reports back. + it('treats only subscribed members of a DM as having access, and reports it as not invitable', async () => { + fixture = buildGroupCall([buildMember({ _id: 'member' }), buildMember({ _id: 'neverJoinedRoom' })]); + room = { _id: 'room1', t: 'd', name: 'member.other', fname: '' }; + findByRoomIdAndUserIdsStub.returns({ + toArray: sinon.stub().resolves([{ u: { _id: 'member' }, status: undefined }]), + }); + + const result = await service.getChatAccess('caller', 'call1'); + + // `neverJoinedRoom` is a conference member who was never in the room at all: no subscription document + // exists for them, which must read the same as an explicitly denied one. + expect(result.membersWithoutAccess).to.deep.equal(['neverJoinedRoom']); + expect(result.canInvite).to.be.false; + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts new file mode 100644 index 0000000000000..b0bf6592c1e37 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts @@ -0,0 +1,336 @@ +import type { IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; +import { VideoConferenceStatus } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { buildDirectCall, buildGroupCall, buildMember, cloneFixture, createService, providerCapabilities, resetAll } from './testHarness'; +import { EMPTY_CALL_GRACE_MS } from '../../../../../lib/videoConference/constants'; + +// `VideoConference.findOneById` is hit more than once per `leaveCall` → `endCall` flow, with different +// projections (`leaveCall` reads `{ rid, users, endedAt }`, `endCall`'s `getUnfiltered` reads everything). A +// real DB would answer both from the same document, so `fixture` is the single canonical record and the +// mutating model methods below write into it, regardless of the projection asked for. +let fixture: VideoConference; + +const VideoConferenceModelMock = { + // `endCall`'s `getUnfiltered` is `VideoConfService.getUnfiltered`, which itself just calls + // `VideoConference.findOneById(callId)` with no projection — there's no separate model method to stub. + findOneById: sinon.stub().callsFake(async () => cloneFixture(fixture)), + setUserLeftById: sinon.stub().callsFake(async (_callId: string, uid: string, leftAt: Date) => { + const member = fixture.users.find((user) => user._id === uid); + if (member) { + (member as IVideoConferenceUser).leftAt = leftAt; + } + }), + setDataById: sinon.stub().callsFake(async (_callId: string, data: Partial) => { + Object.assign(fixture, data); + }), + setStatusById: sinon.stub().callsFake(async (_callId: string, status: VideoConference['status']) => { + fixture.status = status; + }), + find: sinon.stub().returns({ toArray: async () => [] }), + addMemberById: sinon.stub().resolves(), + setUserJoinedById: sinon.stub().resolves(), +}; + +const UsersMock = { + findOneById: sinon.stub().resolves(null), +}; + +const broadcastStub = sinon.stub().resolves(); + +const VideoConfService = createService({ + broadcast: broadcastStub, + models: { + Users: UsersMock, + VideoConference: VideoConferenceModelMock, + // A room with another member in it, so a broadcast the service should NOT send room-wide has somebody + // it would demonstrably reach. + Subscriptions: { + findByRoomIdAndNotUserId: sinon.stub().returns({ + toArray: sinon.stub().resolves([{ u: { _id: 'other' } }]), + forEach: (cb: (subscription: { u: { _id: string } }) => void) => { + cb({ u: { _id: 'other' } }); + return Promise.resolve(); + }, + }), + // The same room read as a list, which is how the end-of-call broadcast asks for it: it adds the call's + // own members to these, and a member with no subscription here is the case that needs the union. + findByRoomId: sinon.stub().returns({ toArray: sinon.stub().resolves([{ u: { _id: 'other' } }]) }), + }, + }, + // This suite is about what happens when a call empties, so the ringing the service would otherwise do on a + // join is stubbed out of the way. The grace period must stay the real one — it is what the suite measures. + overrides: { + '../../../lib/videoConference/constants': { availabilityErrors: {}, shouldRingRecipients: () => false, EMPTY_CALL_GRACE_MS }, + }, +}); + +/** Who was told 'end' through `notifyUser` — the per-user broadcast, as opposed to the room-wide channel. */ +const endNotifiedUserIds = (): string[] => + broadcastStub.args + .filter(([channel, payload]) => channel === 'user.video-conference' && (payload as { action: string }).action === 'end') + .map(([, payload]) => (payload as { userId: string }).userId); + +describe('VideoConfService.leaveCall', () => { + let service: any; + + let clock: sinon.SinonFakeTimers; + + /** The call empties, then the grace period passes with nobody having come back. */ + const leaveAndSettle = async (uid: string) => { + await service.leaveCall(uid, 'call1'); + await clock.tickAsync(EMPTY_CALL_GRACE_MS + 1); + }; + + beforeEach(() => { + clock = sinon.useFakeTimers({ shouldAdvanceTime: false }); + service = new VideoConfService(); + resetAll( + VideoConferenceModelMock.findOneById, + VideoConferenceModelMock.setUserLeftById, + VideoConferenceModelMock.setDataById, + VideoConferenceModelMock.setStatusById, + broadcastStub, + ); + VideoConferenceModelMock.findOneById.callsFake(async () => cloneFixture(fixture)); + providerCapabilities.current = undefined; + }); + + afterEach(() => { + clock.restore(); + providerCapabilities.current = undefined; + }); + + // The reported bug: leaving the last-standing spot in a call must end it and leave every member a + // history entry, not just silently mark the leaver as gone. `creator` already left earlier, so `other` + // is genuinely the last one still in the call — this is what makes it "the last participant leaves" + // rather than just "one of several leaves". + it('ends the call when the last participant leaves', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'creator', leftAt: new Date('2026-01-01T00:30:00.000Z') }), + buildMember({ _id: 'other' }), + ]); + + await leaveAndSettle('other'); + + expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); + expect(fixture.endedAt).to.be.instanceOf(Date); + }); + + // Someone leaving while others remain must not end the call for them. + it('marks the member as left without ending the call when others remain', async () => { + fixture = buildGroupCall([buildMember({ _id: 'creator' }), buildMember({ _id: 'other' })]); + + await service.leaveCall('other', 'call1'); + + expect(fixture.status).to.equal(VideoConferenceStatus.STARTED); + expect(fixture.endedAt).to.be.undefined; + + const leaver = fixture.users.find((user) => user._id === 'other'); + expect(leaver?.leftAt).to.be.instanceOf(Date); + }); + + it('ends a direct (1:1) conference when the last participant leaves', async () => { + fixture = buildDirectCall([ + buildMember({ _id: 'creator', leftAt: new Date('2026-01-01T00:30:00.000Z') }), + buildMember({ _id: 'other' }), + ]); + + await leaveAndSettle('other'); + + expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); + }); + + // A conference that already ended (already carries `endedAt`) must not be re-processed at all — this is + // the guard `leaveCall` itself applies before touching anything. + it('does not re-process a conference that already has endedAt', async () => { + fixture = buildGroupCall([buildMember({ _id: 'creator' }), buildMember({ _id: 'other', leftAt: new Date() })], { + status: VideoConferenceStatus.ENDED, + endedAt: new Date('2026-01-01T01:00:00.000Z'), + }); + + await service.leaveCall('creator', 'call1'); + + expect(VideoConferenceModelMock.setUserLeftById.called).to.be.false; + }); + + // A member who was added to the conference but never joined (`joined: false`) has no active presence in + // the call — they must not hold it open once the only member who actually joined leaves. + it('ends the call when the leaver is the only joined member, even with an unjoined member still on the roster', async () => { + fixture = buildGroupCall([buildMember({ _id: 'creator' }), buildMember({ _id: 'neverJoined', joined: false, joinedAt: undefined })]); + + await leaveAndSettle('creator'); + + expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); + }); + + // `pagehide` fires on a reload exactly as it does on a close, so ending the moment the call empties meant + // refreshing the call window killed the call. Coming back inside the grace period must cancel it. + it('does not end the call when the last participant comes back inside the grace period', async () => { + fixture = buildGroupCall([buildMember({ _id: 'creator' })]); + + await service.leaveCall('creator', 'call1'); + + // The rejoin: what the client's own join does to the entry, which is all isInVideoConference reads. + const rejoiner = fixture.users.find((user) => user._id === 'creator'); + delete rejoiner?.leftAt; + + await clock.tickAsync(EMPTY_CALL_GRACE_MS + 1); + + expect(fixture.status).to.equal(VideoConferenceStatus.STARTED); + expect(fixture.endedAt).to.be.undefined; + }); + + it('still ends the call when nobody comes back', async () => { + fixture = buildGroupCall([buildMember({ _id: 'creator' })]); + + await service.leaveCall('creator', 'call1'); + expect(fixture.endedAt, 'ended before the grace period elapsed').to.be.undefined; + + await clock.tickAsync(EMPTY_CALL_GRACE_MS + 1); + + expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); + }); + + // One member leaving is not the call ending — a reload fires a leave too — so the room at large must not + // hear 'end': that would dismiss everyone else's ringing popup and silence the caller's outgoing ring while + // the call still runs. Only the leaver's own devices are told, so their other windows drop the call UI. + it('tells only the leaver about their own leave, never the room, for an embedded provider', async () => { + providerCapabilities.current = { embedded: true }; + fixture = buildGroupCall([buildMember({ _id: 'creator' }), buildMember({ _id: 'leaver' })]); + + await service.leaveCall('leaver', 'call1'); + + expect(endNotifiedUserIds()).to.deep.equal(['leaver']); + }); + + // The room-wide 'end' belongs to the call actually ending: once the grace period confirms the call emptied, + // everyone still holding a popup for it is told. + it('tells the room once the call actually ends', async () => { + providerCapabilities.current = { embedded: true }; + fixture = buildGroupCall([buildMember({ _id: 'creator' })]); + + await leaveAndSettle('creator'); + + expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); + expect(endNotifiedUserIds()).to.include('other'); + }); + + // Being added to a conference grants no room access, so a member can have no subscription to the room the + // call started in — the third person in a DM call is exactly that. A broadcast that walked subscriptions + // alone never reached them, and their window went on showing a call that had ended. + it('tells a member with no subscription to the room that the call ended', async () => { + providerCapabilities.current = { embedded: true }; + // Added to the call and still ringing, so the call empties when the creator goes: `invited` is in the + // membership without ever having been in the room. + fixture = buildGroupCall([buildMember({ _id: 'creator' }), buildMember({ _id: 'invited', joined: false })]); + + await leaveAndSettle('creator'); + + expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); + expect(endNotifiedUserIds()).to.include('invited'); + }); +}); + +describe('VideoConfService one call at a time', () => { + let clock: sinon.SinonFakeTimers; + let service: any; + + /** The conferences the model answers about, by id — a join reads the one being joined and any other it finds. */ + let calls: Record; + + beforeEach(() => { + clock = sinon.useFakeTimers({ shouldAdvanceTime: false }); + service = new VideoConfService(); + calls = {}; + // Leaving other calls on join is part of the embedded lifecycle — a non-embedded join records the member + // and nothing else. + providerCapabilities.current = { embedded: true }; + resetAll( + VideoConferenceModelMock.findOneById, + VideoConferenceModelMock.setUserLeftById, + VideoConferenceModelMock.find, + VideoConferenceModelMock.addMemberById, + VideoConferenceModelMock.setUserJoinedById, + UsersMock.findOneById, + ); + VideoConferenceModelMock.findOneById.callsFake(async (callId: string) => calls[callId]); + UsersMock.findOneById.resolves({ _id: 'joiner', username: 'joiner.user', name: 'Joiner', avatarETag: null }); + }); + + afterEach(() => { + clock.restore(); + providerCapabilities.current = undefined; + VideoConferenceModelMock.findOneById.callsFake(async () => cloneFixture(fixture)); + UsersMock.findOneById.resolves(null); + }); + + /** `other` is a call this user is in, alongside the `wanted` one they are about to join. */ + const joinWhileIn = async (other: VideoConference) => { + calls = { wanted: buildGroupCall([buildMember({ _id: 'host' })], { _id: 'wanted' }), [other._id]: other }; + VideoConferenceModelMock.find.returns({ toArray: async () => [other] }); + + await service.addUser('wanted', 'joiner'); + }; + + // A window that dies without reporting its departure — a crash, a killed tab — leaves its user counted as + // present forever, which both misreports them and keeps a finished call listed as occupied. Joining anything + // is the moment that can be put right. + it('leaves the call a joining user is still counted as being in', async () => { + await joinWhileIn(buildGroupCall([buildMember({ _id: 'joiner' })], { _id: 'stale' })); + + expect(VideoConferenceModelMock.setUserLeftById.calledWith('stale', 'joiner')).to.be.true; + }); + + it('joins the wanted call all the same', async () => { + await joinWhileIn(buildGroupCall([buildMember({ _id: 'joiner' })], { _id: 'stale' })); + + expect(VideoConferenceModelMock.setUserJoinedById.calledWith('wanted', 'joiner')).to.be.true; + }); + + // The query is the whole rule, so it is what this asserts: another call, still running, and one this user is + // *present* in. Membership of a call already left is not presence in it — leaving it again would write a later + // `leftAt` over the real one — and an entry with no `joined` flag predates the flag and counts as present. + it('asks only about the calls it should leave', async () => { + await joinWhileIn(buildGroupCall([buildMember({ _id: 'joiner' })], { _id: 'stale' })); + + const [query] = VideoConferenceModelMock.find.firstCall.args; + expect(query).to.deep.equal({ + _id: { $ne: 'wanted' }, + // The statuses name what the partial index is filtered on; `endedAt` remains the liveness rule itself. + status: { $in: [VideoConferenceStatus.CALLING, VideoConferenceStatus.STARTED] }, + endedAt: { $exists: false }, + users: { $elemMatch: { _id: 'joiner', joined: { $ne: false }, leftAt: { $exists: false } } }, + }); + }); +}); + +// Leaving is reported more than once by design: the call window says so as it closes, and whatever opened it +// says so again if that window vanished without managing to. The second report must change nothing — a moved +// `leftAt` would rewrite when someone left, and the broadcast would announce a roster change that didn't happen. +describe('VideoConfService.leaveCall reported twice', () => { + let service: any; + + beforeEach(() => { + service = new VideoConfService(); + }); + + it('records the departure once and says nothing the second time', async () => { + fixture = buildGroupCall([buildMember({ _id: 'stays' }), buildMember({ _id: 'goes' })]); + + await service.leaveCall('goes', 'call1'); + + const leftAt = fixture.users.find(({ _id }) => _id === 'goes')?.leftAt; + expect(leftAt).to.be.an.instanceOf(Date); + + const writes = VideoConferenceModelMock.setUserLeftById.callCount; + const broadcasts = broadcastStub.callCount; + + await service.leaveCall('goes', 'call1'); + + expect(VideoConferenceModelMock.setUserLeftById.callCount, 'wrote the departure again').to.equal(writes); + expect(broadcastStub.callCount, 'announced the departure again').to.equal(broadcasts); + expect(fixture.users.find(({ _id }) => _id === 'goes')?.leftAt).to.equal(leftAt); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/listJoinableCalls.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/listJoinableCalls.spec.ts new file mode 100644 index 0000000000000..b3678c2a1985f --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/listJoinableCalls.spec.ts @@ -0,0 +1,278 @@ +import type { IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, beforeEach, describe, it } from 'mocha'; +import sinon from 'sinon'; + +import { buildDirectCall, buildGroupCall, buildMember, createService, resetAll } from './testHarness'; +import { CALL_FACES_SHOWN } from '../../../../../lib/videoConference/constants'; + +const me = 'me'; + +let running: VideoConference[] = []; +let subscribedRids: string[] = []; +let subscriptionNames: Record = {}; + +/** + * Answers with only the fields the query asked for, as a database would. + * + * Not pedantry: handing back the whole fixture hides a projection that forgot a field the code goes on to read, + * and the failure lands in production as an endpoint that throws — which is exactly what happened here when + * naming a call started needing `createdBy`. + */ +const project = >(doc: T, projection?: Record): Partial => + projection ? (Object.fromEntries(Object.entries(doc).filter(([key]) => key === '_id' || projection[key])) as Partial) : { ...doc }; + +const VideoConferenceModelMock = { + find: sinon.stub().callsFake((_query: unknown, options?: { projection?: Record }) => ({ + toArray: async () => running.map((call) => project({ ...call, users: [...call.users] }, options?.projection)), + })), +}; + +const SubscriptionsMock = { + findByUserIdAndRoomIds: sinon.stub().callsFake((_uid: string, rids: string[]) => ({ + toArray: async () => rids.filter((rid) => subscribedRids.includes(rid)).map((rid) => ({ rid, fname: subscriptionNames[rid] })), + })), +}; + +const RoomsMock = { + findOneById: sinon.stub().callsFake(async (rid: string) => ({ _id: rid, name: `name-of-${rid}`, fname: `name-of-${rid}` })), +}; + +const VideoConfService = createService({ + models: { VideoConference: VideoConferenceModelMock, Subscriptions: SubscriptionsMock, Rooms: RoomsMock }, +}); + +describe('VideoConfService.listJoinableCalls', () => { + let service: any; + + before(() => { + // The service reads settings at construction in some paths; nothing here depends on them. + service = new VideoConfService(); + }); + + beforeEach(() => { + resetAll(VideoConferenceModelMock.find, SubscriptionsMock.findByUserIdAndRoomIds, RoomsMock.findOneById); + running = []; + subscribedRids = []; + subscriptionNames = {}; + }); + + after(() => { + sinon.restore(); + }); + + // The two ways in, and they are the same pair the endpoints authorize with: being a member of the call, or + // being in the room it belongs to. + it('offers a call the user is a member of, even with no subscription to its room', async () => { + running = [buildGroupCall([buildMember({ _id: 'other' }), buildMember({ _id: me, joined: false, joinedAt: undefined })])]; + + const calls = await service.listJoinableCalls(me); + + expect(calls.map(({ callId }: { callId: string }) => callId)).to.deep.equal(['call1']); + }); + + it('offers a call in a room the user is in, even without membership of the call', async () => { + running = [buildGroupCall([buildMember({ _id: 'other' })], { rid: 'channel' })]; + subscribedRids = ['channel']; + + const calls = await service.listJoinableCalls(me); + + expect(calls.map(({ callId }: { callId: string }) => callId)).to.deep.equal(['call1']); + }); + + // A public channel is readable by anyone, so room *access* would put calls from channels the user never joined + // in their sidebar. Being in the room is the line. + it('does not offer a call the user has no claim to', async () => { + running = [buildGroupCall([buildMember({ _id: 'other' })], { rid: 'somewhere-else' })]; + + const calls = await service.listJoinableCalls(me); + + expect(calls).to.deep.equal([]); + }); + + it('follows the chat into a discussion when that is the room the user is in', async () => { + running = [buildGroupCall([buildMember({ _id: 'other' })], { rid: 'parent', discussionRid: 'the-discussion' })]; + subscribedRids = ['the-discussion']; + + const calls = await service.listJoinableCalls(me); + + expect(calls).to.have.length(1); + }); + + // A conference only stops when someone ends it or the expiry cron reaches it, so an abandoned one would + // otherwise be advertised as joinable for a day. + it('leaves out a call nobody is in', async () => { + running = [ + buildGroupCall([buildMember({ _id: 'other', leftAt: new Date() })], { rid: 'channel', _id: 'empty' }), + buildGroupCall([buildMember({ _id: 'other' })], { rid: 'channel', _id: 'occupied' }), + ]; + subscribedRids = ['channel']; + + const calls = await service.listJoinableCalls(me); + + expect(calls.map(({ callId }: { callId: string }) => callId)).to.deep.equal(['occupied']); + }); + + // One query for every room in play, rather than one per call: it answers both whether the user is in the room + // and what a nameless room should be called. + it('asks about every room in play at once', async () => { + running = [ + buildGroupCall([buildMember({ _id: me })], { rid: 'mine', _id: 'a-member-of' }), + buildGroupCall([buildMember({ _id: 'other' })], { rid: 'channel', _id: 'by-room' }), + ]; + subscribedRids = ['channel']; + + await service.listJoinableCalls(me); + + expect(SubscriptionsMock.findByUserIdAndRoomIds.callCount).to.equal(1); + const [, rids] = SubscriptionsMock.findByUserIdAndRoomIds.firstCall.args; + expect(rids).to.deep.equal(['mine', 'channel']); + }); + + it('says nothing when no call is running', async () => { + const calls = await service.listJoinableCalls(me); + + expect(calls).to.deep.equal([]); + expect(SubscriptionsMock.findByUserIdAndRoomIds.called).to.be.false; + }); + + describe('what each row says', () => { + it('counts only the people who are in the call', async () => { + running = [ + buildGroupCall( + [ + buildMember({ _id: 'present' }), + buildMember({ _id: 'gone', leftAt: new Date() }), + buildMember({ _id: me, joined: false, joinedAt: undefined }), + ], + { rid: 'channel' }, + ), + ]; + subscribedRids = ['channel']; + + const [call] = await service.listJoinableCalls(me); + + expect(call.usersCount).to.equal(1); + }); + + // Joining another call means leaving this one, so the caller has to be able to tell which one that is. + it('says whether the user is in it', async () => { + running = [ + buildGroupCall([buildMember({ _id: me })], { rid: 'channel', _id: 'in-it' }), + buildGroupCall([buildMember({ _id: 'other' }), buildMember({ _id: me, joined: false, joinedAt: undefined })], { + rid: 'channel', + _id: 'not-in-it', + }), + ]; + subscribedRids = ['channel']; + + const calls = await service.listJoinableCalls(me); + + expect(calls.find(({ callId }: { callId: string }) => callId === 'in-it')?.joined).to.be.true; + expect(calls.find(({ callId }: { callId: string }) => callId === 'not-in-it')?.joined).to.be.false; + }); + + // The sidebar hides these and the call history keeps them, so the row has to carry the fact either way. + it('says whether the user declined it', async () => { + const decliner: IVideoConferenceUser = buildMember({ _id: me, joined: false, joinedAt: undefined, declined: true }); + running = [buildGroupCall([buildMember({ _id: 'other' }), decliner], { rid: 'channel' })]; + subscribedRids = ['channel']; + + const [call] = await service.listJoinableCalls(me); + + expect(call.declined).to.be.true; + }); + + // Faces, not just a number: the row shows who is already in there, and the count is what a "+3" comes from. + // The list shows faces rather than a number, so a few of the people travel with the call — capped here, + // because a call in a busy channel would otherwise send a whole roster to draw three avatars. + it('carries a few of the people in it, and says how many there are altogether', async () => { + running = [ + buildGroupCall( + [buildMember({ _id: 'one' }), buildMember({ _id: 'two' }), buildMember({ _id: 'three' }), buildMember({ _id: 'four' })], + { + rid: 'channel', + }, + ), + ]; + subscribedRids = ['channel']; + + const [call] = await service.listJoinableCalls(me); + + expect(call.usersCount).to.equal(4); + expect(call.participants).to.have.length(CALL_FACES_SHOWN); + expect(call.participants.map(({ _id }: { _id: string }) => _id)).to.deep.equal(['one', 'two']); + // Enough to draw a face with, and nothing else — a payload is not a place to publish a roster. + expect(Object.keys(call.participants[0]).sort()).to.deep.equal(['_id', 'name', 'username']); + }); + + // Faces are of the people who are *in* the call, not of everyone invited to it. + it('carries nobody who is not in the call', async () => { + running = [ + buildGroupCall([buildMember({ _id: 'here' }), buildMember({ _id: 'invited', joined: false, joinedAt: undefined })], { + rid: 'channel', + }), + ]; + subscribedRids = ['channel']; + + const [call] = await service.listJoinableCalls(me); + + expect(call.participants.map(({ _id }: { _id: string }) => _id)).to.deep.equal(['here']); + }); + + it('counts nobody who is not in the call', async () => { + running = [buildGroupCall([buildMember({ _id: 'present' }), buildMember({ _id: 'gone', leftAt: new Date() })], { rid: 'channel' })]; + subscribedRids = ['channel']; + + const [call] = await service.listJoinableCalls(me); + + expect(call.usersCount).to.equal(1); + }); + + it('names a group conference by its title', async () => { + running = [buildGroupCall([buildMember({ _id: 'other' })], { rid: 'channel', title: 'Sprint planning' })]; + subscribedRids = ['channel']; + + const [call] = await service.listJoinableCalls(me); + + expect(call.name).to.equal('Sprint planning'); + }); + + // A direct message has no name of its own — it is named after the other person, and that name lives on + // each side's own subscription. Falling back to the room would show a raw id. + it("names a direct call from the reader's own subscription", async () => { + running = [buildDirectCall([buildMember({ _id: 'other' })], { rid: 'the-dm' })]; + subscribedRids = ['the-dm']; + subscriptionNames = { 'the-dm': 'Alice Liddell' }; + + const [call] = await service.listJoinableCalls(me); + + expect(call.name).to.equal('Alice Liddell'); + }); + + // A member added from outside the room has no subscription to name it from, and the room cannot help: a DM + // room carries neither `name` nor `fname`, so this used to end at `getRoomName`'s last resort and show the + // reader a raw room id. Whoever started the call is who they want named. + it('names a direct call after whoever started it when there is no subscription', async () => { + running = [ + buildDirectCall([buildMember({ _id: 'other' }), buildMember({ _id: me, joined: false, joinedAt: undefined })], { rid: 'the-dm' }), + ]; + + const [call] = await service.listJoinableCalls(me); + + // `buildDirectCall` names the creator "Creator User" — the point is that it is a person, not the room. + expect(call.name).to.equal('Creator User'); + expect(RoomsMock.findOneById.called, 'no room lookup is needed to name a call after a person').to.be.false; + }); + + // The group case still ends at the room, which is the right answer for a call named after one. + it('falls back to the room for a group conference with no title', async () => { + running = [buildGroupCall([buildMember({ _id: 'other' })], { rid: 'channel', title: undefined as unknown as string })]; + subscribedRids = ['channel']; + + const [call] = await service.listJoinableCalls(me); + + expect(call.name).to.equal('name-of-channel'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/renameCall.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/renameCall.spec.ts new file mode 100644 index 0000000000000..47d172637d37a --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/renameCall.spec.ts @@ -0,0 +1,76 @@ +import type { VideoConference } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import sinon from 'sinon'; + +import { buildDirectCall, buildGroupCall, buildMember, createService, resetAll } from './testHarness'; + +let call: VideoConference | null = null; + +const VideoConferenceModelMock = { + findOneById: sinon.stub().callsFake(async () => call), + setTitleById: sinon.stub().resolves(), +}; + +const broadcast = sinon.stub().resolves(); + +const VideoConfService = createService({ broadcast, models: { VideoConference: VideoConferenceModelMock } }); + +describe('VideoConfService.renameCall', () => { + let service: any; + + beforeEach(() => { + service = new VideoConfService(); + resetAll(VideoConferenceModelMock.findOneById, VideoConferenceModelMock.setTitleById, broadcast); + call = buildGroupCall([buildMember({ _id: 'creator' })]); + }); + + it('names the conference', async () => { + await service.renameCall('creator', 'call1', 'Release planning'); + + expect(VideoConferenceModelMock.setTitleById.calledWith('call1', 'Release planning')).to.be.true; + }); + + // The name reaches the room's own call UI, which reads the conference rather than being handed the change. + it('tells the room the conference changed', async () => { + await service.renameCall('creator', 'call1', 'Release planning'); + + expect(broadcast.calledWith('room.video-conference', { rid: 'room1', callId: 'call1' })).to.be.true; + }); + + it('keeps only what was typed, without the whitespace around it', async () => { + await service.renameCall('creator', 'call1', ' Release planning '); + + expect(VideoConferenceModelMock.setTitleById.firstCall.args[1]).to.equal('Release planning'); + }); + + it('refuses a name that is nothing but whitespace', async () => { + await expect(service.renameCall('creator', 'call1', ' ')).to.be.rejectedWith('error-invalid-name'); + expect(VideoConferenceModelMock.setTitleById.called).to.be.false; + }); + + // A title everyone in the call could rewrite is a title nobody can rely on. + it('refuses anyone but the person who started the call', async () => { + await expect(service.renameCall('someone-else', 'call1', 'Release planning')).to.be.rejectedWith('error-not-allowed'); + expect(VideoConferenceModelMock.setTitleById.called).to.be.false; + }); + + // A direct call is named after the other person, per viewer — there is no one title to set. + it('refuses a direct call', async () => { + call = buildDirectCall([buildMember({ _id: 'creator' })]); + + await expect(service.renameCall('creator', 'call1', 'Release planning')).to.be.rejectedWith('error-invalid-video-conf'); + }); + + it('refuses a call that has already ended', async () => { + call = buildGroupCall([buildMember({ _id: 'creator' })], { endedAt: new Date('2026-01-01T01:00:00.000Z') }); + + await expect(service.renameCall('creator', 'call1', 'Release planning')).to.be.rejectedWith('error-invalid-video-conf'); + }); + + it('refuses a call that does not exist', async () => { + call = null; + + await expect(service.renameCall('creator', 'call1', 'Release planning')).to.be.rejectedWith('error-invalid-video-conf'); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts new file mode 100644 index 0000000000000..a89fe7ec080f1 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts @@ -0,0 +1,370 @@ +import type { IDirectVideoConference, IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; +import { isInVideoConference, VIDEO_CONF_RINGING_WINDOW_MS } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import sinon from 'sinon'; + +import { + buildDirectCall, + buildGroupCall, + buildMember, + cloneFixture, + createService, + providerCapabilities, + resetAll, + ringedUserIds, +} from './testHarness'; + +/** + * Who gets rung, and when. + * + * One suite for the three ways it happens — asking again from the members panel, adding people to a call, and a + * direct call's callee being rung when its caller finally walks in — because they all answer the same question and + * all answer it through the same broadcast. They were three files that each loaded the whole service and each + * rebuilt the same broadcast filter to read the answer out of. + */ + +// The single canonical record; `findOneById` hands out a clone of it, whatever projection is asked for. +let fixture: VideoConference; + +const VideoConferenceModelMock = { + findOneById: sinon.stub().callsFake(async () => cloneFixture(fixture)), + setUsersRingingById: sinon.stub().callsFake(async (_callId: string, uids: string[], ringingAt: Date) => { + fixture.users.forEach((user) => { + if (uids.includes(user._id)) { + (user as IVideoConferenceUser).ringingAt = ringingAt; + } + }); + }), + // Mirrors the model: an entry is pushed as *not* present, whatever the caller passed. Getting this wrong + // would leave fixture members with no `joined` flag at all, which every reader treats as joined. + addMemberById: sinon.stub().callsFake(async (_callId: string, member: IVideoConferenceUser) => { + fixture.users.push({ ...member, joined: false }); + }), + setUserJoinedById: sinon.stub().resolves(), + setStatusById: sinon.stub().resolves(), + find: sinon.stub().returns({ toArray: async () => [] }), +}; + +// `notifyUsersAddedToConference` reads the adder and the rung members straight off `Users` and broadcasts a +// desktop notification for each — it must not throw for that to happen, so both calls need to resolve +// something shaped like a real user. +const UsersMock = { + findOneById: sinon.stub().resolves({ _id: 'caller', username: 'caller.user', name: 'Caller User' }), + find: sinon.stub().returns({ toArray: sinon.stub().resolves([]) }), +}; + +const broadcastStub = sinon.stub().resolves(); + +// Deliberately NOT overriding '../../../lib/videoConference/constants' — an add rings through +// `shouldRingRecipients`, so stubbing it would quietly suppress the ring the add tests below assert. Where the +// limit itself falls is pinned on the guard in tests/unit/lib/videoConference/memberStatus.spec.ts. +const VideoConfService = createService({ + broadcast: broadcastStub, + models: { VideoConference: VideoConferenceModelMock, Users: UsersMock }, +}); + +/** + * `notifyUsersAddedToConference` broadcasts one `notify.desktop` per added member, via + * `api.broadcast('notify.desktop', memberId, notification)` — a 3-arg call, unlike the 2-arg ring broadcast. + * `audioNotificationValue`/room identity live under the notification's own nested `payload` property. + */ +const desktopNotifications = (): { memberId: string; payload: Record }[] => + broadcastStub.args + .filter(([channel]) => channel === 'notify.desktop') + .map(([, memberId, notification]) => ({ + memberId: memberId as string, + payload: (notification as { payload: Record }).payload, + })); + +const buildUser = (id: string) => ({ _id: id, username: `${id}.user`, name: id, avatarETag: null }); + +let service: any; + +beforeEach(() => { + service = new VideoConfService(); + resetAll( + VideoConferenceModelMock.findOneById, + VideoConferenceModelMock.setUsersRingingById, + VideoConferenceModelMock.setUserJoinedById, + VideoConferenceModelMock.addMemberById, + UsersMock.findOneById, + UsersMock.find, + broadcastStub, + ); + VideoConferenceModelMock.findOneById.callsFake(async () => cloneFixture(fixture)); + VideoConferenceModelMock.addMemberById.callsFake(async (_callId: string, member: IVideoConferenceUser) => { + fixture.users.push({ ...member, joined: false }); + }); + UsersMock.findOneById.resolves({ _id: 'caller', username: 'caller.user', name: 'Caller User' }); + UsersMock.find.returns({ toArray: sinon.stub().resolves([]) }); + broadcastStub.resolves(); +}); + +describe('VideoConfService.ringMember', () => { + // The base case: a member added to the call but who never answered has no active presence, so a second + // ring is the only way to reach them. + it('rings a member who was never in the call', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'neverJoined', joined: false, joinedAt: undefined })]); + + const result = await service.ringMember('caller', 'call1', 'neverJoined'); + + expect(result).to.be.true; + expect(ringedUserIds(broadcastStub)).to.deep.equal(['neverJoined']); + }); + + // "Call them back" is exactly this shape: they were on the call and aren't anymore. `isInVideoConference` + // says `joined: true` with a `leftAt` is not currently present, so they must be rung the same as anyone + // who never picked up. + it('rings a member who joined the call and then left it', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'caller' }), + buildMember({ _id: 'wentQuiet', joined: true, leftAt: new Date('2026-01-01T00:15:00.000Z') }), + ]); + + const result = await service.ringMember('caller', 'call1', 'wentQuiet'); + + expect(result).to.be.true; + expect(ringedUserIds(broadcastStub)).to.deep.equal(['wentQuiet']); + }); + + // Someone already on the call has no reason to be interrupted by a ring meant for people who aren't there. + it('does not ring a member who is currently in the call', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'stillHere', joined: true })]); + + const result = await service.ringMember('caller', 'call1', 'stillHere'); + + expect(result).to.be.false; + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); + + // The caller is the one asking for the retry, not a target of it — this has to hold even for a caller + // entry that would otherwise read as absent (e.g. written with `joined: false`). + it('never rings the caller themselves, even if their own entry looks absent', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller', joined: false, joinedAt: undefined })]); + + const result = await service.ringMember('caller', 'call1', 'caller'); + + expect(result).to.be.false; + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); + + // A phone that is ringing right now has nothing more to ask of it — re-ringing would just restart the + // sound under the callee's finger. The window is what separates "ringing" from "was rung and ignored". + it('does not ring a member whose phone is still ringing from the last attempt', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'caller' }), + buildMember({ _id: 'stillRinging', joined: false, joinedAt: undefined, ringingAt: new Date() }), + ]); + + const result = await service.ringMember('caller', 'call1', 'stillRinging'); + + expect(result).to.be.false; + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); + + // Once the window has passed, an unanswered ring is exactly what "ring again" exists for. + it('rings a member whose previous ring already went unanswered', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'caller' }), + buildMember({ + _id: 'ignoredIt', + joined: false, + joinedAt: undefined, + ringingAt: new Date(Date.now() - VIDEO_CONF_RINGING_WINDOW_MS - 1_000), + }), + ]); + + const result = await service.ringMember('caller', 'call1', 'ignoredIt'); + + expect(result).to.be.true; + expect(ringedUserIds(broadcastStub)).to.deep.equal(['ignoredIt']); + }); + + // Membership is what a ring targets, so someone who was never added to this call isn't ringable through + // it — the endpoint would otherwise be a way to make any user's phone ring. + it('rings nobody for a user who is not a member of the call', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' })]); + + const result = await service.ringMember('caller', 'call1', 'stranger'); + + expect(result).to.be.false; + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); + + // A conference that already ended is not something you can still ring people into — `ringMember` must + // bail out before even looking at the member. + it('rings nobody for a conference that has already ended', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'absent', joined: false, joinedAt: undefined })], { + endedAt: new Date('2026-01-01T01:00:00.000Z'), + }); + + const result = await service.ringMember('caller', 'call1', 'absent'); + + expect(result).to.be.false; + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); +}); + +describe('VideoConfService.addMembers', () => { + // Added, not arrived: adding somebody to a call is not answering it for them, so nothing here may mark them + // as being in it. + it('registers each named user as a member without marking them present, and returns the ids added', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' })]); + const newUsers = [buildUser('newUser1'), buildUser('newUser2')]; + UsersMock.find.returns({ toArray: sinon.stub().resolves(newUsers) }); + + const result = await service.addMembers('caller', 'call1', ['newUser1.user', 'newUser2.user']); + + expect(result.sort()).to.deep.equal(['newUser1', 'newUser2']); + expect(VideoConferenceModelMock.addMemberById.callCount).to.equal(2); + VideoConferenceModelMock.addMemberById.args.forEach(([callId]) => expect(callId).to.equal('call1')); + expect(VideoConferenceModelMock.addMemberById.args.map(([, member]) => member._id).sort()).to.deep.equal(['newUser1', 'newUser2']); + + const added = fixture.users.filter(({ _id }) => ['newUser1', 'newUser2'].includes(_id)); + expect(added).to.have.length(2); + added.forEach((member) => expect(isInVideoConference(member)).to.be.false); + }); + + // Overwriting an existing entry would wipe out whatever `joinedAt`/`declined` state the member already + // has — this is the guard that stops an add from clobbering a member who is already present. + it('skips a user who already has a users[] entry, leaving their existing state untouched', async () => { + const existingJoinedAt = new Date('2026-01-01T00:10:00.000Z'); + fixture = buildGroupCall([ + buildMember({ _id: 'caller' }), + buildMember({ _id: 'already', joined: true, joinedAt: existingJoinedAt, declined: true }), + ]); + UsersMock.find.returns({ toArray: sinon.stub().resolves([buildUser('already')]) }); + + const result = await service.addMembers('caller', 'call1', ['already.user']); + + expect(result).to.deep.equal([]); + expect(VideoConferenceModelMock.addMemberById.called).to.be.false; + + const existing = fixture.users.filter((user) => user._id === 'already'); + expect(existing).to.have.length(1); + expect(existing[0].joinedAt).to.equal(existingJoinedAt); + expect(existing[0].declined).to.be.true; + }); + + it('rings everyone actually added', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' })]); + const newUsers = [buildUser('newUser1'), buildUser('newUser2')]; + UsersMock.find.returns({ toArray: sinon.stub().resolves(newUsers) }); + + const result = await service.addMembers('caller', 'call1', ['newUser1.user', 'newUser2.user']); + + expect(result.sort()).to.deep.equal(['newUser1', 'newUser2']); + expect(ringedUserIds(broadcastStub).sort()).to.deep.equal(['newUser1', 'newUser2']); + }); + + // A finished call is not something to add people to — and certainly not something to ring them into. + it('adds nobody and rings nobody on a conference that has already ended', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' })], { endedAt: new Date('2026-01-01T01:00:00.000Z') }); + UsersMock.find.returns({ toArray: sinon.stub().resolves([buildUser('newUser1')]) }); + + const result = await service.addMembers('caller', 'call1', ['newUser1.user']); + + expect(result).to.deep.equal([]); + expect(VideoConferenceModelMock.addMemberById.called).to.be.false; + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); + + // Nobody was actually added (every requested user was already a member) — there is nobody new to ring. + it('rings nobody when nobody was added', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'already' })]); + UsersMock.find.returns({ toArray: sinon.stub().resolves([buildUser('already')]) }); + + const result = await service.addMembers('caller', 'call1', ['already.user']); + + expect(result).to.deep.equal([]); + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); + + // A call must not announce itself with the new-message sound (`audioNotificationValue: 'none'`), and its + // click must not try to open a room the member may not be able to see (no `payload.name`). + it('sends a silent desktop notification with no room name for each added member', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' })]); + const newUsers = [buildUser('newUser1')]; + UsersMock.find.returns({ toArray: sinon.stub().resolves(newUsers) }); + UsersMock.findOneById.resolves({ _id: 'caller', username: 'caller.user', name: 'Caller User' }); + + await service.addMembers('caller', 'call1', ['newUser1.user']); + + const notifications = desktopNotifications(); + expect(notifications).to.have.length(1); + expect(notifications[0].memberId).to.equal('newUser1'); + expect(notifications[0].payload).to.include({ audioNotificationValue: 'none' }); + expect(notifications[0].payload).to.not.have.property('name'); + }); +}); + +// Creating the call is not asking anyone to answer it: the caller lands on the preflight first, and being rung +// into a call whose caller is still choosing a camera means answering to an empty room. +describe('VideoConfService: ringing a direct call when its caller arrives', () => { + const directCall = (callee: Partial = {}): IDirectVideoConference => + buildDirectCall([ + buildMember({ _id: 'creator', joined: false, joinedAt: undefined }), + buildMember({ _id: 'callee', joined: false, joinedAt: undefined, ...callee }), + ]); + + beforeEach(() => { + fixture = directCall(); + UsersMock.findOneById.callsFake(async (uid: string) => ({ _id: uid, username: uid, name: uid, avatarETag: null })); + // Ring-on-arrival only exists for embedded providers, whose caller sits on a preflight screen first. + providerCapabilities.current = { embedded: true }; + }); + + afterEach(() => { + providerCapabilities.current = undefined; + }); + + it('rings the callee when the caller joins', async () => { + await service.addUser('call1', 'creator'); + + expect(VideoConferenceModelMock.setUsersRingingById.calledWith('call1', ['callee'])).to.be.true; + }); + + it('rings nobody when the callee is the one arriving', async () => { + await service.addUser('call1', 'callee'); + + expect(VideoConferenceModelMock.setUsersRingingById.called).to.be.false; + }); + + // Rejoining must not ring anyone again — the call window's own "ring again" is how a second attempt is asked + // for. + it('does not ring someone who has already been rung', async () => { + fixture = directCall({ ringingAt: new Date('2026-01-01T00:00:00.000Z') }); + + await service.addUser('call1', 'creator'); + + expect(VideoConferenceModelMock.setUsersRingingById.called).to.be.false; + }); + + it('does not ring someone who already declined', async () => { + fixture = directCall({ declined: true }); + + await service.addUser('call1', 'creator'); + + expect(VideoConferenceModelMock.setUsersRingingById.called).to.be.false; + }); + + it('does not ring someone who is already in the call', async () => { + fixture = directCall({ joined: true, joinedAt: new Date('2026-01-01T00:00:00.000Z') }); + + await service.addUser('call1', 'creator'); + + expect(VideoConferenceModelMock.setUsersRingingById.called).to.be.false; + }); + + // A non-embedded direct call has no preflight — it already rang the callee when it was created, so the + // caller arriving in it must not ring anyone a second time. + it('rings nobody at all for a non-embedded provider', async () => { + providerCapabilities.current = undefined; + + await service.addUser('call1', 'creator'); + + expect(VideoConferenceModelMock.setUsersRingingById.called).to.be.false; + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/testHarness.ts b/apps/meteor/tests/unit/server/services/video-conference/testHarness.ts new file mode 100644 index 0000000000000..c8a833d309639 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/testHarness.ts @@ -0,0 +1,214 @@ +import type { + IGroupVideoConference, + IDirectVideoConference, + IVideoConferenceUser, + VideoConference, + VideoConferenceCapabilities, +} from '@rocket.chat/core-typings'; +import { VideoConferenceStatus } from '@rocket.chat/core-typings'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +/** + * What `videoConfProviders.getProviderCapabilities` answers, for every service loaded through this harness. + * + * The service gates most of its join-side lifecycle on the provider being embedded, so a spec has to be able to + * play both kinds. The stub reads this holder at call time: a spec flips `current` in a `beforeEach` (and back + * in an `afterEach`, since the loaded module is shared across files) rather than re-loading the service. + * `undefined` — no capabilities — is the default, and is what a non-embedded provider looks like. + */ +export const providerCapabilities: { current: VideoConferenceCapabilities | undefined } = { current: undefined }; + +// The stubs below never vary between specs in this directory — they satisfy imports the service file needs +// at load time but that no test here actually exercises. Kept in one place so a new spec doesn't have to +// re-list all ~25 of them just to get the module to load; only the modules a spec actually cares about +// (`@rocket.chat/models`, `@rocket.chat/core-services`, and anything else under test) are assembled by the +// spec itself. +export const commonServiceStubs = { + '@rocket.chat/apps': { Apps: {} }, + // Every level, not just `error`: a missing one throws where the service only meant to say something, and the + // service catches around its logging — so the failure surfaces as the work silently not happening. + '@rocket.chat/logger': { + Logger: class { + error() { + /* no-op */ + } + + warn() { + /* no-op */ + } + + info() { + /* no-op */ + } + + debug() { + /* no-op */ + } + }, + }, + '@rocket.chat/random': { Random: { id: () => 'randomId' } }, + '@rocket.chat/tools': { wrapExceptions: (fn: () => unknown) => fn() }, + 'meteor/meteor': { Meteor: { startup: () => undefined } }, + 'meteor/mongo': { MongoInternals: { defaultRemoteCollectionDriver: () => ({ mongo: { db: {} } }) } }, + '../../../definition/IRoomTypeConfig': { RoomMemberActions: {} }, + '../../../lib/videoConference/chatAccess': { resolveChatAccessMode: () => undefined }, + '../../database/readSecondaryPreferred': { readSecondaryPreferred: () => undefined }, + '../../lib/authorization/canAccessRoom': { canAccessRoomIdAsync: async () => true }, + '../../lib/authorization/hasPermission': { hasAtLeastOnePermissionAsync: async () => true }, + '../../lib/callbacks': { callbacks: { runAsync: () => undefined, run: () => undefined } }, + '../../lib/i18n': { i18n: { t: (s: string) => s } }, + '../../lib/isRoomCompatibleWithVideoConfRinging': { isRoomCompatibleWithVideoConfRinging: () => true }, + '../../lib/media/assets': { RocketChatAssets: { getURL: () => '' } }, + '../../lib/messages/sendMessage': { sendMessage: async () => ({ _id: 'msg1' }) }, + // Loaded for real this would drag in `server/settings` (top-level await), which tsx-in-CJS cannot transform. + '../../lib/messaging/threads/functions': { follow: async () => undefined }, + '../../lib/metrics/lib/metrics': { + metrics: { notificationsSent: { inc: () => undefined }, notificationsSentTotal: { inc: () => undefined } }, + }, + '../../lib/notifications/push/push': { Push: { send: async () => undefined } }, + '../../lib/notifications/push-config/lib/PushNotification': { default: { getNotificationId: () => 'notif' } }, + '../../lib/notifyListener': { notifyOnMessageChange: async () => undefined }, + '../../lib/rooms/createRoom': { createRoom: async () => ({ _id: 'room1' }) }, + '../../lib/rooms/roomCoordinator': { + roomCoordinator: { getRoomDirectives: () => ({ allowMemberAction: async () => true, getDiscussionType: () => 'p' }) }, + }, + '../../lib/statistics/functions/updateStatsCounter': { updateCounter: () => undefined }, + '../../lib/utils/getUserAvatarURL': { getUserAvatarURL: () => '' }, + '../../lib/utils/lib/getUserPreference': { getUserPreference: async () => false }, + '../../lib/videoConfProviders': { + videoConfProviders: { + hasAnyProvider: () => false, + getActiveProvider: () => undefined, + isProviderAvailable: () => false, + getProviderCapabilities: () => providerCapabilities.current, + getProviderAppId: () => undefined, + getProviderList: () => [], + }, + }, + '../../lib/videoConfTypes': { videoConfTypes: { isCallManagedByApp: () => false, getTypeForRoom: () => ({}) } }, + '../../meteor-methods/rooms/addUsersToRoom': { addUsersToRoomMethod: async () => undefined }, + '../../settings': { settings: { get: () => undefined } }, +}; + +/** + * Loads the service with the stubs a spec actually cares about, and the inert ones filled in. + * + * Every spec here needs the same three things beyond `commonServiceStubs`: a `broadcast` it can read, the + * do-nothing `ServiceClassInternal`/`Message`/`Room` the service calls into, and a `@rocket.chat/models` map with + * only the collections that spec exercises. Nine copies of that arrangement is nine places to update when the + * service reaches for one more module. + * + * @param broadcast the stub to observe `api.broadcast` through. + * @param models the `@rocket.chat/models` collections this spec stubs; the rest default to inert. + * @param overrides any other module to replace — where a spec is testing something `commonServiceStubs` fakes. + */ +export const createService = ({ + broadcast = sinon.stub().resolves(), + models = {}, + overrides = {}, +}: { + broadcast?: sinon.SinonStub; + models?: Record; + overrides?: Record; +} = {}) => { + const { VideoConfService } = proxyquire.noCallThru().load('../../../../../server/services/video-conference/service', { + ...commonServiceStubs, + '@rocket.chat/core-services': { + api: { broadcast }, + ServiceClassInternal: class { + onEvent() { + /* no-op */ + } + }, + Message: { saveSystemMessage: sinon.stub().resolves() }, + Room: { addUserToRoom: sinon.stub().resolves() }, + }, + '@rocket.chat/models': { + Users: { findOneById: sinon.stub().resolves(null) }, + Rooms: { findOneById: sinon.stub().resolves(null) }, + Messages: { setBlocksById: sinon.stub().resolves() }, + Subscriptions: { + findByRoomIdAndNotUserId: sinon.stub().returns({ toArray: sinon.stub().resolves([]), forEach: sinon.stub().resolves() }), + }, + ...models, + }, + ...overrides, + }); + + return VideoConfService as new () => any; +}; + +/** + * Bare `sinon.stub()`s live outside sinon's default sandbox, so `sinon.resetHistory()` is a no-op for them — + * each has to be reset by hand or a test would silently read the previous test's calls. + */ +export const resetAll = (...stubs: sinon.SinonStub[]): void => stubs.forEach((stub) => stub.resetHistory()); + +/** + * A read of the fixture, as the database would answer it: a *copy*. + * + * `findOneById` is hit more than once per flow, with different projections, and the mutating model stubs write + * into the canonical record. Handing out the live object would let a later write retroactively change what an + * earlier read shows — which is exactly the staleness some of these flows depend on. + */ +export const cloneFixture = (call: VideoConference): VideoConference => ({ + ...call, + users: call.users.map((user) => ({ ...user })), + messages: { ...call.messages }, +}); + +/** + * Who actually got rung: the `ring` notifications `notifyUser` sends via + * `api.broadcast('user.video-conference', { userId, action, params })`, which is the only observable trace. + */ +export const ringedUserIds = (broadcast: sinon.SinonStub): string[] => + broadcast.args + .filter(([channel, payload]) => channel === 'user.video-conference' && (payload as { action: string }).action === 'ring') + .map(([, payload]) => (payload as { userId: string }).userId); + +export const createdBy = { _id: 'creator', username: 'creator.user', name: 'Creator User' }; + +// A member's shape as it lives in `users[]`: someone who joined and is still in the call, unless overridden. +export const buildMember = (overrides: Partial & Pick): IVideoConferenceUser => ({ + username: `${overrides._id}.user`, + name: overrides._id, + avatarETag: null, + ts: new Date('2026-01-01T00:00:00.000Z'), + joined: true, + joinedAt: new Date('2026-01-01T00:00:00.000Z'), + ...overrides, +}); + +export const buildGroupCall = (users: IVideoConferenceUser[], overrides: Partial = {}): IGroupVideoConference => ({ + _id: 'call1', + type: 'videoconference', + rid: 'room1', + status: VideoConferenceStatus.STARTED, + title: 'Sprint planning', + anonymousUsers: 0, + providerName: 'test', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + _updatedAt: new Date('2026-01-01T00:00:00.000Z'), + createdBy, + messages: {}, + users, + ...overrides, +}); + +export const buildDirectCall = ( + users: IVideoConferenceUser[], + overrides: Partial = {}, +): IDirectVideoConference => ({ + _id: 'call1', + type: 'direct', + rid: 'room1', + status: VideoConferenceStatus.STARTED, + providerName: 'test', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + _updatedAt: new Date('2026-01-01T00:00:00.000Z'), + createdBy, + messages: {}, + users, + ...overrides, +}); diff --git a/packages/core-services/src/events/Events.ts b/packages/core-services/src/events/Events.ts index 7a1c60b52dbd5..79fe4ba72f44c 100644 --- a/packages/core-services/src/events/Events.ts +++ b/packages/core-services/src/events/Events.ts @@ -162,6 +162,14 @@ export type EventSignatures = { user: Pick; previousStatus: UserStatus | undefined; }): void; + /** + * Something about the conference changed: its chat moved to another room, who can read that chat changed, or + * its membership moved — someone joined, declined, left, or was added. + * + * One event for all of it because there is one answer to all of it: read the conference again. It carries the + * room, who can see it, and who is in it, so nothing a subscriber needs is worth a payload of its own. + */ + 'video-conference.updated'(data: { callId: VideoConference['_id'] }): void; 'watch.messages'(data: { message: IMessage }): void; 'watch.roles'( data: diff --git a/packages/core-services/src/types/IVideoConfService.ts b/packages/core-services/src/types/IVideoConfService.ts index 6d74413ccf211..3c7867630f868 100644 --- a/packages/core-services/src/types/IVideoConfService.ts +++ b/packages/core-services/src/types/IVideoConfService.ts @@ -4,7 +4,10 @@ import type { IUser, IVoIPVideoConference, VideoConference, + JoinableVideoConference, VideoConferenceCapabilities, + VideoConferenceChatAccess, + VideoConferenceChatAccessMode, VideoConferenceCreateData, VideoConferenceInstructions, } from '@rocket.chat/core-typings'; @@ -43,5 +46,24 @@ export interface IVideoConfService { params: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] }, ): Promise; assignDiscussionToConference(callId: VideoConference['_id'], rid: IRoom['_id'] | undefined): Promise; + addMembers( + uid: IUser['_id'], + callId: VideoConference['_id'], + usernames: NonNullable[], + options?: { ring?: boolean }, + ): Promise; + declineCall(uid: IUser['_id'], callId: VideoConference['_id']): Promise; + leaveCall(uid: IUser['_id'], callId: VideoConference['_id']): Promise; + /** Renews the caller's presence lease on a call, which is what stops them being treated as gone. */ + renewPresence(uid: IUser['_id'], callId: VideoConference['_id']): Promise; + /** Marks everyone whose presence lease has run out as having left, and ends the calls that empties. */ + expirePresenceLeases(now?: Date): Promise; + /** Rings one member who isn't in the call, again; says whether the ring went out. */ + ringMember(uid: IUser['_id'], callId: VideoConference['_id'], memberId: IUser['_id']): Promise; + listJoinableCalls(uid: IUser['_id']): Promise; + getChatAccess(uid: IUser['_id'], callId: VideoConference['_id']): Promise; + shareChatWithMembers(uid: IUser['_id'], callId: VideoConference['_id'], mode: VideoConferenceChatAccessMode): Promise; + + renameCall(uid: IUser['_id'], callId: VideoConference['_id'], title: string): Promise; createVoIP(data: InsertionModel): Promise; } diff --git a/packages/core-typings/src/INotification.ts b/packages/core-typings/src/INotification.ts index c9da1a8ae8090..7bed19449d7b8 100644 --- a/packages/core-typings/src/INotification.ts +++ b/packages/core-typings/src/INotification.ts @@ -54,13 +54,25 @@ export interface INotificationDesktop { text: string; icon?: string; duration?: number; + // Force the notification to stay until the user interacts with it, regardless of the recipient's + // `desktopNotificationRequireInteraction` preference. + requireInteraction?: boolean; + // Optional action buttons rendered on the notification (desktop app only; ignored elsewhere). + actions?: { + action: string; + title: string; + }[]; payload: { _id: IMessage['_id']; rid: IMessage['rid']; tmid?: IMessage['tmid']; sender: IMessage['u']; - type: IRoom['t']; - name: IRoom['name']; + // Omitted by notifications that aren't about a room the recipient can open — a conference ring, for + // one. Without a name the click doesn't navigate anywhere, which is the point. + type?: IRoom['t']; + name?: IRoom['name']; + // When set, the notification can offer a "Join" action that opens this conference directly. + conferenceId?: string; message: { msg: IMessage['msg']; t?: IMessage['t']; diff --git a/packages/core-typings/src/IVideoConference.ts b/packages/core-typings/src/IVideoConference.ts index 74cb7fc04ee63..30cdecbf01502 100644 --- a/packages/core-typings/src/IVideoConference.ts +++ b/packages/core-typings/src/IVideoConference.ts @@ -52,11 +52,106 @@ export type LivechatInstructions = { export type VideoConferenceType = DirectCallInstructions['type'] | ConferenceInstructions['type'] | LivechatInstructions['type'] | 'voip'; +/** + * Someone associated with a conference — a **member**, which is not the same as someone currently in the + * call. Membership is what authorizes joining (alongside access to the conference's room), and it never + * expires. + * + * `joined` is optional because every entry written before it existed represents someone who had joined, so + * readers must treat an absent flag as joined. Use the `hasJoinedVideoConference` helper rather than + * testing the field directly. + */ +/** + * How a departure came to be recorded. `reported` is the member's own client saying so; `timeout` is their + * presence lease running out, which is what covers everything that can stop a client from reporting. + */ +export type VideoConferenceLeaveReason = 'reported' | 'timeout'; + export interface IVideoConferenceUser extends Pick, '_id' | 'username' | 'name'> { avatarETag: string | null; + /** When the user became a member of the conference. */ ts: Date; + joined?: boolean; + joinedAt?: Date; + /** + * Set when the member dismissed the call rather than joining. It records what happened; it never ends the + * call for anyone else. A member can decline and still join later, so this is not exclusive with `joined`. + */ + declined?: boolean; + declinedAt?: Date; + /** When they left the call. Cleared if they rejoin, so it only ever describes the latest departure. */ + leftAt?: Date; + /** + * How we learned they left. Absent means they told us — which is also how every entry written before this + * existed should be read, since reporting was the only way a departure was recorded then. + */ + leftReason?: VideoConferenceLeaveReason; + /** + * When we last had evidence this member was still in the call: their own call window saying so, or the + * provider confirming it. + * + * Presence is a lease rather than a report because the report can be lost — the workspace can be down while + * the call carries on in the provider, and a crashed tab, a dead battery or a closed laptop never report at + * all. What survives all of those is *the absence of renewals*, which is what this records. + */ + lastSeenAt?: Date; + /** + * When we last rang them. A ring is one-shot and short-lived, so this is what tells "their phone is ringing + * right now" from "they were rung and did nothing", which decides whether ringing again is offered. + */ + ringingAt?: Date; } +/** + * Whether a member is actually in the call. Absent `joined` means the entry predates the flag, and back then + * entries were only written on join — so absent reads as joined. + */ +export const hasJoinedVideoConference = (user: Pick): boolean => user.joined !== false; + +/** + * Whether a member is in the call *right now*, as opposed to having joined it at some point. `joined` never goes + * back to false — it records that they were there — so presence is the pair of it and not having left since. + */ +export const isInVideoConference = (user: Pick): boolean => + hasJoinedVideoConference(user) && !user.leftAt; + +/** + * How long a ring is assumed to still be ringing for. A server-originated ring is one-shot: the callee's client + * gives it 10s before it aborts, so a few seconds beyond that covers the round trip without leaving the caller + * waiting on a phone that has stopped. + */ +export const VIDEO_CONF_RINGING_WINDOW_MS = 15_000; + +/** + * How many people one ring may reach. It bounds the *recipients of a single action*, not anything about the + * conference: a ring is a broadcast per recipient, so a batch has to stay small enough to be worth sending. + * + * Adding participants is the only action that rings a batch, and it is capped at this same number — which is + * why an add always rings rather than silently ringing part of itself. Ringing an existing member reaches one + * person and is not bounded by this at all. + * + * It lives here because both halves of that rule need it: the server deciding whether to ring, and the endpoint + * capping the batch. They were two constants that had to be kept equal by comment. + */ +export const RING_RECIPIENTS_LIMIT = 10; + +/** Whether this member's phone is ringing right now — as opposed to having been rung and done nothing. */ +export const isRingingVideoConferenceMember = ( + user: Pick, + now = Date.now(), +): boolean => { + if (!user.ringingAt) { + return false; + } + + // Answering by declining stops the ringing, even inside the window. + if (user.declined && user.declinedAt && user.declinedAt.getTime() >= user.ringingAt.getTime()) { + return false; + } + + return now - user.ringingAt.getTime() < VIDEO_CONF_RINGING_WINDOW_MS; +}; + export interface IVideoConference extends IRocketChatRecord { type: VideoConferenceType; rid: string; @@ -114,6 +209,54 @@ export interface IVoIPVideoConference extends IVideoConference { }; } +/** + * Where a conference's chat lives and who can't read it. Conference membership grants no room access, so a + * member added from outside the room takes part in the call without seeing its chat; resolving that is a + * deliberate choice with consequences, so the UI needs enough context to explain them before acting. + */ +export type VideoConferenceChatAccess = { + rid: IRoom['_id']; + /** Display name of the room the chat lives in — the history that would be exposed by inviting. */ + name: string; + type: IRoom['t']; + membersWithoutAccess: IUser['_id'][]; + /** Whether that room can take the missing members in: a DM can't, so its chat has to move instead. */ + canInvite: boolean; +}; + +/** + * A call that is running now and that the reader may join — what the sidebar and the navbar list so a call can be + * reached without having caught its ring. + * + * Deliberately not the conference record: a list needs enough to decide whether to walk in, and the room it + * belongs to is not part of that decision. Joining goes by `callId`. + */ +export type JoinableVideoConference = { + callId: IVideoConference['_id']; + /** What to call it: the conference's own title, or the room's name. */ + name: string; + createdAt: Date; + /** How many people are in it right now. Never zero — an empty call isn't offered. */ + usersCount: number; + /** + * A few of the people in it, so a list can show faces instead of a number. Capped on the server — the count + * above is still the whole truth, and what a "+3" is worked out from. + */ + participants: Pick[]; + /** Whether the reader is one of them, which is what makes joining another call a matter of leaving this one. */ + joined: boolean; + /** Whether the reader already turned this call down. The sidebar hides those. */ + declined: boolean; + /** + * When this reader was last rung, if ever. Whether that ring is still live is decided by the reader — see + * `isRingingVideoConferenceMember` — so the list can stop presenting a call as ringing without being told. + */ + ringingAt?: Date; +}; + +/** How to give the missing members access: bring them into the room, or move the chat to a discussion. */ +export type VideoConferenceChatAccessMode = 'invite' | 'discussion'; + export type ExternalVideoConference = IDirectVideoConference | IGroupVideoConference | ILivechatVideoConference; type InternalVideoConference = IVoIPVideoConference; diff --git a/packages/core-typings/src/VideoConferenceCapabilities.ts b/packages/core-typings/src/VideoConferenceCapabilities.ts index 18070608e53f1..c785fa38675bb 100644 --- a/packages/core-typings/src/VideoConferenceCapabilities.ts +++ b/packages/core-typings/src/VideoConferenceCapabilities.ts @@ -3,4 +3,11 @@ export type VideoConferenceCapabilities = { cam?: boolean; title?: boolean; persistentChat?: boolean; + /** + * When true, the call is rendered embedded inside Rocket.Chat (via an + * SFU like LiveKit) rather than handed off to an external URL/popup. + * Consumers gate URL-generation on the inverse: URL-based providers + * (Jitsi/Meet/Zoom) leave this false/undefined. + */ + embedded?: boolean; }; diff --git a/packages/ddp-client/src/types/streams.ts b/packages/ddp-client/src/types/streams.ts index 665368b13f654..85c9578b31652 100644 --- a/packages/ddp-client/src/types/streams.ts +++ b/packages/ddp-client/src/types/streams.ts @@ -472,6 +472,9 @@ export interface StreamerEvents { { key: 'command/removed'; args: [string] }, { key: 'actions/changed'; args: [] }, ]; + + 'video-conference': [{ key: `${string}/updated`; args: [] }]; + 'local': [ { key: 'broadcast'; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2f4f36902aa68..6566bcd2d2ae5 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -7460,6 +7460,7 @@ "You_should_name_it_to_easily_manage_your_integrations": "You should name it to easily manage your integrations.", "You_unfollowed_this_message": "You unfollowed this message.", "You_users_and_more_Reacted_with": "You, {{users}} and {{counter}} more reacted with {{emoji}}", + "You_were_invited_to_a_conference": "You were invited to a conference", "You_will_be_asked_for_permissions": "You will be asked for permissions", "You_will_not_be_able_to_recover": "You will not be able to recover this message!", "You_will_not_be_able_to_recover_email_inbox": "You will not be able to recover this email inbox", diff --git a/packages/model-typings/src/models/IVideoConferenceModel.ts b/packages/model-typings/src/models/IVideoConferenceModel.ts index 66a082af85d2a..c82c3258fbf28 100644 --- a/packages/model-typings/src/models/IVideoConferenceModel.ts +++ b/packages/model-typings/src/models/IVideoConferenceModel.ts @@ -4,6 +4,7 @@ import type { IRoom, IUser, VideoConference, + VideoConferenceLeaveReason, VideoConferenceStatus, IVoIPVideoConference, } from '@rocket.chat/core-typings'; @@ -53,9 +54,34 @@ export interface IVideoConferenceModel extends IBaseModel { setUrlById(callId: string, url: string): Promise; + /** Names a group conference. Only that kind carries a title of its own. */ + setTitleById(callId: string, title: string): Promise; + setProviderDataById(callId: string, providerData: Record | undefined): Promise; - addUserById(callId: string, user: Required> & { ts?: Date }): Promise; + /** Associates a user with the call. It never marks them present — `setUserJoinedById` is what arriving does. */ + addMemberById(callId: string, user: Required> & { ts?: Date }): Promise; + + setUserJoinedById(callId: string, uid: IUser['_id'], joinedAt?: Date): Promise; + + setUserDeclinedById(callId: string, uid: IUser['_id'], declinedAt?: Date): Promise; + setUserLeftById(callId: string, uid: IUser['_id'], leftAt?: Date, reason?: VideoConferenceLeaveReason): Promise; + setUsersRingingById(callId: string, uids: IUser['_id'][], ringingAt?: Date): Promise; + + /** + * Renews one member's presence lease, and with it any departure that was inferred rather than reported. + * Answers atomically with what the write found: `null` when nothing matched (ended call, unknown member, + * reported leave), otherwise whether an inferred departure was revived, with the call's room and provider. + */ + renewUserPresenceById( + callId: string, + uid: IUser['_id'], + lastSeenAt?: Date, + inferredReasons?: VideoConferenceLeaveReason[], + ): Promise<{ revived: boolean; rid: IRoom['_id']; providerName: string } | null>; + + /** Every open call, with the roster and provider the presence sweep judges it by. */ + findActiveWithMembers(): FindCursor>; setMessageById(callId: string, messageType: keyof VideoConference['messages'], messageId: string): Promise; diff --git a/packages/models/src/models/VideoConference.spec.ts b/packages/models/src/models/VideoConference.spec.ts new file mode 100644 index 0000000000000..a4d82cf4f2f12 --- /dev/null +++ b/packages/models/src/models/VideoConference.spec.ts @@ -0,0 +1,231 @@ +// `BaseRaw` participates in a circular import that leaves it uninitialized when this module is loaded +// directly by jest. Only its constructor matters here, so it is stubbed out. +jest.mock('./BaseRaw', () => ({ + BaseRaw: class { + constructor( + public db: unknown, + public name?: string, + ) {} + }, +})); + +// eslint-disable-next-line import-x/first -- must be registered before the module under test is loaded +import { VideoConferenceRaw } from './VideoConference'; + +const member = { _id: 'user-1', username: 'user.one', name: 'User One', avatarETag: 'etag' }; + +/** + * These assert the *shape* of the update sent to Mongo rather than its effect, which is the level the bug + * these methods exist to prevent lives at: `$addToSet` on a whole document silently appends a second entry + * once the first one has been mutated, and the guard that prevents it has to be in the query. + */ +const setupModel = () => { + const updateOne = jest.fn().mockResolvedValue({}); + const findOneAndUpdate = jest.fn().mockResolvedValue(null); + const model = new VideoConferenceRaw({ collection: () => ({}) } as never); + Object.defineProperty(model, 'updateOne', { value: updateOne }); + Object.defineProperty(model, 'findOneAndUpdate', { value: findOneAndUpdate }); + + return { model, updateOne, findOneAndUpdate }; +}; + +describe('VideoConferenceRaw.addMemberById', () => { + it('should guard on the member not already being present, in the query', async () => { + const { model, updateOne } = setupModel(); + + await model.addMemberById('call-1', member); + + const [query] = updateOne.mock.calls[0]; + expect(query).toEqual({ '_id': 'call-1', 'users._id': { $ne: 'user-1' } }); + }); + + // `$addToSet` compares entire documents, so it stops de-duplicating the moment an entry can be mutated. + it('should push rather than add-to-set', async () => { + const { model, updateOne } = setupModel(); + + await model.addMemberById('call-1', member); + + const [, update] = updateOne.mock.calls[0]; + expect(update).toHaveProperty('$push'); + expect(update).not.toHaveProperty('$addToSet'); + }); + + it('should default a new member to not joined', async () => { + const { model, updateOne } = setupModel(); + + await model.addMemberById('call-1', member); + + expect(updateOne.mock.calls[0][1].$push.users).toMatchObject({ _id: 'user-1', joined: false }); + }); + + it('should omit joinedAt when there is none, rather than storing undefined', async () => { + const { model, updateOne } = setupModel(); + + await model.addMemberById('call-1', member); + + expect(updateOne.mock.calls[0][1].$push.users).not.toHaveProperty('joinedAt'); + }); +}); + +describe('VideoConferenceRaw.setUserJoinedById', () => { + it('should mutate the matching entry in place via arrayFilters', async () => { + const { model, updateOne } = setupModel(); + const joinedAt = new Date('2026-08-01T10:00:00Z'); + + await model.setUserJoinedById('call-1', 'user-1', joinedAt); + + const [query, update, options] = updateOne.mock.calls[0]; + expect(query).toEqual({ _id: 'call-1' }); + expect(update.$set).toEqual({ + 'users.$[user].joined': true, + 'users.$[user].joinedAt': joinedAt, + 'users.$[user].lastSeenAt': joinedAt, + }); + expect(options).toEqual({ arrayFilters: [{ 'user._id': 'user-1' }] }); + }); + + // An earlier departure left in place would report the member as gone while they're on the call, and could + // end the call under them once presence is what decides that. + it('should clear an earlier departure, since rejoining contradicts it', async () => { + const { model, updateOne } = setupModel(); + + await model.setUserJoinedById('call-1', 'user-1'); + + expect(updateOne.mock.calls[0][1].$unset).toEqual({ + 'users.$[user].leftAt': 1, + 'users.$[user].leftReason': 1, + 'users.$[user].ringingAt': 1, + }); + }); +}); + +describe('VideoConferenceRaw.renewUserPresenceById', () => { + it('should stamp the lease on the matching entry via arrayFilters, reading the entry as it stood before', async () => { + const { model, findOneAndUpdate } = setupModel(); + const lastSeenAt = new Date('2026-08-01T10:00:00Z'); + + await model.renewUserPresenceById('call-1', 'user-1', lastSeenAt); + + const [, update, options] = findOneAndUpdate.mock.calls[0]; + expect(update.$set).toEqual({ 'users.$[user].lastSeenAt': lastSeenAt }); + expect(options).toMatchObject({ arrayFilters: [{ 'user._id': 'user-1' }], returnDocument: 'before' }); + }); + + // A lease we gave up on while the window was in fact alive was simply wrong, and the window still talking to + // us is the correction — otherwise a member evicted during an outage would stay evicted for the whole call. + it('should undo a departure that was only inferred', async () => { + const { model, findOneAndUpdate } = setupModel(); + + await model.renewUserPresenceById('call-1', 'user-1'); + + expect(findOneAndUpdate.mock.calls[0][1].$unset).toEqual({ 'users.$[user].leftAt': 1, 'users.$[user].leftReason': 1 }); + }); + + // The guards have to be in the query, because that is the only part of an update that can be conditional: a + // heartbeat still in flight behind someone who chose to leave must not put them back in the call, and the + // final heartbeat of a window whose lease expiry ended the call must not regenerate a member inside an ENDED + // conference. + it('should refuse a reported leave and an ended call, in the query', async () => { + const { model, findOneAndUpdate } = setupModel(); + + await model.renewUserPresenceById('call-1', 'user-1', new Date(), ['timeout']); + + const [query] = findOneAndUpdate.mock.calls[0]; + expect(query).toEqual({ + _id: 'call-1', + endedAt: { $exists: false }, + users: { $elemMatch: { _id: 'user-1', $or: [{ leftAt: { $exists: false } }, { leftReason: { $in: ['timeout'] } }] } }, + }); + }); + + // The answer is decided in the same atomic step as the write: what the entry said *before* the renewal + // cleared it is the only evidence of whether anything was revived. + it('should report a revival from the before-document, and null when nothing matched', async () => { + const { model, findOneAndUpdate } = setupModel(); + + findOneAndUpdate.mockResolvedValueOnce({ + rid: 'room-1', + providerName: 'test', + users: [{ _id: 'user-1', leftAt: new Date('2026-08-01T10:00:00Z'), leftReason: 'timeout' }], + }); + expect(await model.renewUserPresenceById('call-1', 'user-1')).toEqual({ revived: true, rid: 'room-1', providerName: 'test' }); + + findOneAndUpdate.mockResolvedValueOnce({ rid: 'room-1', providerName: 'test', users: [{ _id: 'user-1' }] }); + expect(await model.renewUserPresenceById('call-1', 'user-1')).toEqual({ revived: false, rid: 'room-1', providerName: 'test' }); + + findOneAndUpdate.mockResolvedValueOnce(null); + expect(await model.renewUserPresenceById('call-1', 'user-1')).toBeNull(); + }); +}); + +describe('VideoConferenceRaw.setUserLeftById', () => { + it('should mutate the matching entry in place via arrayFilters', async () => { + const { model, updateOne } = setupModel(); + const leftAt = new Date('2026-08-01T10:00:00Z'); + + await model.setUserLeftById('call-1', 'user-1', leftAt); + + const [query, update, options] = updateOne.mock.calls[0]; + expect(query).toEqual({ _id: 'call-1' }); + expect(update.$set).toEqual({ 'users.$[user].leftAt': leftAt }); + expect(options).toEqual({ arrayFilters: [{ 'user._id': 'user-1' }] }); + }); + + // Leaving is not un-joining: the member keeps their place in the call's history and can rejoin. + it('should leave joined and declined alone', async () => { + const { model, updateOne } = setupModel(); + + await model.setUserLeftById('call-1', 'user-1'); + + const keys = Object.keys(updateOne.mock.calls[0][1].$set); + expect(keys).toEqual(['users.$[user].leftAt']); + }); + + // How the departure was learned is only worth writing when there is something to say. An absent reason reads + // as reported, which is what every entry written before presence leases existed was. + it('should record how the departure was learned, only when told', async () => { + const { model, updateOne } = setupModel(); + + // `toHaveProperty` reads a dotted string as a path, and every key here is a dotted Mongo field. + await model.setUserLeftById('call-1', 'user-1', new Date(), 'timeout'); + expect(updateOne.mock.calls[0][1].$set['users.$[user].leftReason']).toBe('timeout'); + + await model.setUserLeftById('call-1', 'user-1', new Date()); + expect(Object.keys(updateOne.mock.calls[1][1].$set)).not.toContain('users.$[user].leftReason'); + }); + + // A reported departure has to erase a leftover inferred one, or a stale heartbeat could still revive it: + // `renewUserPresenceById` treats an inferred reason as permission to undo the departure. + it('should clear a previously inferred reason when the departure is reported', async () => { + const { model, updateOne } = setupModel(); + + await model.setUserLeftById('call-1', 'user-1', new Date()); + expect(updateOne.mock.calls[0][1].$unset).toEqual({ 'users.$[user].leftReason': 1 }); + + await model.setUserLeftById('call-1', 'user-1', new Date(), 'timeout'); + expect(updateOne.mock.calls[1][1]).not.toHaveProperty('$unset'); + }); +}); + +describe('VideoConferenceRaw.setUserDeclinedById', () => { + it('should mutate the matching entry in place via arrayFilters', async () => { + const { model, updateOne } = setupModel(); + const declinedAt = new Date('2026-08-01T10:00:00Z'); + + await model.setUserDeclinedById('call-1', 'user-1', declinedAt); + + const [query, update, options] = updateOne.mock.calls[0]; + expect(query).toEqual({ _id: 'call-1' }); + expect(update).toEqual({ $set: { 'users.$[user].declined': true, 'users.$[user].declinedAt': declinedAt } }); + expect(options).toEqual({ arrayFilters: [{ 'user._id': 'user-1' }] }); + }); + + // Declining must not clear `joined`: a member can dismiss the ring and join later. + it('should not touch the joined flag', async () => { + const { model, updateOne } = setupModel(); + + await model.setUserDeclinedById('call-1', 'user-1'); + + expect(Object.keys(updateOne.mock.calls[0][1].$set)).not.toContain('users.$[user].joined'); + }); +}); diff --git a/packages/models/src/models/VideoConference.ts b/packages/models/src/models/VideoConference.ts index fbcad1529f959..e09699b734eb7 100644 --- a/packages/models/src/models/VideoConference.ts +++ b/packages/models/src/models/VideoConference.ts @@ -6,6 +6,7 @@ import type { IRoom, RocketChatRecordDeleted, IVoIPVideoConference, + VideoConferenceLeaveReason, } from '@rocket.chat/core-typings'; import { VideoConferenceStatus } from '@rocket.chat/core-typings'; import type { FindPaginated, InsertionModel, IVideoConferenceModel } from '@rocket.chat/model-typings'; @@ -31,7 +32,20 @@ export class VideoConferenceRaw extends BaseRaw implements IVid return [ { key: { rid: 1, createdAt: 1 }, unique: false }, { key: { type: 1, status: 1 }, unique: false }, - { key: { discussionRid: 1 }, unique: false }, + // `createdAt` is part of the key so the `$or: [{ rid }, { discussionRid }]` listing below can be + // served by an index-ordered merge instead of a blocking in-memory sort of the whole room history. + { key: { discussionRid: 1, createdAt: 1 }, unique: false }, + // Listing the calls that are running (`findActiveWithMembers` and the service's own scans over open + // calls): a partial + // index, so it holds just the handful of conferences that are live. The hot queries match on these + // exact statuses, which is what makes the index eligible for them; `endedAt: { $exists: false }` alone + // could not anchor an index at all. `$in` in a partialFilterExpression needs MongoDB 6.0, and the + // minimum supported server is 7.0. + { + key: { status: 1, createdAt: -1 }, + unique: false, + partialFilterExpression: { status: { $in: [VideoConferenceStatus.CALLING, VideoConferenceStatus.STARTED] } }, + }, ]; } @@ -39,10 +53,11 @@ export class VideoConferenceRaw extends BaseRaw implements IVid rid: IRoom['_id'], { offset, count }: { offset?: number; count?: number } = {}, ): FindPaginated> { - // No data is lost — `providerData` is optional — but `Omit` over the `VideoConference` union collapses it into a single - // object type, so the explicit type argument opts out of projection inference to preserve the discriminated union. + // Matches conferences started in this room (`rid`) and those whose discussion *is* this room + // (`discussionRid`), so a discussion resolves the conference it belongs to — its members may have no + // access to the parent room the conference originated in. return this.findPaginated( - { rid }, + { $or: [{ rid }, { discussionRid: rid }] }, { sort: { createdAt: -1 }, skip: offset, @@ -193,6 +208,14 @@ export class VideoConferenceRaw extends BaseRaw implements IVid }); } + public async setTitleById(callId: string, title: string): Promise { + await this.updateOneById(callId, { + $set: { + title, + }, + }); + } + public async setProviderDataById(callId: string, providerData: Record | undefined): Promise { await this.updateOneById(callId, { ...(providerData @@ -209,21 +232,140 @@ export class VideoConferenceRaw extends BaseRaw implements IVid }); } - public async addUserById( + /** + * Adds a member to the conference, doing nothing if they are already one. + * + * The guard lives in the *query*, not in a read-then-write: `$addToSet` compares whole documents, so once + * an entry can be mutated (by `setUserJoinedById` below) it would no longer match and a second call would + * append a duplicate. Filtering on `users._id` makes this atomic and idempotent in one update, which also + * removes the race in a caller that checks membership in memory first. + */ + public async addMemberById( callId: string, user: Required> & { ts?: Date }, ): Promise { - await this.updateOneById(callId, { - $addToSet: { - users: { - _id: user._id, - username: user.username, - name: user.name, - avatarETag: user.avatarETag, - ts: user.ts || new Date(), + await this.updateOne( + { '_id': callId, 'users._id': { $ne: user._id } }, + { + $push: { + users: { + _id: user._id, + username: user.username, + name: user.name, + avatarETag: user.avatarETag, + ts: user.ts || new Date(), + // Being a member is not being in the call. Whoever is arriving says so with `setUserJoinedById`. + joined: false, + }, }, }, - }); + ); + } + + /** Marks an existing member as being in the call, mutating their entry in place. */ + public async setUserJoinedById(callId: string, uid: IUser['_id'], joinedAt = new Date()): Promise { + await this.updateOne( + { _id: callId }, + { + // Joining is the first renewal of the member's presence lease: it is the strongest evidence there + // is that they are in the call, and stamping it here saves a second write to say so. + $set: { 'users.$[user].joined': true, 'users.$[user].joinedAt': joinedAt, 'users.$[user].lastSeenAt': joinedAt }, + // Rejoining makes an earlier departure meaningless: leaving it behind would report the member as + // gone while they are on the call, and could end the call under them. Clearing ringingAt stops + // the caller's ringback tone — the person answered. + $unset: { 'users.$[user].leftAt': 1, 'users.$[user].leftReason': 1, 'users.$[user].ringingAt': 1 }, + }, + { arrayFilters: [{ 'user._id': uid }] }, + ); + } + + /** + * Renews a member's presence lease — their call window reporting that it is still in the call. + * + * A renewal also undoes a departure that was *inferred*: a lease we gave up on while the window was in fact + * alive was simply wrong, and the window saying so is the correction. A departure the member reported is + * never undone this way — they left, and a heartbeat still in flight behind them must not put them back in + * the call. Neither is anything undone on a call that has ended: the final heartbeat of a window whose lease + * expiry emptied the call would otherwise regenerate a member inside an ENDED conference. Both conditions + * live in the query, which is why a stale renewal matches nothing at all. + * + * Answers with what the write found, decided in the same atomic step as the write itself: `null` when nothing + * matched (the call ended, the member is unknown, or their departure was reported), and otherwise whether this + * renewal *revived* an inferred departure — judged from the entry as it stood before the write, along with the + * call's room and provider so the caller can react without a second, racy read. + */ + public async renewUserPresenceById( + callId: string, + uid: IUser['_id'], + lastSeenAt = new Date(), + inferredReasons: VideoConferenceLeaveReason[] = ['timeout'], + ): Promise<{ revived: boolean; rid: IRoom['_id']; providerName: string } | null> { + const before = await this.findOneAndUpdate( + { + _id: callId, + endedAt: { $exists: false }, + users: { $elemMatch: { _id: uid, $or: [{ leftAt: { $exists: false } }, { leftReason: { $in: inferredReasons } }] } }, + }, + { + $set: { 'users.$[user].lastSeenAt': lastSeenAt }, + $unset: { 'users.$[user].leftAt': 1, 'users.$[user].leftReason': 1 }, + }, + { + arrayFilters: [{ 'user._id': uid }], + returnDocument: 'before', + projection: { users: 1, rid: 1, providerName: 1 }, + }, + ); + + if (!before) { + return null; + } + + const member = before.users.find(({ _id }) => _id === uid); + return { + revived: !!member?.leftAt && !!member.leftReason && inferredReasons.includes(member.leftReason), + rid: before.rid, + providerName: before.providerName, + }; + } + + /** Records that we just rang these members, so every client can tell a ringing phone from a silent one. */ + public async setUsersRingingById(callId: string, uids: IUser['_id'][], ringingAt = new Date()): Promise { + if (!uids.length) { + return; + } + + await this.updateOne( + { _id: callId }, + { $set: { 'users.$[user].ringingAt': ringingAt } }, + { arrayFilters: [{ 'user._id': { $in: uids } }] }, + ); + } + + /** + * `reason` says how the departure came to be known, and is only written when there is something to say: an + * absent one reads as reported, which is what every entry written before leases existed was. + */ + public async setUserLeftById(callId: string, uid: IUser['_id'], leftAt = new Date(), reason?: VideoConferenceLeaveReason): Promise { + await this.updateOne( + { _id: callId }, + { + $set: { 'users.$[user].leftAt': leftAt, ...(reason && { 'users.$[user].leftReason': reason }) }, + // A reported departure must clear a leftover inferred one, or a stale heartbeat could still revive + // it: `renewUserPresenceById` treats an inferred reason as permission to undo the departure. + ...(!reason && { $unset: { 'users.$[user].leftReason': 1 } }), + }, + { arrayFilters: [{ 'user._id': uid }] }, + ); + } + + /** Records that an existing member dismissed the call, mutating their entry in place. */ + public async setUserDeclinedById(callId: string, uid: IUser['_id'], declinedAt = new Date()): Promise { + await this.updateOne( + { _id: callId }, + { $set: { 'users.$[user].declined': true, 'users.$[user].declinedAt': declinedAt } }, + { arrayFilters: [{ 'user._id': uid }] }, + ); } public async setMessageById(callId: string, messageType: keyof VideoConference['messages'], messageId: string): Promise { @@ -231,8 +373,7 @@ export class VideoConferenceRaw extends BaseRaw implements IVid $set: { [`messages.${messageType}`]: messageId, }, - }); // TODO: Remove this cast when TypeScript is updated - // TypeScript is not smart enough to infer that `messages.${'start' | 'end'}` matches two keys of `VideoConference` + }); } public async updateUserReferences(userId: IUser['_id'], username: IUser['username'], name: IUser['name']): Promise { @@ -304,4 +445,21 @@ export class VideoConferenceRaw extends BaseRaw implements IVid }, ); } + + /** + * Every call that is still open, with what the presence sweep needs to judge it: who is on the roster, and + * which provider is running the media — the one that may be able to say who is in the room. + * + * Deliberately not scoped to a provider or to an age. Any open call has leases to check, and one whose + * members all vanished ten seconds ago is exactly as stuck as one that has been that way for hours. + */ + public findActiveWithMembers(): FindCursor> { + return this.find( + { + status: { $in: [VideoConferenceStatus.CALLING, VideoConferenceStatus.STARTED] }, + endedAt: { $exists: false }, + }, + { projection: { _id: 1, rid: 1, users: 1, providerName: 1 } }, + ); + } } diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts new file mode 100644 index 0000000000000..06b3c76fafb4f --- /dev/null +++ b/packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts @@ -0,0 +1,50 @@ +import { RING_RECIPIENTS_LIMIT } from '@rocket.chat/core-typings'; +import type { JSONSchemaType } from 'ajv'; + +import { ajv } from '../Ajv'; + +export type VideoConfAddParticipantsProps = { + callId: string; + /** + * The *usernames* of the people to add — not user ids. Adding someone can end with them invited into the + * room the chat lives in, and that machinery speaks usernames; `video-conference.ring` speaks ids instead, + * because it targets people who are already conference members. The endpoint answers with the user *ids* + * of the members it actually added. + */ + users: string[]; + /** + * Whether to ring the people being added. **Defaults to `false`** — adding someone is often so they can + * join when they can, and a ring nobody asked for is an interruption. Ringing also needs the + * `videoconf-ring-users` permission, without which it is skipped silently. + */ + ring?: boolean; +}; + +const videoConfAddParticipantsPropsSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + users: { + type: 'array', + description: 'Usernames of the people to add — not user ids. The endpoint returns the user ids of the members actually added.', + items: { + type: 'string', + }, + minItems: 1, + // Adding is capped so the whole batch can always be rung, which is why it is the ringing limit itself. + maxItems: RING_RECIPIENTS_LIMIT, + }, + ring: { + type: 'boolean', + description: 'Whether to ring the people being added. Defaults to false.', + nullable: true, + }, + }, + required: ['callId', 'users'], + additionalProperties: false, +}; + +export const isVideoConfAddParticipantsProps = ajv.compile(videoConfAddParticipantsPropsSchema); diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfCallIdProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfCallIdProps.ts new file mode 100644 index 0000000000000..c472945a9fd9d --- /dev/null +++ b/packages/rest-typings/src/v1/videoConference/VideoConfCallIdProps.ts @@ -0,0 +1,27 @@ +import type { JSONSchemaType } from 'ajv'; + +import { ajv } from '../Ajv'; + +/** + * The body of every conference endpoint that only has to say *which* call: cancel, decline, leave. + * + * They had a validator each, character for character the same, which is three places to keep in step for one + * shape. What each endpoint then *does* with the call is where they actually differ. + */ +export type VideoConfCallIdProps = { + callId: string; +}; + +const videoConfCallIdPropsSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + }, + required: ['callId'], + additionalProperties: false, +}; + +export const isVideoConfCallIdProps = ajv.compile(videoConfCallIdPropsSchema); diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfCancelProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfCancelProps.ts deleted file mode 100644 index 9f93576aa302d..0000000000000 --- a/packages/rest-typings/src/v1/videoConference/VideoConfCancelProps.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { JSONSchemaType } from 'ajv'; - -import { ajv } from '../Ajv'; - -export type VideoConfCancelProps = { - callId: string; -}; - -const videoConfCancelPropsSchema: JSONSchemaType = { - type: 'object', - properties: { - callId: { - type: 'string', - nullable: false, - }, - }, - required: ['callId'], - additionalProperties: false, -}; - -export const isVideoConfCancelProps = ajv.compile(videoConfCancelPropsSchema); diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfRenameProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfRenameProps.ts new file mode 100644 index 0000000000000..7e366a2ee1e67 --- /dev/null +++ b/packages/rest-typings/src/v1/videoConference/VideoConfRenameProps.ts @@ -0,0 +1,29 @@ +import type { JSONSchemaType } from 'ajv'; + +import { ajv } from '../Ajv'; + +export type VideoConfRenameProps = { + callId: string; + /** What to call the conference. Trimmed, and only the person who started it may set it. */ + title: string; +}; + +const videoConfRenamePropsSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + title: { + type: 'string', + nullable: false, + minLength: 1, + maxLength: 200, + }, + }, + required: ['callId', 'title'], + additionalProperties: false, +}; + +export const isVideoConfRenameProps = ajv.compile(videoConfRenamePropsSchema); diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfRingProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfRingProps.ts new file mode 100644 index 0000000000000..6eabba0e090f2 --- /dev/null +++ b/packages/rest-typings/src/v1/videoConference/VideoConfRingProps.ts @@ -0,0 +1,35 @@ +import type { JSONSchemaType } from 'ajv'; + +import { ajv } from '../Ajv'; + +export type VideoConfRingProps = { + callId: string; + /** + * The member to ring, by user *id* — not username. Ringing targets someone who is already a conference + * member, and members are tracked by id; `video-conference.add-participants` speaks usernames instead, + * because it may also invite people into a room. + * + * One at a time on purpose: ringing again is aimed at a particular person who didn't pick up, so the caller + * says who. Ringing a batch is what adding participants does. + */ + userId: string; +}; + +const videoConfRingPropsSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + userId: { + type: 'string', + description: 'Id of the member to ring — not their username.', + nullable: false, + }, + }, + required: ['callId', 'userId'], + additionalProperties: false, +}; + +export const isVideoConfRingProps = ajv.compile(videoConfRingPropsSchema); diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfShareChatProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfShareChatProps.ts new file mode 100644 index 0000000000000..9bc042a9c35f1 --- /dev/null +++ b/packages/rest-typings/src/v1/videoConference/VideoConfShareChatProps.ts @@ -0,0 +1,32 @@ +import type { VideoConferenceChatAccessMode } from '@rocket.chat/core-typings'; +import type { JSONSchemaType } from 'ajv'; + +import { ajv } from '../Ajv'; + +export type VideoConfShareChatProps = { + callId: string; + /** + * How to give the members without access to the chat: bring them into the room, or move the chat to a + * discussion. Required — which of the two leads is a choice the caller makes and shows, so the endpoint + * does not guess. `invite` is rejected for rooms that can't take new members. + */ + mode: VideoConferenceChatAccessMode; +}; + +const videoConfShareChatPropsSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + mode: { + type: 'string', + enum: ['invite', 'discussion'], + }, + }, + required: ['callId', 'mode'], + additionalProperties: false, +}; + +export const isVideoConfShareChatProps = ajv.compile(videoConfShareChatPropsSchema); diff --git a/packages/rest-typings/src/v1/videoConference/index.ts b/packages/rest-typings/src/v1/videoConference/index.ts index 02dcc67cc7137..0712f9cae68c6 100644 --- a/packages/rest-typings/src/v1/videoConference/index.ts +++ b/packages/rest-typings/src/v1/videoConference/index.ts @@ -1,9 +1,19 @@ -import type { VideoConferenceInstructions, VideoConference, VideoConferenceCapabilities } from '@rocket.chat/core-typings'; +import type { + JoinableVideoConference, + VideoConferenceInstructions, + VideoConference, + VideoConferenceCapabilities, + VideoConferenceChatAccess, +} from '@rocket.chat/core-typings'; -import type { VideoConfCancelProps } from './VideoConfCancelProps'; +import type { VideoConfAddParticipantsProps } from './VideoConfAddParticipantsProps'; +import type { VideoConfCallIdProps } from './VideoConfCallIdProps'; import type { VideoConfInfoProps } from './VideoConfInfoProps'; import type { VideoConfJoinProps } from './VideoConfJoinProps'; import type { VideoConfListProps } from './VideoConfListProps'; +import type { VideoConfRenameProps } from './VideoConfRenameProps'; +import type { VideoConfRingProps } from './VideoConfRingProps'; +import type { VideoConfShareChatProps } from './VideoConfShareChatProps'; import type { VideoConfStartProps } from './VideoConfStartProps'; import type { PaginatedResult } from '../../helpers/PaginatedResult'; @@ -11,7 +21,11 @@ export * from './VideoConfInfoProps'; export * from './VideoConfListProps'; export * from './VideoConfStartProps'; export * from './VideoConfJoinProps'; -export * from './VideoConfCancelProps'; +export * from './VideoConfRingProps'; +export * from './VideoConfCallIdProps'; +export * from './VideoConfAddParticipantsProps'; +export * from './VideoConfShareChatProps'; +export * from './VideoConfRenameProps'; export type VideoConferenceEndpoints = { '/v1/video-conference.start': { @@ -19,15 +33,62 @@ export type VideoConferenceEndpoints = { }; '/v1/video-conference.join': { - POST: (params: VideoConfJoinProps) => { url: string; providerName: string }; + // Embedded providers (e.g. LiveKit) return an empty `url` and include + // `callId` + `rid` instead — the client routes the join into the + // embedded provider's React context rather than opening a popup URL. + POST: (params: VideoConfJoinProps) => { url: string; providerName: string; callId?: string; rid?: string }; + }; + + /** Records that the caller left the call, ending the conference when nobody is left in it. */ + '/v1/video-conference.leave': { + POST: (params: VideoConfCallIdProps) => void; + }; + + /** + * Renews the caller's presence lease on the call. Leaving is inferred from these stopping, so that a departure + * nobody could report — a workspace outage, a crashed tab — is still recorded. + */ + '/v1/video-conference.heartbeat': { + POST: (params: VideoConfCallIdProps) => void; + }; + + /** Rings one member who isn't in the call again; says whether the ring actually went out. */ + '/v1/video-conference.ring': { + POST: (params: VideoConfRingProps) => { rang: boolean }; }; '/v1/video-conference.cancel': { - POST: (params: VideoConfCancelProps) => void; + POST: (params: VideoConfCallIdProps) => void; + }; + + '/v1/video-conference.decline': { + POST: (params: VideoConfCallIdProps) => void; + }; + + '/v1/video-conference.add-participants': { + POST: (params: VideoConfAddParticipantsProps) => { added: string[] }; + }; + + /** Renames a running group conference. Only the person who started it may. */ + '/v1/video-conference.rename': { + POST: (params: VideoConfRenameProps) => void; + }; + + '/v1/video-conference.share-chat': { + POST: (params: VideoConfShareChatProps) => { rid: string }; }; '/v1/video-conference.info': { - GET: (params: VideoConfInfoProps) => VideoConference & { capabilities: VideoConferenceCapabilities }; + GET: (params: VideoConfInfoProps) => VideoConference & { + capabilities: VideoConferenceCapabilities; + /** Where the chat lives, who among the members cannot read it, and how that can be resolved. */ + chatAccess: VideoConferenceChatAccess; + }; + }; + + /** The calls running now that the caller may join — how a call is reached without catching its ring. */ + '/v1/video-conference.joinable': { + GET: () => { calls: JoinableVideoConference[] }; }; '/v1/video-conference.list': {