mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
refactor(voice): remove heartbeat and debug logging sessions (#2121)
This commit is contained in:
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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user