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,