refactor(voice): remove heartbeat and debug logging sessions (#2121)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-30 21:08:46 +02:00
1 parent 36b85512c6
commit b4d9cdc584
33 files changed
+566 -3783

No files matched your search

@@ -1,93 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Readable} from 'node:stream';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {
VoiceDiagnosticsObjectListResponse,
VoiceDiagnosticsQueryRequest,
} from '@fluxer/schema/src/domains/admin/AdminVoiceSchemas';
import {VOICE_DIAGNOSTICS_BUCKET, VoiceDiagnosticsService} from '../../channel/services/VoiceDiagnosticsService';
import {requireAdminACL} from '../../middleware/AdminMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
export function VoiceDiagnosticsAdminController(app: HonoApp) {
app.get(
'/admin/voice/diagnostics/objects',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.VOICE_DIAGNOSTICS_VIEW),
Validator('query', VoiceDiagnosticsQueryRequest),
OpenAPI({
operationId: 'list_voice_diagnostics_objects',
summary: 'List voice diagnostics objects',
responseSchema: VoiceDiagnosticsObjectListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Lists raw voice diagnostics NDJSON S3 objects for a channel and time range. Requires VOICE_DIAGNOSTICS_VIEW permission.',
}),
async (ctx) => {
const query = ctx.req.valid('query');
const service = new VoiceDiagnosticsService(
ctx.get('cacheService'),
ctx.get('channelService'),
ctx.get('gatewayService'),
ctx.get('storageService'),
);
const objects = await service.listObjects({
channelId: query.channel_id,
startMs: query.start_ms,
endMs: query.end_ms,
sessionId: query.session_id,
limitObjects: query.limit_objects,
});
return ctx.json({bucket: VOICE_DIAGNOSTICS_BUCKET, objects});
},
);
app.get(
'/admin/voice/diagnostics/raw',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.VOICE_DIAGNOSTICS_VIEW),
Validator('query', VoiceDiagnosticsQueryRequest),
OpenAPI({
operationId: 'stream_voice_diagnostics_raw',
summary: 'Stream raw voice diagnostics',
responseSchema: null,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Streams matching voice diagnostics NDJSON objects for local processing. Requires VOICE_DIAGNOSTICS_VIEW permission.',
}),
async (ctx) => {
const query = ctx.req.valid('query');
const service = new VoiceDiagnosticsService(
ctx.get('cacheService'),
ctx.get('channelService'),
ctx.get('gatewayService'),
ctx.get('storageService'),
);
const objects = await service.listObjects({
channelId: query.channel_id,
startMs: query.start_ms,
endMs: query.end_ms,
sessionId: query.session_id,
limitObjects: query.limit_objects,
});
const stream = service.createRawObjectStream(objects);
return new Response(Readable.toWeb(stream) as ReadableStream, {
status: 200,
headers: {
'Content-Type': 'application/x-ndjson',
'Cache-Control': 'no-store, private',
'X-Fluxer-Diagnostics-Bucket': VOICE_DIAGNOSTICS_BUCKET,
'X-Fluxer-Diagnostics-Object-Count': objects.length.toString(),
},
});
},
);
}
@@ -23,7 +23,6 @@ import {SystemAdminController} from './SystemAdminController';
import {SystemDmAdminController} from './SystemDmAdminController';
import {UserAdminController} from './UserAdminController';
import {VoiceAdminController} from './VoiceAdminController';
import {VoiceDiagnosticsAdminController} from './VoiceDiagnosticsAdminController';
export function registerAdminControllers(app: HonoApp) {
AdminApiKeyAdminController(app);
@@ -42,7 +41,6 @@ export function registerAdminControllers(app: HonoApp) {
ReportAdminController(app);
BillingAdminController(app);
VoiceAdminController(app);
VoiceDiagnosticsAdminController(app);
GatewayAdminController(app);
SearchAdminController(app);
DiscoveryAdminController(app);
@@ -1,110 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {
VoiceDebugLoggingEventsBodySchema,
VoiceDebugLoggingToggleBodySchema,
} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
import {
VoiceDebugLoggingEventsResponse,
VoiceDebugLoggingStatusResponse,
} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {ChannelIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import type {Context} from 'hono';
import {createChannelID} from '../../BrandedTypes';
import {DefaultUserOnly, LoginRequired} from '../../middleware/AuthMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp, HonoEnv} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
import {VoiceDiagnosticsService} from '../services/VoiceDiagnosticsService';
function makeVoiceDiagnosticsService(ctx: Context<HonoEnv>): VoiceDiagnosticsService {
return new VoiceDiagnosticsService(
ctx.get('cacheService'),
ctx.get('channelService'),
ctx.get('gatewayService'),
ctx.get('storageService'),
);
}
export function VoiceDiagnosticsController(app: HonoApp) {
app.get(
'/channels/:channel_id/voice-debug-logging/session',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_VOICE_DEBUG_LOGGING_STATUS),
LoginRequired,
DefaultUserOnly,
Validator('param', ChannelIdParam),
OpenAPI({
operationId: 'get_voice_debug_logging_status',
summary: 'Get voice debug logging status',
description:
'Returns whether staff-enabled voice debug logging is active for this channel. Clients poll this while connected to decide whether to upload diagnostics.',
responseSchema: VoiceDebugLoggingStatusResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const service = makeVoiceDiagnosticsService(ctx);
return ctx.json(await service.getStatus({userId, channelId}));
},
);
app.put(
'/channels/:channel_id/voice-debug-logging/session',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_VOICE_DEBUG_LOGGING_TOGGLE),
LoginRequired,
DefaultUserOnly,
Validator('param', ChannelIdParam),
Validator('json', VoiceDebugLoggingToggleBodySchema),
OpenAPI({
operationId: 'set_voice_debug_logging_status',
summary: 'Toggle voice debug logging',
description:
'Allows staff to start or stop a channel-scoped voice debug logging session. Non-staff users cannot activate or stop sessions.',
responseSchema: VoiceDebugLoggingStatusResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const user = ctx.get('user');
if ((user.flags & UserFlags.STAFF) === 0n) {
throw new MissingPermissionsError();
}
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const {enabled, duration_ms} = ctx.req.valid('json');
const service = makeVoiceDiagnosticsService(ctx);
return ctx.json(await service.setSession({userId: user.id, channelId, enabled, durationMs: duration_ms}));
},
);
app.post(
'/channels/:channel_id/voice-debug-logging/events',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_VOICE_DEBUG_LOGGING_EVENTS),
LoginRequired,
DefaultUserOnly,
Validator('param', ChannelIdParam),
Validator('json', VoiceDebugLoggingEventsBodySchema),
OpenAPI({
operationId: 'upload_voice_debug_logging_events',
summary: 'Upload voice debug logging events',
description:
'Uploads a small batch of client voice diagnostics events for an active staff-enabled debug logging session.',
responseSchema: VoiceDebugLoggingEventsResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const body = ctx.req.valid('json');
const service = makeVoiceDiagnosticsService(ctx);
return ctx.json(await service.ingestEvents({userId, channelId, body}));
},
);
}
@@ -1,84 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {VoicePresenceHeartbeatBodySchema} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
import {
VoicePresenceHeartbeatEndResponse,
VoicePresenceHeartbeatResponse,
} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {ChannelIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {createChannelID} from '../../BrandedTypes';
import {DefaultUserOnly, LoginRequired} from '../../middleware/AuthMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
import {VoicePresenceHeartbeatStore} from '../../voice/VoicePresenceHeartbeatStore';
export function VoicePresenceController(app: HonoApp) {
app.post(
'/channels/:channel_id/voice-presence/heartbeat',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_VOICE_PRESENCE_HEARTBEAT),
LoginRequired,
DefaultUserOnly,
Validator('param', ChannelIdParam),
Validator('json', VoicePresenceHeartbeatBodySchema),
OpenAPI({
operationId: 'heartbeat_voice_presence',
summary: 'Heartbeat voice presence',
description:
'Refreshes the current user voice presence marker for v2 voice reconciliation. Clients call this while connected to voice.',
responseSchema: VoicePresenceHeartbeatResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const body = ctx.req.valid('json');
const store = new VoicePresenceHeartbeatStore(ctx.get('apiContext').services.kv);
const heartbeat = await store.recordHeartbeat({
channelId,
userId,
connectionId: body.connection_id,
});
return ctx.json({
ok: true,
heartbeat_interval_ms: heartbeat.heartbeatIntervalMs,
heartbeat_ttl_ms: heartbeat.heartbeatTtlMs,
expires_at_ms: heartbeat.expiresAtMs,
});
},
);
app.delete(
'/channels/:channel_id/voice-presence/heartbeat',
RateLimitMiddleware(RateLimitConfigs.CHANNEL_VOICE_PRESENCE_HEARTBEAT),
LoginRequired,
DefaultUserOnly,
Validator('param', ChannelIdParam),
Validator('json', VoicePresenceHeartbeatBodySchema),
OpenAPI({
operationId: 'end_voice_presence_heartbeat',
summary: 'End voice presence heartbeat',
description:
'Clears the current user active v2 voice presence marker for a voice connection while preserving v2 enrollment for fast reconciliation.',
responseSchema: VoicePresenceHeartbeatEndResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: 'Channels',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const body = ctx.req.valid('json');
const store = new VoicePresenceHeartbeatStore(ctx.get('apiContext').services.kv);
await store.markHeartbeatEnded({
channelId,
userId,
connectionId: body.connection_id,
});
return ctx.json({ok: true});
},
);
}
@@ -7,8 +7,6 @@ import {MessageController} from './MessageController';
import {MessageInteractionController} from './MessageInteractionController';
import {ScheduledMessageController} from './ScheduledMessageController';
import {StreamController} from './StreamController';
import {VoiceDiagnosticsController} from './VoiceDiagnosticsController';
import {VoicePresenceController} from './VoicePresenceController';
export function registerChannelControllers(app: HonoApp) {
ChannelController(app);
@@ -17,6 +15,4 @@ export function registerChannelControllers(app: HonoApp) {
ScheduledMessageController(app);
CallController(app);
StreamController(app);
VoiceDiagnosticsController(app);
VoicePresenceController(app);
}
@@ -1,336 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Readable} from 'node:stream';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import type {
VoiceDebugLoggingEventSchema,
VoiceDebugLoggingEventsBodySchema,
} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
import type {
VoiceDebugLoggingEventsResponse,
VoiceDebugLoggingStatusResponse,
} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import type {ChannelID, UserID} from '../../BrandedTypes';
import type {IGatewayService} from '../../infrastructure/IGatewayService';
import type {IStorageService} from '../../infrastructure/IStorageService';
import type {ChannelService} from './ChannelService';
export const VOICE_DIAGNOSTICS_BUCKET = 'fluxer-voice-diagnostics';
const VOICE_DEBUG_LOGGING_POLL_INTERVAL_MS = 10000;
const VOICE_DEBUG_LOGGING_UPLOAD_INTERVAL_MS = 2000;
const DEFAULT_SESSION_DURATION_MS = 60 * 60 * 1000;
const MAX_SESSION_DURATION_MS = 4 * 60 * 60 * 1000;
const NDJSON_CONTENT_TYPE = 'application/x-ndjson';
const TEXT_ENCODER = new TextEncoder();
interface ActiveVoiceDebugLoggingSession {
session_id: string;
channel_id: string;
activated_by_user_id: string;
started_at_ms: number;
expires_at_ms: number;
}
interface VoiceDiagnosticsObject {
key: string;
session_id: string;
start_ns: string;
end_ns: string;
last_modified: string | null;
}
function sessionCacheKey(channelId: ChannelID): string {
return `voice_debug_logging:channel:${channelId.toString()}`;
}
function createId(prefix: string): string {
const randomUUID = globalThis.crypto?.randomUUID?.bind(globalThis.crypto);
if (randomUUID) return `${prefix}_${randomUUID()}`;
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
}
function datePartFromMs(timestampMs: number): string {
return new Date(timestampMs).toISOString().slice(0, 10);
}
function sanitizeKeyPart(value: string): string {
return value.replace(/[^A-Za-z0-9_.:-]/g, '_').slice(0, 256) || 'unknown';
}
function minMaxEventTimestampNs(events: ReadonlyArray<VoiceDebugLoggingEventSchema>): {
startNs: bigint;
endNs: bigint;
} {
let startNs: bigint | null = null;
let endNs: bigint | null = null;
for (const event of events) {
const timestampNs = BigInt(event.timestamp_ns);
if (startNs === null || timestampNs < startNs) startNs = timestampNs;
if (endNs === null || timestampNs > endNs) endNs = timestampNs;
}
return {startNs: startNs ?? 0n, endNs: endNs ?? 0n};
}
function buildDiagnosticsKey(params: {
channelId: ChannelID;
sessionId: string;
userId: UserID;
connectionId?: string;
participantIdentity?: string;
startNs: bigint;
endNs: bigint;
}): string {
const timestampMs = Number(params.startNs / 1000000n);
const date = datePartFromMs(timestampMs > 0 ? timestampMs : Date.now());
return [
`channel_id=${params.channelId.toString()}`,
`date=${date}`,
`session_id=${sanitizeKeyPart(params.sessionId)}`,
`start_ns=${params.startNs.toString()}_end_ns=${params.endNs.toString()}`,
`participant=${sanitizeKeyPart(params.userId.toString())}`,
`connection=${sanitizeKeyPart(params.connectionId ?? 'unknown')}`,
`identity=${sanitizeKeyPart(params.participantIdentity ?? 'unknown')}`,
`batch=${createId('batch')}.ndjson`,
].join('/');
}
function parseDiagnosticsKey(key: string): VoiceDiagnosticsObject | null {
const match = key.match(/^channel_id=[^/]+\/date=[^/]+\/session_id=([^/]+)\/start_ns=([0-9]+)_end_ns=([0-9]+)\//);
if (!match) return null;
const [, sessionId, startNs, endNs] = match;
if (!sessionId || !startNs || !endNs) return null;
return {
key,
session_id: sessionId,
start_ns: startNs,
end_ns: endNs,
last_modified: null,
};
}
function buildDatePrefixes(params: {
channelId: string;
startMs: number;
endMs: number;
sessionId?: string;
}): Array<string> {
const prefixes: Array<string> = [];
const cursor = new Date(params.startMs);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(params.endMs);
end.setUTCHours(0, 0, 0, 0);
while (cursor.getTime() <= end.getTime()) {
const base = `channel_id=${params.channelId}/date=${datePartFromMs(cursor.getTime())}/`;
prefixes.push(params.sessionId ? `${base}session_id=${sanitizeKeyPart(params.sessionId)}/` : base);
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return prefixes;
}
async function assertVoiceChannelAccess(params: {
channelService: ChannelService;
gatewayService: IGatewayService;
userId: UserID;
channelId: ChannelID;
}): Promise<void> {
const channel = await params.channelService.channelData.operations.getChannel({
userId: params.userId,
channelId: params.channelId,
});
if (!channel.guildId) return;
const hasConnect = await params.gatewayService.checkPermission({
guildId: channel.guildId,
channelId: params.channelId,
userId: params.userId,
permission: Permissions.CONNECT,
});
if (!hasConnect) {
throw new MissingPermissionsError();
}
}
export class VoiceDiagnosticsService {
constructor(
private readonly cacheService: ICacheService,
private readonly channelService: ChannelService,
private readonly gatewayService: IGatewayService,
private readonly storageService: IStorageService,
) {}
private async getActiveSession(channelId: ChannelID): Promise<ActiveVoiceDebugLoggingSession | null> {
const session = await this.cacheService.get<ActiveVoiceDebugLoggingSession>(sessionCacheKey(channelId));
if (!session) return null;
if (session.expires_at_ms <= Date.now()) {
await this.cacheService.delete(sessionCacheKey(channelId));
return null;
}
return session;
}
private statusFromSession(session: ActiveVoiceDebugLoggingSession | null): VoiceDebugLoggingStatusResponse {
return {
active: session !== null,
session_id: session?.session_id ?? null,
activated_by_user_id: session?.activated_by_user_id ?? null,
started_at_ms: session?.started_at_ms ?? null,
expires_at_ms: session?.expires_at_ms ?? null,
poll_interval_ms: VOICE_DEBUG_LOGGING_POLL_INTERVAL_MS,
upload_interval_ms: VOICE_DEBUG_LOGGING_UPLOAD_INTERVAL_MS,
};
}
async getStatus(params: {userId: UserID; channelId: ChannelID}): Promise<VoiceDebugLoggingStatusResponse> {
await assertVoiceChannelAccess({
channelService: this.channelService,
gatewayService: this.gatewayService,
userId: params.userId,
channelId: params.channelId,
});
return this.statusFromSession(await this.getActiveSession(params.channelId));
}
async setSession(params: {
userId: UserID;
channelId: ChannelID;
enabled: boolean;
durationMs?: number;
}): Promise<VoiceDebugLoggingStatusResponse> {
await assertVoiceChannelAccess({
channelService: this.channelService,
gatewayService: this.gatewayService,
userId: params.userId,
channelId: params.channelId,
});
const key = sessionCacheKey(params.channelId);
if (!params.enabled) {
await this.cacheService.delete(key);
return this.statusFromSession(null);
}
const now = Date.now();
const durationMs = Math.min(params.durationMs ?? DEFAULT_SESSION_DURATION_MS, MAX_SESSION_DURATION_MS);
const session: ActiveVoiceDebugLoggingSession = {
session_id: createId('voice_debug'),
channel_id: params.channelId.toString(),
activated_by_user_id: params.userId.toString(),
started_at_ms: now,
expires_at_ms: now + durationMs,
};
await this.cacheService.set(key, session, Math.ceil(durationMs / 1000));
return this.statusFromSession(session);
}
async ingestEvents(params: {
userId: UserID;
channelId: ChannelID;
body: VoiceDebugLoggingEventsBodySchema;
}): Promise<VoiceDebugLoggingEventsResponse> {
await assertVoiceChannelAccess({
channelService: this.channelService,
gatewayService: this.gatewayService,
userId: params.userId,
channelId: params.channelId,
});
const session = await this.getActiveSession(params.channelId);
if (!session || session.session_id !== params.body.session_id) {
return {accepted: false, active: session !== null, stored_event_count: 0};
}
const {startNs, endNs} = minMaxEventTimestampNs(params.body.events);
const key = buildDiagnosticsKey({
channelId: params.channelId,
sessionId: session.session_id,
userId: params.userId,
connectionId: params.body.connection_id,
participantIdentity: params.body.participant_identity,
startNs,
endNs,
});
const serverReceivedAtMs = Date.now();
const serverReceivedMonotonicNs = process.hrtime.bigint().toString();
const lines = params.body.events.map((event) =>
JSON.stringify({
schema_version: 1,
server_received_at_ms: serverReceivedAtMs,
server_received_monotonic_ns: serverReceivedMonotonicNs,
channel_id: params.channelId.toString(),
session_id: session.session_id,
activated_by_user_id: session.activated_by_user_id,
participant_user_id: params.userId.toString(),
connection_id: params.body.connection_id ?? null,
participant_identity: params.body.participant_identity ?? null,
event,
}),
);
await this.storageService.uploadObject({
bucket: VOICE_DIAGNOSTICS_BUCKET,
key,
body: TEXT_ENCODER.encode(`${lines.join('\n')}\n`),
contentType: NDJSON_CONTENT_TYPE,
});
return {accepted: true, active: true, stored_event_count: params.body.events.length};
}
async listObjects(params: {
channelId: string;
startMs: number;
endMs: number;
sessionId?: string;
limitObjects: number;
}): Promise<Array<VoiceDiagnosticsObject>> {
const startNs = BigInt(Math.floor(params.startMs)) * 1000000n;
const endNs = BigInt(Math.floor(params.endMs)) * 1000000n;
const prefixes = buildDatePrefixes({
channelId: params.channelId,
startMs: params.startMs,
endMs: params.endMs,
sessionId: params.sessionId,
});
const listed = await Promise.all(
prefixes.map((prefix) => this.storageService.listObjects({bucket: VOICE_DIAGNOSTICS_BUCKET, prefix})),
);
return listed
.flat()
.flatMap((object): Array<VoiceDiagnosticsObject> => {
const parsed = parseDiagnosticsKey(object.key);
if (!parsed) return [];
return [
{
...parsed,
last_modified: object.lastModified?.toISOString() ?? null,
},
];
})
.filter((object) => {
const objectStartNs = BigInt(object.start_ns);
const objectEndNs = BigInt(object.end_ns);
return objectEndNs >= startNs && objectStartNs <= endNs;
})
.sort((a, b) => {
const delta = BigInt(a.start_ns) - BigInt(b.start_ns);
if (delta < 0n) return -1;
if (delta > 0n) return 1;
return a.key.localeCompare(b.key);
})
.slice(0, params.limitObjects);
}
createRawObjectStream(objects: ReadonlyArray<VoiceDiagnosticsObject>): Readable {
const storageService = this.storageService;
async function* streamObjects(): AsyncGenerator<Uint8Array> {
for (const object of objects) {
const stream = await storageService.streamObject({
bucket: VOICE_DIAGNOSTICS_BUCKET,
key: object.key,
});
if (!stream) continue;
for await (const chunk of stream.body) {
yield typeof chunk === 'string' ? TEXT_ENCODER.encode(chunk) : new Uint8Array(chunk);
}
yield TEXT_ENCODER.encode('\n');
}
}
return Readable.from(streamObjects());
}
}
@@ -2,14 +2,13 @@
import type {WebhookEvent} from 'livekit-server-sdk';
import {TrackSource, WebhookReceiver} from 'livekit-server-sdk';
import type {ChannelID, GuildID, UserID} from '../BrandedTypes';
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 {VoicePresenceHeartbeatStore} from '../voice/VoicePresenceHeartbeatStore';
import type {VoiceTopology} from '../voice/VoiceTopology';
import type {IGatewayService} from './IGatewayService';
import type {ILiveKitService} from './ILiveKitService';
@@ -39,7 +38,6 @@ export class LiveKitWebhookService {
private liveKitService: ILiveKitService,
private voiceTopology: VoiceTopology,
private limitConfigService: LimitConfigService,
private voicePresenceHeartbeatStore: VoicePresenceHeartbeatStore | null = null,
) {
this.receivers = new Map();
this.serverMap = new Map();
@@ -140,43 +138,6 @@ export class LiveKitWebhookService {
);
}
private async markVoicePresenceHeartbeatEnded(params: {
channelId: ChannelID;
userId: UserID;
connectionId: string;
}): Promise<void> {
if (this.voicePresenceHeartbeatStore === null) {
return;
}
try {
await this.voicePresenceHeartbeatStore.markHeartbeatEnded(params);
} catch (error) {
Logger.warn(
{
error,
channelId: params.channelId.toString(),
userId: params.userId.toString(),
connectionId: params.connectionId,
},
'Failed to mark v2 voice presence heartbeat ended',
);
}
}
private async markChannelVoicePresenceHeartbeatsEnded(channelId: ChannelID): Promise<void> {
if (this.voicePresenceHeartbeatStore === null) {
return;
}
try {
await this.voicePresenceHeartbeatStore.markChannelHeartbeatsEnded(channelId);
} catch (error) {
Logger.warn(
{error, channelId: channelId.toString()},
'Failed to clear active v2 voice presence heartbeats for finished room',
);
}
}
async handleRoomFinished(event: WebhookEvent, apiKey: string): Promise<void> {
if (event.event !== 'room_finished' || !event.room) {
return;
@@ -210,7 +171,6 @@ export class LiveKitWebhookService {
);
return;
}
await this.markChannelVoicePresenceHeartbeatsEnded(context.channelId);
await this.voiceRoomStore.deleteRoomServer(undefined, context.channelId);
Logger.debug({channelId: context.channelId.toString()}, 'Cleared DM voice room server pinning');
} else {
@@ -227,7 +187,6 @@ export class LiveKitWebhookService {
);
return;
}
await this.markChannelVoicePresenceHeartbeatsEnded(context.channelId);
await this.voiceRoomStore.deleteRoomServer(context.guildId, context.channelId);
Logger.debug(
{guildId: context.guildId.toString(), channelId: context.channelId.toString()},
@@ -338,11 +297,6 @@ export class LiveKitWebhookService {
},
'LiveKit participant_joined rejected - disconnecting participant',
);
await this.markVoicePresenceHeartbeatEnded({
channelId: context.channelId,
userId: context.userId,
connectionId: context.connectionId,
});
try {
await this.liveKitService.disconnectParticipant({
guildId,
@@ -479,11 +433,6 @@ export class LiveKitWebhookService {
);
return;
}
await this.markVoicePresenceHeartbeatEnded({
channelId: context.channelId,
userId: context.userId,
connectionId: context.connectionId,
});
const guildId = context.type === 'guild' ? context.guildId : undefined;
Logger.info(
{
@@ -81,7 +81,6 @@ import {UserContentRequestService} from '../user/services/UserContentRequestServ
import {UserRelationshipRequestService} from '../user/services/UserRelationshipRequestService';
import {UserService} from '../user/services/UserService';
import {resolveRequestClientIp} from '../utils/IpUtils';
import {VoicePresenceHeartbeatStore} from '../voice/VoicePresenceHeartbeatStore';
import {VoiceService} from '../voice/VoiceService';
import {WebhookRequestService} from '../webhook/WebhookRequestService';
import {WebhookService} from '../webhook/WebhookService';
@@ -334,7 +333,6 @@ function getLiveKitWebhookService(): LiveKitWebhookService | null {
liveKitService,
voiceTopology,
getLimitConfigService(),
new VoicePresenceHeartbeatStore(getKVClient()),
);
}
}
-646
View File
@@ -5468,410 +5468,6 @@
]
}
},
"/channels/{channel_id}/voice-debug-logging/events": {
"post": {
"operationId": "upload_voice_debug_logging_events",
"summary": "Upload voice debug logging events",
"tags": ["Channels"],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/VoiceDebugLoggingEventsResponse"}}
}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "Upload voice debug logging events"}},
"description": "Uploads a small batch of client voice diagnostics events for an active staff-enabled debug logging session.",
"security": [{"sessionToken": []}],
"parameters": [
{
"name": "channel_id",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the channel"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/VoiceDebugLoggingEventsBodySchema"}}
}
}
}
},
"/channels/{channel_id}/voice-debug-logging/session": {
"get": {
"operationId": "get_voice_debug_logging_status",
"summary": "Get voice debug logging status",
"tags": ["Channels"],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/VoiceDebugLoggingStatusResponse"}}
}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "Get voice debug logging status"}},
"description": "Returns whether staff-enabled voice debug logging is active for this channel. Clients poll this while connected to decide whether to upload diagnostics.",
"security": [{"sessionToken": []}],
"parameters": [
{
"name": "channel_id",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the channel"
}
]
},
"put": {
"operationId": "set_voice_debug_logging_status",
"summary": "Toggle voice debug logging",
"tags": ["Channels"],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/VoiceDebugLoggingStatusResponse"}}
}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "Toggle voice debug logging"}},
"description": "Allows staff to start or stop a channel-scoped voice debug logging session. Non-staff users cannot activate or stop sessions.",
"security": [{"sessionToken": []}],
"parameters": [
{
"name": "channel_id",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the channel"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/VoiceDebugLoggingToggleBodySchema"}}
}
}
}
},
"/channels/{channel_id}/voice-presence/heartbeat": {
"post": {
"operationId": "heartbeat_voice_presence",
"summary": "Heartbeat voice presence",
"tags": ["Channels"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/VoicePresenceHeartbeatResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "Heartbeat voice presence"}},
"description": "Refreshes the current user voice presence marker for v2 voice reconciliation. Clients call this while connected to voice.",
"security": [{"sessionToken": []}],
"parameters": [
{
"name": "channel_id",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the channel"
}
],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/VoicePresenceHeartbeatBodySchema"}}}
}
},
"delete": {
"operationId": "end_voice_presence_heartbeat",
"summary": "End voice presence heartbeat",
"tags": ["Channels"],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/VoicePresenceHeartbeatEndResponse"}}
}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
"message": {"type": "string"},
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
},
"required": ["code", "message", "retry_after"]
}
}
},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"x-mint": {"metadata": {"title": "End voice presence heartbeat"}},
"description": "Clears the current user active v2 voice presence marker for a voice connection while preserving v2 enrollment for fast reconciliation.",
"security": [{"sessionToken": []}],
"parameters": [
{
"name": "channel_id",
"in": "path",
"required": true,
"schema": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the channel"
}
],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/VoicePresenceHeartbeatBodySchema"}}}
}
}
},
"/channels/{channel_id}/webhooks": {
"get": {
"operationId": "list_channel_webhooks",
@@ -29516,248 +29112,6 @@
},
"required": ["rate_limit_per_user", "retry_after_ms", "next_send_allowed_at", "can_bypass"]
},
"VoiceDebugLoggingEventsResponse": {
"type": "object",
"properties": {
"accepted": {"type": "boolean", "description": "Whether the telemetry batch was accepted for storage"},
"active": {
"type": "boolean",
"description": "Whether the server still considers this logging session active"
},
"stored_event_count": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647,
"format": "int32",
"description": "Number of events written to diagnostics storage"
}
},
"required": ["accepted", "active", "stored_event_count"]
},
"VoiceDebugLoggingEventsBodySchema": {
"type": "object",
"properties": {
"session_id": {"type": "string", "description": "Active voice debug logging session id"},
"connection_id": {"type": "string", "description": "Client voice connection id"},
"participant_identity": {"type": "string", "description": "LiveKit participant identity"},
"events": {
"type": "array",
"items": {"$ref": "#/components/schemas/VoiceDebugLoggingEventSchema"},
"minItems": 1,
"maxItems": 200,
"description": "NDJSON batch events to store"
}
},
"required": ["session_id", "events"]
},
"VoiceDebugLoggingEventSchema": {
"type": "object",
"properties": {
"type": {"type": "string", "description": "Client-side diagnostic event type"},
"timestamp_ns": {
"type": "string",
"pattern": "^[0-9]{1,32}$",
"description": "Client wall-clock Unix timestamp in nanoseconds"
},
"monotonic_ns": {
"type": "string",
"pattern": "^[0-9]{1,32}$",
"description": "Client monotonic timestamp in nanoseconds"
},
"data": {
"type": "object",
"additionalProperties": {
"anyOf": [
{"type": "string"},
{"type": "number"},
{"type": "boolean"},
{
"type": "object",
"additionalProperties": {
"anyOf": [
{"type": "string"},
{"type": "number"},
{"type": "boolean"},
{
"type": "object",
"additionalProperties": {
"anyOf": [
{"type": "string"},
{"type": "number"},
{"type": "boolean"},
{"type": "object", "additionalProperties": true},
{"type": "null"}
]
}
},
{
"type": "array",
"items": {
"anyOf": [
{"type": "string"},
{"type": "number"},
{"type": "boolean"},
{"type": "object", "additionalProperties": true},
{"type": "null"}
]
}
},
{"type": "null"}
]
}
},
{
"type": "array",
"items": {
"anyOf": [
{"type": "string"},
{"type": "number"},
{"type": "boolean"},
{
"type": "object",
"additionalProperties": {
"anyOf": [
{"type": "string"},
{"type": "number"},
{"type": "boolean"},
{"type": "object", "additionalProperties": true},
{"type": "null"}
]
}
},
{
"type": "array",
"items": {
"anyOf": [
{"type": "string"},
{"type": "number"},
{"type": "boolean"},
{"type": "object", "additionalProperties": true},
{"type": "null"}
]
}
},
{"type": "null"}
]
}
},
{"type": "null"}
]
},
"description": "Event-specific diagnostic payload"
}
},
"required": ["type", "timestamp_ns"]
},
"VoiceDebugLoggingStatusResponse": {
"type": "object",
"properties": {
"active": {
"type": "boolean",
"description": "Whether clients in this channel should currently send voice diagnostics"
},
"session_id": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Current debug logging session id, if active"
},
"activated_by_user_id": {
"anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}],
"description": "Staff user that activated the session, if active"
},
"started_at_ms": {
"anyOf": [
{"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"},
{"type": "null"}
],
"description": "Session start Unix timestamp in milliseconds"
},
"expires_at_ms": {
"anyOf": [
{"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"},
{"type": "null"}
],
"description": "Session expiration Unix timestamp in milliseconds"
},
"poll_interval_ms": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647,
"format": "int32",
"description": "Recommended client polling interval in milliseconds"
},
"upload_interval_ms": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647,
"format": "int32",
"description": "Recommended client telemetry batch upload interval in milliseconds"
}
},
"required": [
"active",
"session_id",
"activated_by_user_id",
"started_at_ms",
"expires_at_ms",
"poll_interval_ms",
"upload_interval_ms"
]
},
"VoiceDebugLoggingToggleBodySchema": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "Whether voice debug logging should be active for this channel"
},
"duration_ms": {
"type": "integer",
"minimum": 60000,
"maximum": 14400000,
"format": "int32",
"description": "Optional activation duration in milliseconds. Defaults to one hour and is capped at four hours."
}
},
"required": ["enabled"]
},
"VoicePresenceHeartbeatResponse": {
"type": "object",
"properties": {
"ok": {"type": "boolean", "description": "Whether the heartbeat was accepted"},
"heartbeat_interval_ms": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647,
"format": "int32",
"description": "Recommended client heartbeat interval in milliseconds"
},
"heartbeat_ttl_ms": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647,
"format": "int32",
"description": "Server-side heartbeat expiration window in milliseconds"
},
"expires_at_ms": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991,
"format": "int53",
"description": "Unix timestamp in milliseconds when this heartbeat expires"
}
},
"required": ["ok", "heartbeat_interval_ms", "heartbeat_ttl_ms", "expires_at_ms"]
},
"VoicePresenceHeartbeatBodySchema": {
"type": "object",
"properties": {"connection_id": {"type": "string", "description": "Client voice connection id"}},
"required": ["connection_id"]
},
"VoicePresenceHeartbeatEndResponse": {
"type": "object",
"properties": {"ok": {"type": "boolean", "description": "Whether the heartbeat was ended"}},
"required": ["ok"]
},
"WebhookResponse": {
"type": "object",
"properties": {
@@ -96,22 +96,6 @@ export const ChannelRateLimitConfigs = {
bucket: 'channel:call:stop_ringing::channel_id',
config: {limit: 20, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
CHANNEL_VOICE_DEBUG_LOGGING_STATUS: {
bucket: 'channel:voice_debug_logging:status::channel_id',
config: {limit: 60, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
CHANNEL_VOICE_DEBUG_LOGGING_TOGGLE: {
bucket: 'channel:voice_debug_logging:toggle::channel_id',
config: {limit: 10, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
CHANNEL_VOICE_DEBUG_LOGGING_EVENTS: {
bucket: 'channel:voice_debug_logging:events::channel_id::user_id',
config: {limit: 60, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
CHANNEL_VOICE_PRESENCE_HEARTBEAT: {
bucket: 'channel:voice_presence:heartbeat::channel_id::user_id',
config: {limit: 20, windowMs: ms('10 seconds')},
} as RouteRateLimitConfig,
CHANNEL_STREAM_UPDATE: {
bucket: 'channel:stream:update::stream_key',
config: {limit: 20, windowMs: ms('10 seconds')},
@@ -1,109 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import type {ChannelID, UserID} from '../BrandedTypes';
export type VoicePresenceHeartbeatState = 'active' | 'expired' | 'legacy';
interface VoicePresenceHeartbeatParams {
channelId: ChannelID;
userId: UserID;
connectionId: string;
}
interface VoicePresenceHeartbeatResult {
heartbeatIntervalMs: number;
heartbeatTtlMs: number;
expiresAtMs: number;
}
const VOICE_PRESENCE_HEARTBEAT_INTERVAL_MS = 15000;
const VOICE_PRESENCE_HEARTBEAT_TTL_SECONDS = 45;
const VOICE_PRESENCE_ENROLLMENT_TTL_SECONDS = 3600;
const VOICE_PRESENCE_HEARTBEAT_ACTIVE_PREFIX = 'voice:presence:v2:active:';
const VOICE_PRESENCE_HEARTBEAT_ENROLLED_PREFIX = 'voice:presence:v2:enrolled:';
const VOICE_PRESENCE_HEARTBEAT_CHANNEL_PREFIX = 'voice:presence:v2:channel:';
export class VoicePresenceHeartbeatStore {
constructor(private readonly kvClient: IKVProvider) {}
async recordHeartbeat(params: VoicePresenceHeartbeatParams): Promise<VoicePresenceHeartbeatResult> {
const now = Date.now();
const payload = JSON.stringify({
version: 2,
channelId: params.channelId.toString(),
userId: params.userId.toString(),
connectionId: params.connectionId,
heartbeatAtMs: now,
});
await Promise.all([
this.kvClient.setex(voicePresenceHeartbeatActiveKey(params), VOICE_PRESENCE_HEARTBEAT_TTL_SECONDS, payload),
this.kvClient.setex(voicePresenceHeartbeatEnrollmentKey(params), VOICE_PRESENCE_ENROLLMENT_TTL_SECONDS, payload),
this.kvClient.sadd(voicePresenceHeartbeatChannelKey(params.channelId), voicePresenceHeartbeatKeySuffix(params)),
this.kvClient.expire(voicePresenceHeartbeatChannelKey(params.channelId), VOICE_PRESENCE_ENROLLMENT_TTL_SECONDS),
]);
return {
heartbeatIntervalMs: VOICE_PRESENCE_HEARTBEAT_INTERVAL_MS,
heartbeatTtlMs: VOICE_PRESENCE_HEARTBEAT_TTL_SECONDS * 1000,
expiresAtMs: now + VOICE_PRESENCE_HEARTBEAT_TTL_SECONDS * 1000,
};
}
async getHeartbeatState(params: VoicePresenceHeartbeatParams): Promise<VoicePresenceHeartbeatState> {
const active = await this.kvClient.get(voicePresenceHeartbeatActiveKey(params));
if (active !== null) {
return 'active';
}
const enrolled = await this.kvClient.get(voicePresenceHeartbeatEnrollmentKey(params));
return enrolled !== null ? 'expired' : 'legacy';
}
async markHeartbeatEnded(params: VoicePresenceHeartbeatParams): Promise<void> {
const enrollmentKey = voicePresenceHeartbeatEnrollmentKey(params);
const enrolled = await this.kvClient.get(enrollmentKey);
const payload = JSON.stringify({
version: 2,
channelId: params.channelId.toString(),
userId: params.userId.toString(),
connectionId: params.connectionId,
endedAtMs: Date.now(),
});
await Promise.all([
this.kvClient.del(voicePresenceHeartbeatActiveKey(params)),
this.kvClient.srem(voicePresenceHeartbeatChannelKey(params.channelId), voicePresenceHeartbeatKeySuffix(params)),
enrolled === null
? Promise.resolve()
: this.kvClient.setex(enrollmentKey, VOICE_PRESENCE_ENROLLMENT_TTL_SECONDS, payload),
]);
}
async markChannelHeartbeatsEnded(channelId: ChannelID): Promise<void> {
const channelKey = voicePresenceHeartbeatChannelKey(channelId);
const suffixes = await this.kvClient.smembers(channelKey);
const activeKeys = suffixes.map((suffix) => `${VOICE_PRESENCE_HEARTBEAT_ACTIVE_PREFIX}${suffix}`);
if (activeKeys.length > 0) {
await this.kvClient.del(...activeKeys, channelKey);
return;
}
await this.kvClient.del(channelKey);
}
}
function voicePresenceHeartbeatActiveKey(params: VoicePresenceHeartbeatParams): string {
return `${VOICE_PRESENCE_HEARTBEAT_ACTIVE_PREFIX}${voicePresenceHeartbeatKeySuffix(params)}`;
}
function voicePresenceHeartbeatEnrollmentKey(params: VoicePresenceHeartbeatParams): string {
return `${VOICE_PRESENCE_HEARTBEAT_ENROLLED_PREFIX}${voicePresenceHeartbeatKeySuffix(params)}`;
}
function voicePresenceHeartbeatChannelKey(channelId: ChannelID): string {
return `${VOICE_PRESENCE_HEARTBEAT_CHANNEL_PREFIX}${channelId.toString()}`;
}
function voicePresenceHeartbeatKeySuffix(params: VoicePresenceHeartbeatParams): string {
const connectionHash = createHash('sha256').update(params.connectionId).digest('base64url');
return `channel:${params.channelId.toString()}:user:${params.userId.toString()}:connection:${connectionHash}`;
}
@@ -12,7 +12,6 @@ import type {GatewayVoiceStateEntry, IGatewayService} from '../infrastructure/IG
import type {ILiveKitService, LiveKitRoomLocation} from '../infrastructure/ILiveKitService';
import type {IVoiceRoomStore} from '../infrastructure/IVoiceRoomStore';
import {parseParticipantIdentity, parseRoomName} from '../infrastructure/VoiceRoomContext';
import {type VoicePresenceHeartbeatState, VoicePresenceHeartbeatStore} from './VoicePresenceHeartbeatStore';
interface GatewayPendingJoinEntry {
readonly connectionId: string;
@@ -27,7 +26,6 @@ interface VoiceReconciliationWorkerOptions {
voiceRoomStore: IVoiceRoomStore;
kvClient: IKVProvider;
logger: ILogger;
voicePresenceHeartbeatStore?: VoicePresenceHeartbeatStore;
intervalMs?: number;
staggerDelayMs?: number;
lockTtlSeconds?: number;
@@ -122,7 +120,6 @@ export class VoiceReconciliationWorker {
private readonly liveKitService: ILiveKitService;
private readonly voiceRoomStore: IVoiceRoomStore;
private readonly kvClient: IKVProvider;
private readonly voicePresenceHeartbeatStore: VoicePresenceHeartbeatStore;
private readonly logger: ILogger;
private readonly intervalMs: number;
private readonly staggerDelayMs: number;
@@ -140,8 +137,6 @@ export class VoiceReconciliationWorker {
this.liveKitService = options.liveKitService;
this.voiceRoomStore = options.voiceRoomStore;
this.kvClient = options.kvClient;
this.voicePresenceHeartbeatStore =
options.voicePresenceHeartbeatStore ?? new VoicePresenceHeartbeatStore(this.kvClient);
this.logger = options.logger.child({worker: 'VoiceReconciliationWorker'});
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
this.staggerDelayMs = options.staggerDelayMs ?? DEFAULT_STAGGER_DELAY_MS;
@@ -575,34 +570,6 @@ export class VoiceReconciliationWorker {
livekitOnlyDeferred++;
continue;
}
const heartbeatState = await this.getVoicePresenceHeartbeatState(room.channelId, participant);
if (heartbeatState === 'active') {
await this.clearLiveKitOnlyCandidate(room.guildId, room.channelId, participant);
this.logger.debug(
{
roomName: room.roomName,
userId: participant.userId.toString(),
connectionId: participant.connectionId,
},
'Deferring LiveKit-only participant because v2 voice presence heartbeat is active',
);
livekitOnlyDeferred++;
continue;
}
if (heartbeatState === 'expired') {
await this.clearLiveKitOnlyCandidate(room.guildId, room.channelId, participant);
this.logger.warn(
{
roomName: room.roomName,
userId: participant.userId.toString(),
connectionId: participant.connectionId,
},
'Disconnecting LiveKit-only participant because v2 voice presence heartbeat expired',
);
await this.disconnectLiveKitOnlyParticipant(room, participant);
livekitOnlyDisconnected++;
continue;
}
if (!liveKitSnapshot.completed) {
this.logger.warn(
{
@@ -649,7 +616,7 @@ export class VoiceReconciliationWorker {
gatewayOnlySkipped++;
continue;
}
const shouldRemove = await this.shouldRemoveGatewayOnlyState(room, voiceState);
const shouldRemove = await this.confirmGatewayOnlyCandidate(room.guildId, room.channelId, voiceState);
if (!shouldRemove) {
gatewayOnlyDeferred++;
continue;
@@ -954,67 +921,6 @@ export class VoiceReconciliationWorker {
}
}
private async shouldRemoveGatewayOnlyState(
room: DiscoveredRoom,
voiceState: GatewayVoiceStateEntry,
): Promise<boolean> {
let userId: UserID;
try {
userId = createUserID(BigInt(voiceState.userId));
} catch (error) {
this.logger.warn(
{error, roomName: room.roomName, userId: voiceState.userId, connectionId: voiceState.connectionId},
'Falling back to legacy gateway-only reconciliation because voice state user id was invalid',
);
return this.confirmGatewayOnlyCandidate(room.guildId, room.channelId, voiceState);
}
const heartbeatState = await this.getVoicePresenceHeartbeatState(room.channelId, {
userId,
connectionId: voiceState.connectionId,
});
if (heartbeatState === 'active') {
await this.clearGatewayOnlyCandidate(room.guildId, room.channelId, voiceState.connectionId);
this.logger.debug(
{roomName: room.roomName, userId: voiceState.userId, connectionId: voiceState.connectionId},
'Deferring gateway-only state because v2 voice presence heartbeat is active',
);
return false;
}
if (heartbeatState === 'expired') {
await this.clearGatewayOnlyCandidate(room.guildId, room.channelId, voiceState.connectionId);
this.logger.warn(
{roomName: room.roomName, userId: voiceState.userId, connectionId: voiceState.connectionId},
'Removing gateway-only state because v2 voice presence heartbeat expired',
);
return true;
}
return this.confirmGatewayOnlyCandidate(room.guildId, room.channelId, voiceState);
}
private async getVoicePresenceHeartbeatState(
channelId: ChannelID,
connection: {userId: UserID; connectionId: string},
): Promise<VoicePresenceHeartbeatState> {
try {
return await this.voicePresenceHeartbeatStore.getHeartbeatState({
channelId,
userId: connection.userId,
connectionId: connection.connectionId,
});
} catch (error) {
this.logger.warn(
{
error,
channelId: channelId.toString(),
userId: connection.userId.toString(),
connectionId: connection.connectionId,
},
'Falling back to legacy reconciliation because v2 voice presence heartbeat lookup failed',
);
return 'legacy';
}
}
private async confirmGatewayOnlyCandidate(
guildId: GuildID | undefined,
channelId: ChannelID,