Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions services/love/src/__tests__/rooms.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) =>
infos.filter((i) => i.room === query.room && (query.person === undefined || i.person === query.person)),
findOne: async (_class: string, query: Record<string, string>) => 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]
])
})
})
38 changes: 38 additions & 0 deletions services/love/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -138,6 +139,14 @@ export const main = async (): Promise<void> => {
} 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
}
Expand Down Expand Up @@ -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<void> {
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<string> => {
const at = new AccessToken(config.ApiKey, config.ApiSecret, {
identity: _id,
Expand Down
71 changes: 71 additions & 0 deletions services/love/src/rooms.ts
Original file line number Diff line number Diff line change
@@ -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<Room>
}

/**
* A LiveKit room is named `<workspace uuid>_<room name>_<room id>` 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<Room> }
}

/**
* 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<Room>,
person?: PersonRef
): Promise<number> {
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
}
13 changes: 12 additions & 1 deletion services/love/src/workspaceClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Room>): Promise<number> {
return await resetRoomParticipants(this.client, roomId, person)
}

/** The LiveKit room is gone: nobody is in it any more. */
async clearRoom (roomId: Ref<Room>): Promise<number> {
return await resetRoomParticipants(this.client, roomId)
}

async saveFile (
uuid: string,
name: string,
Expand Down