mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
refactor(api): remove premium-based voice track muting (#2536)
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {TrackSource} from 'livekit-server-sdk';
|
||||
import type {ChannelID, GuildID, UserID} from '../BrandedTypes';
|
||||
import type {VoiceRegionMetadata, VoiceServerRecord} from '../voice/VoiceModel';
|
||||
import type {ILiveKitService, ListActiveRoomsResult, ListParticipantsResult} from './ILiveKitService';
|
||||
@@ -52,27 +51,6 @@ interface UpdateParticipantPermissionsParams {
|
||||
deaf?: boolean;
|
||||
}
|
||||
|
||||
interface MuteParticipantTrackParams {
|
||||
userId: UserID;
|
||||
guildId?: GuildID;
|
||||
channelId: ChannelID;
|
||||
connectionId: string;
|
||||
regionId: string;
|
||||
serverId: string;
|
||||
trackSid: string;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
interface RevokeParticipantPublishSourceParams {
|
||||
userId: UserID;
|
||||
guildId?: GuildID;
|
||||
channelId: ChannelID;
|
||||
connectionId: string;
|
||||
regionId: string;
|
||||
serverId: string;
|
||||
source: TrackSource;
|
||||
}
|
||||
|
||||
export class DisabledLiveKitService implements ILiveKitService {
|
||||
async createToken(_params: CreateTokenParams): Promise<{
|
||||
token: string;
|
||||
@@ -87,14 +65,6 @@ export class DisabledLiveKitService implements ILiveKitService {
|
||||
|
||||
async disconnectParticipant(_params: DisconnectParticipantParams): Promise<void> {}
|
||||
|
||||
async muteParticipantTrack(_params: MuteParticipantTrackParams): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async revokeParticipantPublishSource(_params: RevokeParticipantPublishSourceParams): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async listParticipants(_params: {
|
||||
guildId?: GuildID;
|
||||
channelId: ChannelID;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {TrackSource} from 'livekit-server-sdk';
|
||||
import type {ChannelID, GuildID, UserID} from '../BrandedTypes';
|
||||
import type {VoiceRegionMetadata, VoiceServerRecord} from '../voice/VoiceModel';
|
||||
|
||||
@@ -55,27 +54,6 @@ interface DisconnectParticipantParams {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface MuteParticipantTrackParams {
|
||||
userId: UserID;
|
||||
guildId?: GuildID;
|
||||
channelId: ChannelID;
|
||||
connectionId: string;
|
||||
regionId: string;
|
||||
serverId: string;
|
||||
trackSid: string;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
interface RevokeParticipantPublishSourceParams {
|
||||
userId: UserID;
|
||||
guildId?: GuildID;
|
||||
channelId: ChannelID;
|
||||
connectionId: string;
|
||||
regionId: string;
|
||||
serverId: string;
|
||||
source: TrackSource;
|
||||
}
|
||||
|
||||
interface ListParticipantsParams {
|
||||
guildId?: GuildID;
|
||||
channelId: ChannelID;
|
||||
@@ -131,10 +109,6 @@ export abstract class ILiveKitService {
|
||||
|
||||
abstract disconnectParticipant(params: DisconnectParticipantParams): Promise<void>;
|
||||
|
||||
abstract muteParticipantTrack(params: MuteParticipantTrackParams): Promise<boolean>;
|
||||
|
||||
abstract revokeParticipantPublishSource(params: RevokeParticipantPublishSourceParams): Promise<boolean>;
|
||||
|
||||
abstract listParticipants(params: ListParticipantsParams): Promise<ListParticipantsResult>;
|
||||
|
||||
abstract listActiveRooms(): Promise<ListActiveRoomsResult>;
|
||||
|
||||
@@ -60,27 +60,6 @@ interface UpdateParticipantPermissionsParams {
|
||||
deaf?: boolean;
|
||||
}
|
||||
|
||||
interface MuteParticipantTrackParams {
|
||||
userId: UserID;
|
||||
guildId?: GuildID;
|
||||
channelId: ChannelID;
|
||||
connectionId: string;
|
||||
regionId: string;
|
||||
serverId: string;
|
||||
trackSid: string;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
interface RevokeParticipantPublishSourceParams {
|
||||
userId: UserID;
|
||||
guildId?: GuildID;
|
||||
channelId: ChannelID;
|
||||
connectionId: string;
|
||||
regionId: string;
|
||||
serverId: string;
|
||||
source: TrackSource;
|
||||
}
|
||||
|
||||
interface ServerClientConfig {
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
@@ -97,26 +76,6 @@ interface LiveKitPublishPermissions {
|
||||
|
||||
export const VOICE_TOKEN_TTL_SECONDS = 60 * 10;
|
||||
|
||||
const ALL_PUBLISH_SOURCES: ReadonlyArray<TrackSource> = [
|
||||
TrackSource.MICROPHONE,
|
||||
TrackSource.CAMERA,
|
||||
TrackSource.SCREEN_SHARE,
|
||||
TrackSource.SCREEN_SHARE_AUDIO,
|
||||
];
|
||||
|
||||
interface LiveKitPublishGrant {
|
||||
canPublish: boolean;
|
||||
canPublishSources: Array<TrackSource>;
|
||||
}
|
||||
|
||||
export function computeRevokedPublishGrant(current: LiveKitPublishGrant, source: TrackSource): LiveKitPublishGrant {
|
||||
const allowed = current.canPublishSources.length > 0 ? current.canPublishSources : ALL_PUBLISH_SOURCES;
|
||||
const revoked =
|
||||
source === TrackSource.SCREEN_SHARE ? [TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO] : [source];
|
||||
const canPublishSources = allowed.filter((allowedSource) => !revoked.includes(allowedSource));
|
||||
return {canPublish: current.canPublish && canPublishSources.length > 0, canPublishSources};
|
||||
}
|
||||
|
||||
export function computeLiveKitPublishSources(permissions: LiveKitPublishPermissions): Array<TrackSource> {
|
||||
const sources: Array<TrackSource> = [];
|
||||
if (permissions.canSpeak) {
|
||||
@@ -341,72 +300,6 @@ export class LiveKitService extends ILiveKitService {
|
||||
}
|
||||
}
|
||||
|
||||
async muteParticipantTrack(params: MuteParticipantTrackParams): Promise<boolean> {
|
||||
const {userId, guildId, channelId, connectionId, regionId, serverId, trackSid, muted} = params;
|
||||
const roomName = this.getRoomName(guildId, channelId);
|
||||
const participantIdentity = this.getParticipantIdentity(userId, connectionId);
|
||||
const server = this.tryResolveServerClient(regionId, serverId);
|
||||
if (server === null) {
|
||||
Logger.debug(
|
||||
{regionId, serverId, participantIdentity, roomName, trackSid},
|
||||
'LiveKit track mute skipped, pinned server no longer exists in topology',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await server.roomServiceClient.mutePublishedTrack(roomName, participantIdentity, trackSid, muted);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (LiveKitService.isHttp404(error)) {
|
||||
Logger.debug({participantIdentity, roomName, trackSid}, 'LiveKit track no longer published, nothing to mute');
|
||||
return false;
|
||||
}
|
||||
Logger.error({error, participantIdentity, roomName, trackSid, muted}, 'Error muting LiveKit published track');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async revokeParticipantPublishSource(params: RevokeParticipantPublishSourceParams): Promise<boolean> {
|
||||
const {userId, guildId, channelId, connectionId, regionId, serverId, source} = params;
|
||||
const roomName = this.getRoomName(guildId, channelId);
|
||||
const participantIdentity = this.getParticipantIdentity(userId, connectionId);
|
||||
const server = this.tryResolveServerClient(regionId, serverId);
|
||||
if (server === null) {
|
||||
Logger.debug(
|
||||
{regionId, serverId, participantIdentity, roomName, source},
|
||||
'LiveKit publish source revoke skipped, pinned server no longer exists in topology',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const participants = await server.roomServiceClient.listParticipants(roomName);
|
||||
const participant = participants.find((p) => p.identity === participantIdentity);
|
||||
if (!participant?.permission) {
|
||||
Logger.debug(
|
||||
{participantIdentity, roomName, source},
|
||||
'LiveKit participant no longer in room, nothing to revoke',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const grant = computeRevokedPublishGrant(participant.permission, source);
|
||||
await server.roomServiceClient.updateParticipant(roomName, participantIdentity, undefined, {
|
||||
...participant.permission,
|
||||
...grant,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (LiveKitService.isHttp404(error)) {
|
||||
Logger.debug(
|
||||
{participantIdentity, roomName, source},
|
||||
'LiveKit participant no longer in room, nothing to revoke',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Logger.error({error, participantIdentity, roomName, source}, 'Error revoking LiveKit publish source');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async listParticipants(params: {
|
||||
guildId?: GuildID;
|
||||
channelId: ChannelID;
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {WebhookEvent} from 'livekit-server-sdk';
|
||||
import {TrackSource, WebhookReceiver} from 'livekit-server-sdk';
|
||||
import {WebhookReceiver} from 'livekit-server-sdk';
|
||||
import type {ChannelID, GuildID} from '../BrandedTypes';
|
||||
import {Config} from '../Config';
|
||||
import {Logger} from '../Logger';
|
||||
import type {LimitConfigService} from '../limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '../limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '../limits/LimitMatchContextBuilder';
|
||||
import type {IUserRepository} from '../user/IUserRepository';
|
||||
import type {VoiceTopology} from '../voice/VoiceTopology';
|
||||
import type {IGatewayService} from './IGatewayService';
|
||||
import type {ILiveKitService} from './ILiveKitService';
|
||||
import type {IVoiceRoomStore} from './IVoiceRoomStore';
|
||||
import {isDMRoom, parseParticipantIdentity, parseParticipantMetadataWithRaw, parseRoomName} from './VoiceRoomContext';
|
||||
import {isDMRoom, parseParticipantMetadataWithRaw, parseRoomName} from './VoiceRoomContext';
|
||||
|
||||
interface VoiceWebhookParticipantContext {
|
||||
readonly type: 'dm' | 'guild';
|
||||
@@ -34,10 +29,8 @@ export class LiveKitWebhookService {
|
||||
constructor(
|
||||
private voiceRoomStore: IVoiceRoomStore,
|
||||
private gatewayService: IGatewayService,
|
||||
private userRepository: IUserRepository,
|
||||
private liveKitService: ILiveKitService,
|
||||
private voiceTopology: VoiceTopology,
|
||||
private limitConfigService: LimitConfigService,
|
||||
) {
|
||||
this.receivers = new Map();
|
||||
this.serverMap = new Map();
|
||||
@@ -466,176 +459,6 @@ export class LiveKitWebhookService {
|
||||
}
|
||||
}
|
||||
|
||||
async handleTrackPublished(event: WebhookEvent, apiKey: string): Promise<void> {
|
||||
if (event.event !== 'track_published') {
|
||||
return;
|
||||
}
|
||||
const {room, participant, track} = event;
|
||||
if (!room || !participant || !track) {
|
||||
Logger.debug('Track published without required data, skipping');
|
||||
return;
|
||||
}
|
||||
Logger.debug(
|
||||
{
|
||||
apiKey,
|
||||
roomName: room.name,
|
||||
participantIdentity: participant.identity,
|
||||
trackType: track.type,
|
||||
width: track.width,
|
||||
height: track.height,
|
||||
},
|
||||
'Processing LiveKit track_published event',
|
||||
);
|
||||
if (track.type !== 1) {
|
||||
return;
|
||||
}
|
||||
if (track.source !== TrackSource.CAMERA && track.source !== TrackSource.SCREEN_SHARE) {
|
||||
return;
|
||||
}
|
||||
const trackSourceLabel = track.source === TrackSource.SCREEN_SHARE ? 'screen_share' : 'camera';
|
||||
try {
|
||||
const identity = parseParticipantIdentity(participant.identity);
|
||||
if (!identity) {
|
||||
Logger.warn({identity: participant.identity}, 'Unexpected participant identity format');
|
||||
return;
|
||||
}
|
||||
const {userId, connectionId} = identity;
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
Logger.warn({userId: userId.toString()}, 'User not found for track_published event');
|
||||
return;
|
||||
}
|
||||
if (Config.instance.selfHosted) {
|
||||
return;
|
||||
}
|
||||
const ctx = createLimitMatchContext({user});
|
||||
const hasHigherQuality = resolveLimitSafe(
|
||||
this.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'feature_higher_video_quality',
|
||||
0,
|
||||
);
|
||||
const canUseHigherQuality = hasHigherQuality > 0 && !user.isBot;
|
||||
if (canUseHigherQuality) {
|
||||
return;
|
||||
}
|
||||
const FREE_MAX_WIDTH = 1280;
|
||||
const FREE_MAX_HEIGHT = 720;
|
||||
const exceedsResolution = track.width > FREE_MAX_WIDTH || track.height > FREE_MAX_HEIGHT;
|
||||
if (!exceedsResolution) {
|
||||
return;
|
||||
}
|
||||
Logger.warn(
|
||||
{
|
||||
userId: userId.toString(),
|
||||
isBot: user.isBot,
|
||||
width: track.width,
|
||||
height: track.height,
|
||||
trackSource: trackSourceLabel,
|
||||
},
|
||||
'User without higher video quality entitlement published video exceeding free tier limits - muting track and revoking source',
|
||||
);
|
||||
if (!track.sid) {
|
||||
Logger.warn(
|
||||
{userId: userId.toString(), roomName: room.name, trackSource: trackSourceLabel},
|
||||
'Track published without a sid, cannot enforce free tier video limits',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const roomContext = parseRoomName(room.name);
|
||||
if (!roomContext) {
|
||||
Logger.warn({roomName: room.name}, 'Unknown room name format, cannot enforce free tier video limits');
|
||||
return;
|
||||
}
|
||||
let regionId: string | undefined;
|
||||
let serverId: string | undefined;
|
||||
if (participant.metadata) {
|
||||
const parsed = parseParticipantMetadataWithRaw(participant.metadata);
|
||||
if (parsed) {
|
||||
regionId = parsed.raw.region_id;
|
||||
serverId = parsed.raw.server_id;
|
||||
}
|
||||
}
|
||||
if (!regionId || !serverId) {
|
||||
const serverInfo = this.serverMap.get(apiKey);
|
||||
if (serverInfo) {
|
||||
regionId = serverInfo.regionId;
|
||||
serverId = serverInfo.serverId;
|
||||
}
|
||||
}
|
||||
if (!regionId || !serverId) {
|
||||
const guildId = isDMRoom(roomContext) ? undefined : roomContext.guildId;
|
||||
const pinnedServer = await this.voiceRoomStore.getPinnedRoomServer(guildId, roomContext.channelId);
|
||||
if (pinnedServer) {
|
||||
regionId = pinnedServer.regionId;
|
||||
serverId = pinnedServer.serverId;
|
||||
}
|
||||
}
|
||||
if (!regionId || !serverId) {
|
||||
Logger.warn(
|
||||
{participantId: participant.identity, roomName: room.name, apiKey},
|
||||
'Missing region or server info, cannot enforce free tier video limits',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const guildId = isDMRoom(roomContext) ? undefined : roomContext.guildId;
|
||||
Logger.debug(
|
||||
{
|
||||
userId: userId.toString(),
|
||||
type: roomContext.type,
|
||||
guildId: guildId?.toString(),
|
||||
channelId: roomContext.channelId.toString(),
|
||||
regionId,
|
||||
serverId,
|
||||
isBot: user.isBot,
|
||||
width: track.width,
|
||||
height: track.height,
|
||||
trackSource: trackSourceLabel,
|
||||
},
|
||||
'Muting oversized track and revoking its publish source for user without higher video quality entitlement',
|
||||
);
|
||||
const muted = await this.liveKitService.muteParticipantTrack({
|
||||
userId,
|
||||
guildId,
|
||||
channelId: roomContext.channelId,
|
||||
connectionId,
|
||||
regionId,
|
||||
serverId,
|
||||
trackSid: track.sid,
|
||||
muted: true,
|
||||
});
|
||||
const revoked = await this.liveKitService.revokeParticipantPublishSource({
|
||||
userId,
|
||||
guildId,
|
||||
channelId: roomContext.channelId,
|
||||
connectionId,
|
||||
regionId,
|
||||
serverId,
|
||||
source: track.source,
|
||||
});
|
||||
Logger.info(
|
||||
{
|
||||
userId: userId.toString(),
|
||||
type: roomContext.type,
|
||||
guildId: guildId?.toString(),
|
||||
channelId: roomContext.channelId.toString(),
|
||||
isBot: user.isBot,
|
||||
width: track.width,
|
||||
height: track.height,
|
||||
trackSource: trackSourceLabel,
|
||||
trackSid: track.sid,
|
||||
muted,
|
||||
revoked,
|
||||
},
|
||||
muted || revoked
|
||||
? 'Enforced free tier video limits on user without higher video quality entitlement'
|
||||
: 'Failed to enforce free tier video limits on user without higher video quality entitlement',
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Error processing track_published event');
|
||||
}
|
||||
}
|
||||
|
||||
async processEvent(data: {event: WebhookEvent; apiKey: string}): Promise<void> {
|
||||
const {event, apiKey} = data;
|
||||
Logger.debug({event: event.event, apiKey}, 'Dispatching LiveKit webhook event');
|
||||
@@ -650,9 +473,6 @@ export class LiveKitWebhookService {
|
||||
case 'room_finished':
|
||||
await this.handleRoomFinished(event, apiKey);
|
||||
break;
|
||||
case 'track_published':
|
||||
await this.handleTrackPublished(event, apiKey);
|
||||
break;
|
||||
default:
|
||||
Logger.debug({event: event.event}, 'Ignoring LiveKit webhook event');
|
||||
}
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {WebhookEvent} from 'livekit-server-sdk';
|
||||
import {AccessToken, TrackSource} from 'livekit-server-sdk';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {getConfig} from '../../Config';
|
||||
import type {LimitConfigService} from '../../limits/LimitConfigService';
|
||||
import type {User} from '../../models/User';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import type {VoiceTopology} from '../../voice/VoiceTopology';
|
||||
import type {IGatewayService} from '../IGatewayService';
|
||||
import type {ILiveKitService} from '../ILiveKitService';
|
||||
import type {IVoiceRoomStore} from '../IVoiceRoomStore';
|
||||
import {computeLiveKitPublishSources, computeRevokedPublishGrant, VOICE_TOKEN_TTL_SECONDS} from '../LiveKitService';
|
||||
import {LiveKitWebhookService} from '../LiveKitWebhookService';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {computeLiveKitPublishSources, VOICE_TOKEN_TTL_SECONDS} from '../LiveKitService';
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> {
|
||||
const [, payload] = token.split('.');
|
||||
@@ -55,45 +44,6 @@ describe('LiveKitService publish permissions', () => {
|
||||
canPublishSources: ['microphone', 'screen_share', 'screen_share_audio'],
|
||||
});
|
||||
});
|
||||
it('revokes only the offending source and keeps the rest of the grant', () => {
|
||||
expect(
|
||||
computeRevokedPublishGrant(
|
||||
{
|
||||
canPublish: true,
|
||||
canPublishSources: [
|
||||
TrackSource.MICROPHONE,
|
||||
TrackSource.CAMERA,
|
||||
TrackSource.SCREEN_SHARE,
|
||||
TrackSource.SCREEN_SHARE_AUDIO,
|
||||
],
|
||||
},
|
||||
TrackSource.CAMERA,
|
||||
),
|
||||
).toEqual({
|
||||
canPublish: true,
|
||||
canPublishSources: [TrackSource.MICROPHONE, TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO],
|
||||
});
|
||||
});
|
||||
it('revokes screen share audio together with screen share video', () => {
|
||||
expect(
|
||||
computeRevokedPublishGrant(
|
||||
{
|
||||
canPublish: true,
|
||||
canPublishSources: [TrackSource.MICROPHONE, TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO],
|
||||
},
|
||||
TrackSource.SCREEN_SHARE,
|
||||
),
|
||||
).toEqual({canPublish: true, canPublishSources: [TrackSource.MICROPHONE]});
|
||||
});
|
||||
it('treats an empty source list as every source and never leaves it empty', () => {
|
||||
expect(computeRevokedPublishGrant({canPublish: true, canPublishSources: []}, TrackSource.CAMERA)).toEqual({
|
||||
canPublish: true,
|
||||
canPublishSources: [TrackSource.MICROPHONE, TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO],
|
||||
});
|
||||
expect(
|
||||
computeRevokedPublishGrant({canPublish: true, canPublishSources: [TrackSource.CAMERA]}, TrackSource.CAMERA),
|
||||
).toEqual({canPublish: false, canPublishSources: []});
|
||||
});
|
||||
it('bounds voice token lifetime to the configured TTL', async () => {
|
||||
const token = new AccessToken('test-key', 'test-secret', {
|
||||
identity: 'user_1_conn',
|
||||
@@ -107,108 +57,3 @@ describe('LiveKitService publish permissions', () => {
|
||||
expect(exp - nowSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
function createFreeUser(): User {
|
||||
return {
|
||||
id: createUserID(1n),
|
||||
isBot: false,
|
||||
premiumType: null,
|
||||
premiumUntil: null,
|
||||
premiumGiftExtensionEndsAt: null,
|
||||
premiumWillCancel: false,
|
||||
premiumGraceEndsAt: null,
|
||||
flags: 0n,
|
||||
premiumFlags: 0,
|
||||
traits: new Set<string>(),
|
||||
} as unknown as User;
|
||||
}
|
||||
|
||||
function createTrackPublishedEvent(width: number, height: number): WebhookEvent {
|
||||
return {
|
||||
event: 'track_published',
|
||||
room: {name: 'guild_2_channel_3'},
|
||||
participant: {identity: 'user_1_conn'},
|
||||
track: {type: 1, source: TrackSource.CAMERA, sid: 'TR_oversized', width, height},
|
||||
} as unknown as WebhookEvent;
|
||||
}
|
||||
|
||||
function createWebhookHarness() {
|
||||
const muteParticipantTrack = vi.fn().mockResolvedValue(true);
|
||||
const revokeParticipantPublishSource = vi.fn().mockResolvedValue(true);
|
||||
const disconnectParticipant = vi.fn().mockResolvedValue(undefined);
|
||||
const disconnectVoiceUserIfInChannel = vi.fn().mockResolvedValue(undefined);
|
||||
const service = new LiveKitWebhookService(
|
||||
{
|
||||
getPinnedRoomServer: vi.fn().mockResolvedValue({regionId: 'region-1', serverId: 'region-1-server-1'}),
|
||||
} as unknown as IVoiceRoomStore,
|
||||
{disconnectVoiceUserIfInChannel} as unknown as IGatewayService,
|
||||
{findUnique: vi.fn().mockResolvedValue(createFreeUser())} as unknown as IUserRepository,
|
||||
{muteParticipantTrack, revokeParticipantPublishSource, disconnectParticipant} as unknown as ILiveKitService,
|
||||
{
|
||||
getAllRegions: () => [],
|
||||
getServersForRegion: () => [],
|
||||
registerSubscriber: () => {},
|
||||
} as unknown as VoiceTopology,
|
||||
{getConfigSnapshot: () => null} as unknown as LimitConfigService,
|
||||
);
|
||||
return {
|
||||
service,
|
||||
muteParticipantTrack,
|
||||
revokeParticipantPublishSource,
|
||||
disconnectParticipant,
|
||||
disconnectVoiceUserIfInChannel,
|
||||
};
|
||||
}
|
||||
|
||||
describe('LiveKit free tier video resolution enforcement', () => {
|
||||
it('mutes the oversized track and revokes its source instead of ending the call', async () => {
|
||||
const {
|
||||
service,
|
||||
muteParticipantTrack,
|
||||
revokeParticipantPublishSource,
|
||||
disconnectParticipant,
|
||||
disconnectVoiceUserIfInChannel,
|
||||
} = createWebhookHarness();
|
||||
|
||||
await service.handleTrackPublished(createTrackPublishedEvent(1920, 1080), 'api-key');
|
||||
|
||||
expect(muteParticipantTrack).toHaveBeenCalledTimes(1);
|
||||
expect(muteParticipantTrack).toHaveBeenCalledWith(
|
||||
expect.objectContaining({trackSid: 'TR_oversized', muted: true, regionId: 'region-1'}),
|
||||
);
|
||||
expect(revokeParticipantPublishSource).toHaveBeenCalledTimes(1);
|
||||
expect(revokeParticipantPublishSource).toHaveBeenCalledWith(
|
||||
expect.objectContaining({source: TrackSource.CAMERA, connectionId: 'conn', regionId: 'region-1'}),
|
||||
);
|
||||
expect(disconnectParticipant).not.toHaveBeenCalled();
|
||||
expect(disconnectVoiceUserIfInChannel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves tracks within the free tier limits alone', async () => {
|
||||
const {service, muteParticipantTrack, revokeParticipantPublishSource, disconnectParticipant} =
|
||||
createWebhookHarness();
|
||||
|
||||
await service.handleTrackPublished(createTrackPublishedEvent(1280, 720), 'api-key');
|
||||
|
||||
expect(muteParticipantTrack).not.toHaveBeenCalled();
|
||||
expect(revokeParticipantPublishSource).not.toHaveBeenCalled();
|
||||
expect(disconnectParticipant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not enforce resolution limits on self-hosted instances', async () => {
|
||||
const {service, muteParticipantTrack, revokeParticipantPublishSource, disconnectParticipant} =
|
||||
createWebhookHarness();
|
||||
const config = getConfig();
|
||||
const originalSelfHosted = config.instance.selfHosted;
|
||||
config.instance.selfHosted = true;
|
||||
try {
|
||||
await service.handleTrackPublished(createTrackPublishedEvent(3840, 2160), 'api-key');
|
||||
} finally {
|
||||
config.instance.selfHosted = originalSelfHosted;
|
||||
}
|
||||
|
||||
expect(muteParticipantTrack).not.toHaveBeenCalled();
|
||||
expect(revokeParticipantPublishSource).not.toHaveBeenCalled();
|
||||
expect(disconnectParticipant).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,10 +331,8 @@ function getLiveKitWebhookService(): LiveKitWebhookService | null {
|
||||
_liveKitWebhookService = new LiveKitWebhookService(
|
||||
voiceRoomStore,
|
||||
getGatewayService(),
|
||||
getUserRepository(),
|
||||
liveKitService,
|
||||
voiceTopology,
|
||||
getLimitConfigService(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user