refactor: atomic, DRY, and logging improvements
- Shared Redis channel constants as single source of truth (redis-channels.ts) - commandHandler.ts split into VoiceHandler, MediaHandler, GuildHandler, ModerationHandler with handler-registry.ts dispatch - messageStore.ts (1322 lines) split into domain-specific DB files: messages.db.ts, attachments.db.ts, reviews.db.ts, moderation-actions.db.ts, retention.db.ts - recorder.ts startSpeaking callback extracted into speakingHandler.ts, streamSetup.ts, segmentFinalizer.ts - autoDeleteManager.ts split into autoDeleteEligibility.ts, autoDeleteNotify.ts, autoDeleteLogger.ts - Added createChildLogger() logging across 8 service files - Backend messages.repository.ts migrated from raw SQL to Drizzle ORM - Fixed biome.json to exclude packages/**/dist/* from lint - Fixed config.ts GUILD_ID pre-existing type error Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7108f6bb47
commit
b68789fffc
@@ -1,10 +1,8 @@
|
||||
import { promises as fsPromises } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { retryWithBackoff } from "@bete/shared/utils";
|
||||
import {
|
||||
type DiscordGatewayAdapterCreator,
|
||||
EndBehaviorType,
|
||||
entersState,
|
||||
getVoiceConnection,
|
||||
joinVoiceChannel,
|
||||
@@ -14,19 +12,12 @@ import {
|
||||
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js";
|
||||
import { PacketFilter } from "./packetFilter.js";
|
||||
import { OpusDecoder } from "./recorder/decoder.js";
|
||||
import {
|
||||
collectUserMetadata,
|
||||
createSegmentMetadata,
|
||||
} from "./recorder/metadata.js";
|
||||
import { SegmentManager } from "./recorder/segment.js";
|
||||
import {
|
||||
createRecordingSession,
|
||||
finalizeRecordingSession,
|
||||
type RecordingSession,
|
||||
} from "./recorder/sessionRecording.js";
|
||||
import { uploadRecordingSegment } from "./recorder/uploader.js";
|
||||
import { createSpeakingHandler } from "./recorder/speakingHandler.js";
|
||||
|
||||
const logger = createChildLogger("recorder");
|
||||
|
||||
@@ -132,174 +123,18 @@ export async function startRecording(
|
||||
|
||||
const receiver = connection.receiver;
|
||||
|
||||
// Dengarkan siapapun yang mulai bicara
|
||||
receiver.speaking.on("start", async (userId) => {
|
||||
if (userId === client.user?.id) return;
|
||||
|
||||
const userMetadata = await collectUserMetadata(client, userId, channel);
|
||||
if (userMetadata.bot) return;
|
||||
|
||||
logger.debug(
|
||||
{ userId, username: userMetadata.username },
|
||||
"Voice activity detected",
|
||||
);
|
||||
|
||||
// Notify webserver
|
||||
_eventBroadcaster?.voiceActiveUser(userId, {
|
||||
username: userMetadata.username,
|
||||
avatar: userMetadata.avatarUrl,
|
||||
speaking: true,
|
||||
});
|
||||
|
||||
// Skip if user already has an active stream
|
||||
if (receiver.subscriptions.has(userId)) return;
|
||||
|
||||
const userDir = path.join(recordingsDir, userId);
|
||||
await fsPromises.mkdir(userDir, { recursive: true }).catch(() => {
|
||||
// Directory already exists, ignore
|
||||
});
|
||||
|
||||
try {
|
||||
// Subscribe to the audio stream FIRST, then immediately attach all event
|
||||
// handlers before piping — prevents race condition where initial packets
|
||||
// arrive before listeners are registered.
|
||||
const audioStream = receiver.subscribe(userId, {
|
||||
end: {
|
||||
behavior: EndBehaviorType.AfterSilence,
|
||||
duration: config.AUDIO_STREAM_SILENCE_DURATION_MS,
|
||||
},
|
||||
});
|
||||
|
||||
const packetFilterForOgg = new PacketFilter(
|
||||
config.PACKET_FILTER_MIN_SIZE,
|
||||
);
|
||||
const segmentManager = new SegmentManager(
|
||||
userDir,
|
||||
config.RECORDING_SEGMENT_MS,
|
||||
);
|
||||
|
||||
// --- Web broadcast: prism decoder with safe restart and cooldown ---
|
||||
const decoder = new OpusDecoder({
|
||||
cooldownMs: config.DECODER_COOLDOWN_MS,
|
||||
rotateMs: config.DECODER_ROTATE_MS,
|
||||
onData: (pcm) => {
|
||||
// Downsample 48kHz stereo → 24kHz mono (left channel, every 2nd sample)
|
||||
const outBuf = Buffer.alloc(pcm.length / 4);
|
||||
for (let i = 0; i < outBuf.length / 2; i++) {
|
||||
outBuf.writeInt16LE(pcm.readInt16LE(i * 8), i * 2);
|
||||
}
|
||||
_eventBroadcaster?.voicePcmData(outBuf, userId);
|
||||
},
|
||||
});
|
||||
|
||||
// Attach all audioStream event handlers BEFORE pipe() to avoid data loss
|
||||
audioStream.on("data", (chunk: Buffer) => {
|
||||
if (chunk.length < 8) return;
|
||||
segmentManager.rotateIfNeeded(packetFilterForOgg);
|
||||
decoder.rotateIfNeeded();
|
||||
decoder.write(chunk);
|
||||
});
|
||||
|
||||
audioStream.on("end", () => {
|
||||
segmentManager.close(packetFilterForOgg);
|
||||
decoder.destroy();
|
||||
_eventBroadcaster?.voiceActiveUser(userId, {
|
||||
username: userMetadata.username,
|
||||
avatar: userMetadata.avatarUrl,
|
||||
speaking: false,
|
||||
});
|
||||
});
|
||||
|
||||
audioStream.on("error", (error: Error) => {
|
||||
segmentManager.close(packetFilterForOgg);
|
||||
decoder.destroy();
|
||||
logger.error({ userId, error: error.message }, "Audio stream error");
|
||||
});
|
||||
|
||||
// Now pipe for OGG recording (safe — event handlers already attached)
|
||||
const oggPacketStream = audioStream.pipe(packetFilterForOgg);
|
||||
|
||||
const activeSession = activeSessions.get(channel.guild.id);
|
||||
let currentSegment = segmentManager.open(oggPacketStream);
|
||||
currentSegment.out.on("finish", () => {
|
||||
if (config.VERBOSE) {
|
||||
logger.info({ filename: currentSegment.filename }, "Segment saved");
|
||||
}
|
||||
const endTime = currentSegment.endTime ?? Date.now();
|
||||
if (activeSession) {
|
||||
activeSession.registerSegment({
|
||||
user: userMetadata,
|
||||
oggPath: currentSegment.filename,
|
||||
jsonPath: currentSegment.jsonFilename,
|
||||
startTime: currentSegment.startTime,
|
||||
endTime,
|
||||
});
|
||||
}
|
||||
const metadata = createSegmentMetadata(
|
||||
userMetadata,
|
||||
currentSegment,
|
||||
activeSession?.sessionId ?? `${userId}-0`,
|
||||
activeSession?.sessionId ?? `${channel.guild.id}-${channel.id}-0`,
|
||||
activeSession?.startTime ?? 0,
|
||||
config.RECORDING_SEGMENT_MS,
|
||||
);
|
||||
fsPromises
|
||||
.writeFile(
|
||||
currentSegment.jsonFilename,
|
||||
JSON.stringify(metadata, null, 2),
|
||||
)
|
||||
.then(() => {
|
||||
if (config.VERBOSE) {
|
||||
logger.info(
|
||||
{ jsonFile: currentSegment.jsonFilename },
|
||||
"Metadata saved",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.error(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
"Failed to write segment metadata",
|
||||
);
|
||||
});
|
||||
|
||||
// Trigger async voice segment upload
|
||||
const segmentId = `${userId}-${currentSegment.startTime}`;
|
||||
uploadRecordingSegment({
|
||||
id: segmentId,
|
||||
oggPath: currentSegment.filename,
|
||||
userId: userMetadata.userId,
|
||||
username: userMetadata.username,
|
||||
avatarUrl: userMetadata.avatarUrl,
|
||||
guildId: channel.guild.id,
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
}).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.error(
|
||||
{ segmentId, error: msg },
|
||||
"Upload segment trigger failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
currentSegment.out.on("error", (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.error({ userId, error: msg }, "File write error");
|
||||
});
|
||||
|
||||
packetFilterForOgg.on("error", (err) => {
|
||||
segmentManager.close(oggPacketStream);
|
||||
logger.error({ userId, error: err.message }, "PacketFilter error");
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
{ userId, error: e instanceof Error ? e.message : String(e) },
|
||||
"Failed to create stream",
|
||||
);
|
||||
}
|
||||
// Use the extracted speaking handler for voice activity
|
||||
const speakingHandler = createSpeakingHandler({
|
||||
client,
|
||||
channel,
|
||||
receiver,
|
||||
eventBroadcaster: _eventBroadcaster,
|
||||
activeSessions,
|
||||
recordingsDir,
|
||||
});
|
||||
|
||||
receiver.speaking.on("start", speakingHandler);
|
||||
|
||||
// Handle unexpected disconnection
|
||||
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
||||
if (config.VERBOSE) {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { promises as fsPromises } from "node:fs";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { config } from "../../../shared/config/config.js";
|
||||
import type {
|
||||
SegmentState,
|
||||
UserMetadata,
|
||||
} from "../../message-capture/types.js";
|
||||
import { createSegmentMetadata } from "./metadata.js";
|
||||
import type { RecordingSession } from "./sessionRecording.js";
|
||||
import { uploadRecordingSegment } from "./uploader.js";
|
||||
|
||||
const logger = createChildLogger("segment-finalizer");
|
||||
|
||||
export interface SegmentFinalizerInput {
|
||||
currentSegment: SegmentState;
|
||||
userMetadata: UserMetadata;
|
||||
activeSession: RecordingSession | undefined;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the completion of an OGG segment:
|
||||
* - Logs the saved segment (if VERBOSE)
|
||||
* - Registers the segment with the active recording session
|
||||
* - Writes the metadata JSON file alongside the OGG file
|
||||
* - Triggers async upload of the segment to external storage
|
||||
*
|
||||
* This function is fire-and-forget for the metadata write and upload;
|
||||
* errors are caught and logged without throwing.
|
||||
*/
|
||||
export function finalizeSegment(input: SegmentFinalizerInput): void {
|
||||
const {
|
||||
currentSegment,
|
||||
userMetadata,
|
||||
activeSession,
|
||||
guildId,
|
||||
channelId,
|
||||
channelName,
|
||||
} = input;
|
||||
|
||||
const endTime = currentSegment.endTime ?? Date.now();
|
||||
|
||||
if (config.VERBOSE) {
|
||||
logger.info({ filename: currentSegment.filename }, "Segment saved");
|
||||
}
|
||||
|
||||
// Register segment with the active recording session
|
||||
if (activeSession) {
|
||||
activeSession.registerSegment({
|
||||
user: userMetadata,
|
||||
oggPath: currentSegment.filename,
|
||||
jsonPath: currentSegment.jsonFilename,
|
||||
startTime: currentSegment.startTime,
|
||||
endTime,
|
||||
});
|
||||
}
|
||||
|
||||
// Write metadata JSON (async, fire-and-forget)
|
||||
const metadata = createSegmentMetadata(
|
||||
userMetadata,
|
||||
currentSegment,
|
||||
activeSession?.sessionId ?? `${userMetadata.userId}-0`,
|
||||
activeSession?.sessionId ?? `${guildId}-${channelId}-0`,
|
||||
activeSession?.startTime ?? 0,
|
||||
config.RECORDING_SEGMENT_MS,
|
||||
);
|
||||
|
||||
fsPromises
|
||||
.writeFile(currentSegment.jsonFilename, JSON.stringify(metadata, null, 2))
|
||||
.then(() => {
|
||||
if (config.VERBOSE) {
|
||||
logger.info(
|
||||
{ jsonFile: currentSegment.jsonFilename },
|
||||
"Metadata saved",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.error(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
"Failed to write segment metadata",
|
||||
);
|
||||
});
|
||||
|
||||
// Trigger async voice segment upload (fire-and-forget)
|
||||
const segmentId = `${userMetadata.userId}-${currentSegment.startTime}`;
|
||||
uploadRecordingSegment({
|
||||
id: segmentId,
|
||||
oggPath: currentSegment.filename,
|
||||
userId: userMetadata.userId,
|
||||
username: userMetadata.username,
|
||||
avatarUrl: userMetadata.avatarUrl,
|
||||
guildId,
|
||||
channelId,
|
||||
channelName,
|
||||
}).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.error({ segmentId, error: msg }, "Upload segment trigger failed");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { promises as fsPromises } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { VoiceConnection } from "@discordjs/voice";
|
||||
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
|
||||
import type { EventBroadcaster } from "../../event-broadcaster/eventBroadcaster.js";
|
||||
import { collectUserMetadata } from "./metadata.js";
|
||||
import { finalizeSegment } from "./segmentFinalizer.js";
|
||||
import type { RecordingSession } from "./sessionRecording.js";
|
||||
import { setupUserStream } from "./streamSetup.js";
|
||||
|
||||
const logger = createChildLogger("speaking-handler");
|
||||
|
||||
export interface SpeakingHandlerContext {
|
||||
client: Client;
|
||||
channel: VoiceChannel;
|
||||
receiver: VoiceConnection["receiver"];
|
||||
eventBroadcaster: EventBroadcaster | undefined;
|
||||
activeSessions: Map<string, RecordingSession>;
|
||||
recordingsDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the event handler for `receiver.speaking.on("start", handler)`.
|
||||
*
|
||||
* The returned handler manages the full lifecycle for a user who starts speaking:
|
||||
* 1. Validates the user (skip bot self, skip already-subscribed)
|
||||
* 2. Collects user metadata and notifies the event broadcaster
|
||||
* 3. Sets up the audio stream, decoder, packet filter, and segment manager
|
||||
* 4. Attaches stream event handlers (data, end, error) BEFORE piping
|
||||
* 5. Pipes audio through the packet filter for OGG recording
|
||||
* 6. Handles segment completion (metadata write, upload trigger)
|
||||
*/
|
||||
export function createSpeakingHandler(
|
||||
ctx: SpeakingHandlerContext,
|
||||
): (userId: string) => Promise<void> {
|
||||
const {
|
||||
client,
|
||||
channel,
|
||||
receiver,
|
||||
eventBroadcaster,
|
||||
activeSessions,
|
||||
recordingsDir,
|
||||
} = ctx;
|
||||
|
||||
return async (userId: string) => {
|
||||
// Skip the bot's own audio
|
||||
if (userId === client.user?.id) return;
|
||||
|
||||
const userMetadata = await collectUserMetadata(client, userId, channel);
|
||||
if (userMetadata.bot) return;
|
||||
|
||||
logger.debug(
|
||||
{ userId, username: userMetadata.username },
|
||||
"Voice activity detected",
|
||||
);
|
||||
|
||||
// Notify webserver / WebSocket clients
|
||||
eventBroadcaster?.voiceActiveUser(userId, {
|
||||
username: userMetadata.username,
|
||||
avatar: userMetadata.avatarUrl,
|
||||
speaking: true,
|
||||
});
|
||||
|
||||
// Skip if user already has an active stream subscription
|
||||
if (receiver.subscriptions.has(userId)) return;
|
||||
|
||||
// Ensure per-user recording directory
|
||||
const userDir = path.join(recordingsDir, userId);
|
||||
await fsPromises.mkdir(userDir, { recursive: true }).catch(() => {
|
||||
// Directory already exists, ignore
|
||||
});
|
||||
|
||||
try {
|
||||
// Step 1: Set up stream components (subscribe, decoder, filter, segment
|
||||
// manager). NOTE: pipe() is NOT called here — we attach event handlers
|
||||
// first to prevent data loss from race conditions.
|
||||
const { audioStream, packetFilter, segmentManager, decoder } =
|
||||
setupUserStream({
|
||||
userId,
|
||||
receiver,
|
||||
userDir,
|
||||
onPcmData: (pcm) => {
|
||||
eventBroadcaster?.voicePcmData(pcm, userId);
|
||||
},
|
||||
});
|
||||
|
||||
// Step 2: Attach all audioStream event handlers BEFORE pipe()
|
||||
audioStream.on("data", (chunk: Buffer) => {
|
||||
if (chunk.length < 8) return;
|
||||
segmentManager.rotateIfNeeded(packetFilter);
|
||||
decoder.rotateIfNeeded();
|
||||
decoder.write(chunk);
|
||||
});
|
||||
|
||||
audioStream.on("end", () => {
|
||||
segmentManager.close(packetFilter);
|
||||
decoder.destroy();
|
||||
eventBroadcaster?.voiceActiveUser(userId, {
|
||||
username: userMetadata.username,
|
||||
avatar: userMetadata.avatarUrl,
|
||||
speaking: false,
|
||||
});
|
||||
});
|
||||
|
||||
audioStream.on("error", (error: Error) => {
|
||||
segmentManager.close(packetFilter);
|
||||
decoder.destroy();
|
||||
logger.error({ userId, error: error.message }, "Audio stream error");
|
||||
});
|
||||
|
||||
// Step 3: Now pipe for OGG recording (safe — event handlers attached)
|
||||
const oggPacketStream = audioStream.pipe(packetFilter);
|
||||
|
||||
// Step 4: Open the first segment
|
||||
const activeSession = activeSessions.get(channel.guild.id);
|
||||
let currentSegment = segmentManager.open(oggPacketStream);
|
||||
|
||||
// Step 5: Handle segment file completion
|
||||
currentSegment.out.on("finish", () => {
|
||||
finalizeSegment({
|
||||
currentSegment,
|
||||
userMetadata,
|
||||
activeSession,
|
||||
guildId: channel.guild.id,
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
});
|
||||
});
|
||||
|
||||
currentSegment.out.on("error", (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.error({ userId, error: msg }, "File write error");
|
||||
});
|
||||
|
||||
// Step 6: Handle packet filter errors
|
||||
packetFilter.on("error", (err) => {
|
||||
segmentManager.close(oggPacketStream);
|
||||
logger.error({ userId, error: err.message }, "PacketFilter error");
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
{ userId, error: e instanceof Error ? e.message : String(e) },
|
||||
"Failed to create stream",
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { VoiceConnection } from "@discordjs/voice";
|
||||
import { EndBehaviorType } from "@discordjs/voice";
|
||||
import { config } from "../../../shared/config/config.js";
|
||||
import { PacketFilter } from "../packetFilter.js";
|
||||
import { OpusDecoder } from "./decoder.js";
|
||||
import { SegmentManager } from "./segment.js";
|
||||
|
||||
const logger = createChildLogger("stream-setup");
|
||||
|
||||
export interface StreamSetupInput {
|
||||
userId: string;
|
||||
receiver: VoiceConnection["receiver"];
|
||||
userDir: string;
|
||||
onPcmData: (pcm: Buffer) => void;
|
||||
}
|
||||
|
||||
export interface StreamSetupResult {
|
||||
audioStream: NodeJS.ReadableStream;
|
||||
packetFilter: PacketFilter;
|
||||
segmentManager: SegmentManager;
|
||||
decoder: OpusDecoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the audio stream subscription, decoder, packet filter, and segment
|
||||
* manager for a user who started speaking.
|
||||
*
|
||||
* NOTE: This function does NOT pipe the audio stream through the packet filter.
|
||||
* The caller must attach event handlers to `audioStream` BEFORE calling
|
||||
* `audioStream.pipe(packetFilter)` to prevent data loss from race conditions.
|
||||
*/
|
||||
export function setupUserStream(input: StreamSetupInput): StreamSetupResult {
|
||||
const { userId, receiver, userDir, onPcmData } = input;
|
||||
|
||||
logger.debug({ userId }, "Setting up user audio stream");
|
||||
|
||||
// Subscribe to the audio stream from the Discord voice receiver
|
||||
const audioStream = receiver.subscribe(userId, {
|
||||
end: {
|
||||
behavior: EndBehaviorType.AfterSilence,
|
||||
duration: config.AUDIO_STREAM_SILENCE_DURATION_MS,
|
||||
},
|
||||
});
|
||||
|
||||
const packetFilter = new PacketFilter(config.PACKET_FILTER_MIN_SIZE);
|
||||
const segmentManager = new SegmentManager(
|
||||
userDir,
|
||||
config.RECORDING_SEGMENT_MS,
|
||||
);
|
||||
|
||||
// Create decoder for web broadcast (PCM downsampling)
|
||||
const decoder = new OpusDecoder({
|
||||
cooldownMs: config.DECODER_COOLDOWN_MS,
|
||||
rotateMs: config.DECODER_ROTATE_MS,
|
||||
onData: (pcm: Buffer) => {
|
||||
// Downsample 48kHz stereo -> 24kHz mono (left channel, every 2nd sample)
|
||||
const outBuf = Buffer.alloc(pcm.length / 4);
|
||||
for (let i = 0; i < outBuf.length / 2; i++) {
|
||||
outBuf.writeInt16LE(pcm.readInt16LE(i * 8), i * 2);
|
||||
}
|
||||
onPcmData(outBuf);
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug({ userId }, "User audio stream setup complete");
|
||||
|
||||
return {
|
||||
audioStream,
|
||||
packetFilter,
|
||||
segmentManager,
|
||||
decoder,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { BACKEND_VOICE_TRANSMIT } from "@bete/shared";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { StreamType } from "@discordjs/voice";
|
||||
import type Redis from "ioredis";
|
||||
@@ -19,12 +20,13 @@ export class VoiceTransmitter {
|
||||
private pcmStream: PassThrough | null = null;
|
||||
private ffmpegProcess: ReturnType<typeof spawn> | null = null;
|
||||
private isActive = false;
|
||||
private readonly TRANSMIT_CHANNEL = "backend:voice:transmit";
|
||||
private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT;
|
||||
|
||||
/**
|
||||
* Start listening for PCM audio data from Redis and stream to Discord
|
||||
*/
|
||||
async start(redis: Redis): Promise<void> {
|
||||
logger.info("Transmitter start requested");
|
||||
if (this.isActive) {
|
||||
logger.warn("Voice transmitter already active");
|
||||
return;
|
||||
@@ -143,6 +145,7 @@ export class VoiceTransmitter {
|
||||
* Stop transmitting and clean up resources
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
logger.info("Transmitter stop requested");
|
||||
if (!this.isActive) return;
|
||||
|
||||
this.isActive = false;
|
||||
|
||||
@@ -40,6 +40,7 @@ export class VoiceController {
|
||||
constructor(private readonly client: Client) {}
|
||||
|
||||
getStatus(): VoiceStatus {
|
||||
logger.debug("getStatus called");
|
||||
const connection = this.activeGuildId
|
||||
? getVoiceConnection(this.activeGuildId)
|
||||
: undefined;
|
||||
@@ -54,12 +55,14 @@ export class VoiceController {
|
||||
}
|
||||
|
||||
listGuilds(): GuildSummary[] {
|
||||
logger.info("listGuilds called");
|
||||
return this.client.guilds.cache
|
||||
.map((guild) => ({ id: guild.id, name: guild.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async listVoiceChannels(guildId: string): Promise<VoiceChannelSummary[]> {
|
||||
logger.info({ guildId }, "listVoiceChannels called");
|
||||
const guild = this.getGuild(guildId);
|
||||
await guild.channels.fetch().catch(() => null);
|
||||
|
||||
@@ -70,6 +73,7 @@ export class VoiceController {
|
||||
}
|
||||
|
||||
async listWatchableChannels(guildId: string): Promise<ChannelSummary[]> {
|
||||
logger.info({ guildId }, "listWatchableChannels called");
|
||||
const guild = this.getGuild(guildId);
|
||||
await guild.channels.fetch().catch(() => null);
|
||||
|
||||
@@ -84,6 +88,7 @@ export class VoiceController {
|
||||
}
|
||||
|
||||
async connect(guildId: string, channelId: string): Promise<VoiceStatus> {
|
||||
logger.info({ guildId, channelId }, "connect called");
|
||||
if (!this.client.isReady()) {
|
||||
throw new AppError(
|
||||
"Discord client is not ready",
|
||||
@@ -155,6 +160,7 @@ export class VoiceController {
|
||||
}
|
||||
|
||||
async disconnect(): Promise<VoiceStatus> {
|
||||
logger.info("disconnect called");
|
||||
if (this.activeGuildId) {
|
||||
stopRecording(this.activeGuildId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user