refactor(api): remove premium-based voice track muting (#2536)

This commit is contained in:
Hampus
2026-09-06 18:37:01 +02:00
committed by GitHub
parent e805a3797f
commit a8d6e5ab73
6 changed files with 4 additions and 504 deletions
@@ -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(),
);
}
}