From be8eb17e466d06fed9d236d419c72959750e63ee Mon Sep 17 00:00:00 2001 From: Ayush Nigade Date: Wed, 2 Sep 2026 10:12:53 -0700 Subject: [PATCH] fix(love): clear stale ParticipantInfo on LiveKit participant_left / room_finished ParticipantInfo is written by the person's own client on join and leave and dropped by the server only when the person goes fully offline. When the client goes away mid-call (reload, crash, network loss) LiveKit drops the participant but the record keeps pointing at the room: the person is shown as a member indefinitely, cannot re-establish the call, and nobody can remove them (Kick exists only for an office owner in their own office). The Love service already receives LiveKit webhooks. Handle participant_left by moving that person's record out of the room (to their office, else reception) and room_finished by doing so for everyone still recorded in it - the same update the client's own kick performs, so the server-side room triggers apply as usual. Filtering by room keeps a late event harmless for someone who has already moved on to another room. Failures are logged and never fail the webhook. Signed-off-by: Ayush Nigade --- services/love/src/__tests__/rooms.test.ts | 93 +++++++++++++++++++++++ services/love/src/main.ts | 38 +++++++++ services/love/src/rooms.ts | 71 +++++++++++++++++ services/love/src/workspaceClient.ts | 13 +++- 4 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 services/love/src/__tests__/rooms.test.ts create mode 100644 services/love/src/rooms.ts diff --git a/services/love/src/__tests__/rooms.test.ts b/services/love/src/__tests__/rooms.test.ts new file mode 100644 index 00000000000..33f8406de95 --- /dev/null +++ b/services/love/src/__tests__/rooms.test.ts @@ -0,0 +1,93 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type TxOperations } from '@hcengineering/core' +import love from '@hcengineering/love' +import { parseRoomName, resetRoomParticipants } from '../rooms' + +describe('parseRoomName', () => { + it('takes the workspace from the first segment and the room id from the last', () => { + expect(parseRoomName('ws-1_All hands_room-1')).toEqual({ workspace: 'ws-1', roomId: 'room-1' }) + }) + + it('tolerates underscores inside the room name', () => { + expect(parseRoomName('ws-1_Team_sync_room_room-2')).toEqual({ workspace: 'ws-1', roomId: 'room-2' }) + }) + + it('rejects names that are not workspace_name_id', () => { + expect(parseRoomName('room-1')).toBeUndefined() + expect(parseRoomName('ws-1_room-1')).toBeUndefined() + expect(parseRoomName('_name_')).toBeUndefined() + }) +}) + +interface Update { + _id: string + room: string + x: number + y: number +} + +function fakeClient ( + infos: Array<{ _id: string, person: string, room: string }>, + offices: Array<{ _id: string, person: string }> +): { client: TxOperations, updates: Update[] } { + const updates: Update[] = [] + const client = { + findAll: async (_class: string, query: Record) => + infos.filter((i) => i.room === query.room && (query.person === undefined || i.person === query.person)), + findOne: async (_class: string, query: Record) => offices.find((o) => o.person === query.person), + update: async (doc: { _id: string }, ops: { room: string, x: number, y: number }) => { + updates.push({ _id: doc._id, ...ops }) + } + } as unknown as TxOperations + return { client, updates } +} + +describe('resetRoomParticipants', () => { + const infos = [ + { _id: 'pi-a', person: 'alice', room: 'room-1' }, + { _id: 'pi-b', person: 'bob', room: 'room-1' }, + { _id: 'pi-c', person: 'carol', room: 'room-2' } + ] + const offices = [{ _id: 'office-a', person: 'alice' }] + + it('moves one dropped participant to their office', async () => { + const { client, updates } = fakeClient(infos, offices) + await expect(resetRoomParticipants(client, 'room-1' as any, 'alice' as any)).resolves.toBe(1) + expect(updates).toEqual([{ _id: 'pi-a', room: 'office-a', x: 0, y: 0 }]) + }) + + it('falls back to reception for a participant without an office', async () => { + const { client, updates } = fakeClient(infos, offices) + await resetRoomParticipants(client, 'room-1' as any, 'bob' as any) + expect(updates).toEqual([{ _id: 'pi-b', room: love.ids.Reception, x: 0, y: 0 }]) + }) + + it('ignores a participant who has already moved to another room', async () => { + const { client, updates } = fakeClient(infos, offices) + await expect(resetRoomParticipants(client, 'room-1' as any, 'carol' as any)).resolves.toBe(0) + expect(updates).toEqual([]) + }) + + it('clears everyone when the room is finished', async () => { + const { client, updates } = fakeClient(infos, offices) + await expect(resetRoomParticipants(client, 'room-1' as any)).resolves.toBe(2) + expect(updates.map((u) => [u._id, u.room])).toEqual([ + ['pi-a', 'office-a'], + ['pi-b', love.ids.Reception] + ]) + }) +}) diff --git a/services/love/src/main.ts b/services/love/src/main.ts index 80433e9ef97..d7a5c74ad59 100644 --- a/services/love/src/main.ts +++ b/services/love/src/main.ts @@ -41,6 +41,7 @@ import { join } from 'path' import { saveLiveKitEgressBilling, updateLiveKitSessions } from './billing' import config from './config' import { getRecordingPreset } from './preset' +import { parseRoomName, type PersonRef } from './rooms' import { getS3UploadParams, saveFile } from './storage' import { WorkspaceClient } from './workspaceClient' @@ -138,6 +139,14 @@ export const main = async (): Promise => { } else if (event.event === 'room_finished' && event.room !== undefined) { const { sid, name } = event.room ctx.info('webhook event', { event: event.event, room: { sid, name } }) + await clearParticipants(ctx, name) + res.send() + return + } else if (event.event === 'participant_left' && event.room !== undefined && event.participant !== undefined) { + const { sid, name } = event.room + const { identity } = event.participant + ctx.info('webhook event', { event: event.event, room: { sid, name }, participant: identity }) + await clearParticipants(ctx, name, identity as PersonRef) res.send() return } @@ -312,6 +321,35 @@ const stopEgress = async (egressClient: EgressClient, roomName: string): Promise } } +/** + * LiveKit has dropped a participant (or closed the room): the ParticipantInfo + * records that still place people in that room are stale, because only the + * person's own client writes them and that client is gone. Reset them so the + * room is not shown occupied forever and the person can join again. + */ +async function clearParticipants (ctx: MeasureContext, roomName: string, person?: PersonRef): Promise { + const parsed = parseRoomName(roomName) + if (parsed === undefined) { + ctx.warn('unexpected LiveKit room name', { roomName }) + return + } + try { + const client = await WorkspaceClient.create(parsed.workspace, ctx) + try { + const reset = + person !== undefined ? await client.leaveRoom(person, parsed.roomId) : await client.clearRoom(parsed.roomId) + if (reset > 0) { + ctx.info('reset stale participants', { room: parsed.roomId, person, reset }) + } + } finally { + await client.close() + } + } catch (err: any) { + // The webhook must not fail over this: LiveKit would retry the event. + ctx.error('failed to reset stale participants', { roomName, person, error: err.message }) + } +} + const createToken = async (roomName: string, _id: string, participantName: string): Promise => { const at = new AccessToken(config.ApiKey, config.ApiSecret, { identity: _id, diff --git a/services/love/src/rooms.ts b/services/love/src/rooms.ts new file mode 100644 index 00000000000..87724a1995f --- /dev/null +++ b/services/love/src/rooms.ts @@ -0,0 +1,71 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type Ref, type TxOperations, type WorkspaceUuid } from '@hcengineering/core' +import love, { type Office, type ParticipantInfo, type Room } from '@hcengineering/love' + +export type PersonRef = ParticipantInfo['person'] + +export interface ParsedRoomName { + workspace: WorkspaceUuid + roomId: Ref +} + +/** + * A LiveKit room is named `__` by the + * client. The room name itself may contain underscores, so only the first and + * the last segment are meaningful. + */ +export function parseRoomName (name: string): ParsedRoomName | undefined { + const parts = name.split('_') + if (parts.length < 3) return undefined + const workspace = parts[0] + const roomId = parts[parts.length - 1] + if (workspace === '' || roomId === '') return undefined + return { workspace: workspace as WorkspaceUuid, roomId: roomId as Ref } +} + +/** + * Move every ParticipantInfo still recorded in `roomId` (optionally just + * `person`'s) back to the person's office, or to reception without one. + * + * ParticipantInfo is written by the person's own client on join and leave; + * when that client goes away mid-call (reload, crash, network loss) the + * record keeps pointing at the room although LiveKit has long dropped the + * participant. This is the same update the client performs when an office + * owner kicks a visitor, so the server-side room triggers apply as usual. + * + * Filtering by room makes a late event harmless: a participant who has + * already moved on to another room is no longer matched. + */ +export async function resetRoomParticipants ( + client: TxOperations, + roomId: Ref, + person?: PersonRef +): Promise { + const infos = await client.findAll(love.class.ParticipantInfo, { + room: roomId, + ...(person !== undefined ? { person } : {}) + }) + for (const info of infos) { + const office = await client.findOne(love.class.Office, { person: info.person }) + await client.update(info, { + room: (office as Office | undefined)?._id ?? love.ids.Reception, + x: 0, + y: 0 + }) + } + return infos.length +} diff --git a/services/love/src/workspaceClient.ts b/services/love/src/workspaceClient.ts index fc8ed3fb1ab..5c358defbce 100644 --- a/services/love/src/workspaceClient.ts +++ b/services/love/src/workspaceClient.ts @@ -24,10 +24,11 @@ import core, { type Blob } from '@hcengineering/core' import drive, { createFile } from '@hcengineering/drive' -import love, { MeetingMinutes } from '@hcengineering/love' +import love, { MeetingMinutes, type Room } from '@hcengineering/love' import { generateToken } from '@hcengineering/server-token' import { getClient } from './client' import { RecordingPreset } from './preset' +import { type PersonRef, resetRoomParticipants } from './rooms' export class WorkspaceClient { private client!: TxOperations @@ -54,6 +55,16 @@ export class WorkspaceClient { return this.client } + /** A participant LiveKit has dropped is no longer in the room. */ + async leaveRoom (person: PersonRef, roomId: Ref): Promise { + return await resetRoomParticipants(this.client, roomId, person) + } + + /** The LiveKit room is gone: nobody is in it any more. */ + async clearRoom (roomId: Ref): Promise { + return await resetRoomParticipants(this.client, roomId) + } + async saveFile ( uuid: string, name: string,