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:
MythEclipse
2026-06-09 17:34:18 +07:00
co-authored by Claude Opus 4.8
parent 7108f6bb47
commit b68789fffc
37 changed files with 3620 additions and 2345 deletions
@@ -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);
}