-
Notifications
You must be signed in to change notification settings - Fork 13.9k
chore(video-conf): conference data model, server service & API (1/4) #41934
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
aaf5512
feat(video-conf): conference data model, server service & API
rodrigok 46e3e92
fix(video-conf): skip presence lease expiry for non-embedded providers
rodrigok a6f7842
chore(video-conf): remove jwt, i18n, desktop-api, docs and changeset
rodrigok 30deae3
fix(video-conf): keep embedded call lifecycle away from non-embedded …
rodrigok 20795ad
feat(video-conf): register the VideoConf_Persistent_Chat_Mode setting
rodrigok f203f2b
fix(video-conf): make the join endpoint's missing-url failure reachable
rodrigok 9ec7924
fix(video-conf): measure the presence-sweep grace from job registration
rodrigok 73df473
fix(models): correct video-conference index, stale leftReason and par…
rodrigok d64f661
fix(video-conf): scope leave notifications to the leaver and survive …
rodrigok 069c761
fix(video-conf): share canAccessConference with the conference stream…
rodrigok fdd32e1
chore(video-conf): drop the unused CORE_PROVIDER_APP_ID export
rodrigok 8c87fab
docs(rest-typings): say which identifier the video-conference users f…
rodrigok 86cf00d
fix(video-conf): enforce the ring permission on the new endpoints
rodrigok 89f40c9
fix(video-conf): presence and ringing correctness from the re-review
rodrigok 8376e2b
fix(video-conf): decide presence revival atomically with the renewal …
rodrigok ffa58bb
test(video-conf): keep the ringing e2e as develop has it
rodrigok f021b56
fix(i18n): add the conference invite notification string
rodrigok 16072c3
chore(video-conf): add the changeset for the conference data model
rodrigok 35d8b93
chore(video-conf): defer the embedded participants record to LiveKit
rodrigok ccb56e8
chore(video-conf): answer the review on ringing, chat mode and API co…
rodrigok 6d0b35b
chore(video-conf): name the ring limit after its recipients, ring one…
rodrigok d391d7f
chore(video-conf): stop inferring the chat-sharing mode
rodrigok b2c19bc
chore(video-conf): correct two comments the review caught out
rodrigok 18058e4
chore(video-conf): leave the chat mode to the PR that introduces the …
rodrigok 4de8bbb
fix(video-conf): don't push the callee twice when the caller arrives
rodrigok 17bda0c
fix(video-conf): report a departure once, however many times it is re…
rodrigok ef7305f
refactor(video-conf): ask "is this provider embedded" through a name
rodrigok f605dfc
fix(video-conf): tell a call's own members when it ends, not just its…
rodrigok c54b472
fix(video-conf): announce a renamed call on the conference stream too
rodrigok 3536a06
fix(video-conf): drop the share-chat endpoint's unreliable error mapping
rodrigok 79b1f42
chore(video-conf): answer the review on the changeset, a stray commen…
rodrigok File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = ( | ||
|
rodrigok marked this conversation as resolved.
|
||
| access: Pick<VideoConferenceChatAccess, 'membersWithoutAccess'> | undefined, | ||
| uid: IUser['_id'] | null | undefined, | ||
| ): boolean => !uid || !access?.membersWithoutAccess.includes(uid); | ||
|
rodrigok marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * 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); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import type { IRoom, IUser, IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; | ||
|
|
||
| type NameableConference = { | ||
| type: VideoConference['type']; | ||
| title?: string; | ||
| createdBy: Pick<IUser, '_id'> & Partial<Pick<IUser, 'name' | 'username'>>; | ||
| users: (Pick<IVideoConferenceUser, '_id'> & Partial<Pick<IVideoConferenceUser, 'name' | 'username'>>)[]; | ||
| }; | ||
|
|
||
| const displayName = (person?: Partial<Pick<IUser, 'name' | 'username'>>): 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)) : ''; | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<IVideoConferenceUser, 'joined' | 'declined' | 'declinedAt' | 'leftAt' | 'ringingAt'>; | ||
|
|
||
| /** | ||
| * 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<IVideoConferenceUser, 'joined' | 'declined' | 'ringingAt'>): boolean => | ||
| !member.ringingAt && !hasJoinedVideoConference(member) && !member.declined; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.