refactor: split monolith into 3 microservices (frontend, backend, discord-gateway)
- Extract services into services/{frontend,backend,discord-gateway}
- Create packages/shared/ for shared logger, errors, utils, types
- Setup Modular MVC pattern in backend (controller→service→repository)
- Setup event-driven architecture in discord-gateway with Redis pub/sub
- Move Docker files to infra/docker/ with per-service Dockerfiles
- Update docker-compose.yml to use Traefik-only routing (no port exposes)
- Update GitHub Actions deploy workflow for multi-service matrix build
- Fix all import paths and resolve type errors across all services
- All 3 services pass tsc --noEmit clean
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bda8304bb9
commit
c48a0c5e3b
@@ -0,0 +1,61 @@
|
||||
import { spawn } from "child_process";
|
||||
|
||||
export interface MuxFfmpegArgsOptions {
|
||||
inputs: string[];
|
||||
filter: string;
|
||||
output: string;
|
||||
codec: string;
|
||||
audioFrequency?: number;
|
||||
audioChannels?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds ffmpeg argument array for muxing audio clips.
|
||||
*/
|
||||
export function buildMuxFfmpegArgs(options: MuxFfmpegArgsOptions): string[] {
|
||||
const args: string[] = ["-y"];
|
||||
|
||||
for (const input of options.inputs) {
|
||||
args.push("-i", input);
|
||||
}
|
||||
|
||||
args.push("-filter_complex", options.filter);
|
||||
args.push("-map", "[out]");
|
||||
args.push("-codec:a", options.codec);
|
||||
|
||||
if (options.audioFrequency !== undefined) {
|
||||
args.push("-ar", String(options.audioFrequency));
|
||||
}
|
||||
|
||||
if (options.audioChannels !== undefined) {
|
||||
args.push("-ac", String(options.audioChannels));
|
||||
}
|
||||
|
||||
args.push(options.output);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs ffmpeg with the given arguments.
|
||||
* Resolves on successful (code 0) exit, rejects on error or non-zero exit.
|
||||
*/
|
||||
export function runFfmpeg(args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("ffmpeg", args, {
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`ffmpeg exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { OpusDecoder } from "./recorder/decoder.js";
|
||||
export { SegmentManager } from "./recorder/segment.js";
|
||||
export { startRecording, stopRecording } from "./recorder.js";
|
||||
export { VoiceController } from "./voiceController.js";
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import type { StreamType } from "@discordjs/voice";
|
||||
|
||||
export type MediaMode = "music" | "screen";
|
||||
export type MediaSourceKind =
|
||||
| "url"
|
||||
| "local"
|
||||
| "youtube"
|
||||
| "spotify"
|
||||
| "search";
|
||||
export type MediaQueueItemStatus = "queued" | "playing" | "failed";
|
||||
|
||||
export interface ResolvedMediaSource {
|
||||
source: string;
|
||||
title: string;
|
||||
kind: MediaSourceKind;
|
||||
}
|
||||
|
||||
export interface MediaQueueItem extends ResolvedMediaSource {
|
||||
id: string;
|
||||
mode: MediaMode;
|
||||
requestedBy: string;
|
||||
addedAt: number;
|
||||
status: MediaQueueItemStatus;
|
||||
}
|
||||
|
||||
export interface MediaState {
|
||||
playing: boolean;
|
||||
activeMode: MediaMode | null;
|
||||
musicVolume: number;
|
||||
current: MediaQueueItem | null;
|
||||
queue: MediaQueueItem[];
|
||||
}
|
||||
|
||||
export interface QueueMediaOptions {
|
||||
mode?: MediaMode;
|
||||
requestedBy?: string;
|
||||
}
|
||||
|
||||
export interface MusicPlayback {
|
||||
done: Promise<void>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface MusicPlayer {
|
||||
play(source: ResolvedMediaSource): MusicPlayback;
|
||||
}
|
||||
|
||||
export interface ScreenSharePlayback {
|
||||
done: Promise<void>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface ScreenShareController {
|
||||
isActive(): boolean;
|
||||
start(source: string): Promise<ScreenSharePlayback>;
|
||||
}
|
||||
|
||||
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
|
||||
|
||||
export interface DiscordPlayOptions {
|
||||
inputType?: StreamType;
|
||||
inlineVolume?: boolean;
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
export interface DiscordAudioPlayer {
|
||||
getOwner(): DiscordPlayerOwner;
|
||||
isConnected(): boolean;
|
||||
playStream(
|
||||
stream: Readable,
|
||||
owner: DiscordPlayerOwner,
|
||||
options?: DiscordPlayOptions,
|
||||
): void;
|
||||
pause(owner?: DiscordPlayerOwner): void;
|
||||
unpause(owner?: DiscordPlayerOwner): boolean;
|
||||
stop(owner?: DiscordPlayerOwner): void;
|
||||
getMusicVolume(): number;
|
||||
setMusicVolume(volume: number): void;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { buildMuxFfmpegArgs, runFfmpeg } from "./ffmpegProcess.js";
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Transform, TransformCallback } from "node:stream";
|
||||
|
||||
/**
|
||||
* Transform stream untuk memfilter audio packets yang terlalu kecil
|
||||
* Packet yang terlalu kecil kemungkinan gagal didekripsi oleh Discord
|
||||
*/
|
||||
export class PacketFilter extends Transform {
|
||||
private minPacketSize: number;
|
||||
private filteredCount: number = 0;
|
||||
private totalCount: number = 0;
|
||||
|
||||
constructor(minPacketSize: number = 10) {
|
||||
super();
|
||||
this.minPacketSize = minPacketSize;
|
||||
}
|
||||
|
||||
_transform(
|
||||
chunk: Buffer,
|
||||
encoding: string,
|
||||
callback: TransformCallback,
|
||||
): void {
|
||||
this.totalCount++;
|
||||
|
||||
// Filter packet yang terlalu kecil
|
||||
if (chunk.length >= this.minPacketSize) {
|
||||
this.push(chunk);
|
||||
} else {
|
||||
this.filteredCount++;
|
||||
if (this.filteredCount % 10 === 0) {
|
||||
// console.log(`[packet-filter] Filtered ${this.filteredCount} small packets (size < ${this.minPacketSize} bytes)`);
|
||||
}
|
||||
}
|
||||
|
||||
callback();
|
||||
}
|
||||
|
||||
_flush(callback: TransformCallback): void {
|
||||
// console.log(`[packet-filter] Total packets: ${this.totalCount}, filtered: ${this.filteredCount}, passed: ${this.totalCount - this.filteredCount}`);
|
||||
callback();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
AudioPlayer,
|
||||
AudioPlayerStatus,
|
||||
type AudioResource,
|
||||
createAudioPlayer,
|
||||
createAudioResource,
|
||||
StreamType,
|
||||
VoiceConnection,
|
||||
} from "@discordjs/voice";
|
||||
import type {
|
||||
DiscordPlayerOwner,
|
||||
DiscordPlayOptions,
|
||||
} from "./mediaTypes.js";
|
||||
|
||||
export class DiscordPlayer {
|
||||
private player: AudioPlayer;
|
||||
private connection: VoiceConnection | null = null;
|
||||
private owner: DiscordPlayerOwner = "none";
|
||||
private resource: AudioResource | null = null;
|
||||
private musicVolume = 1;
|
||||
|
||||
constructor() {
|
||||
this.player = createAudioPlayer();
|
||||
|
||||
this.player.on(AudioPlayerStatus.Playing, () => {
|
||||
console.log("[player] Audio player is now playing!");
|
||||
});
|
||||
|
||||
this.player.on("error", (error) => {
|
||||
console.error(`[player] Error: ${error.message}`);
|
||||
this.owner = "none";
|
||||
this.resource = null;
|
||||
});
|
||||
}
|
||||
|
||||
public setConnection(connection: VoiceConnection) {
|
||||
this.connection = connection;
|
||||
this.connection.subscribe(this.player);
|
||||
}
|
||||
|
||||
public getOwner(): DiscordPlayerOwner {
|
||||
return this.owner;
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.connection !== null;
|
||||
}
|
||||
|
||||
public playStream(
|
||||
stream: Readable,
|
||||
owner: DiscordPlayerOwner,
|
||||
options: DiscordPlayOptions = {},
|
||||
) {
|
||||
if (owner === "none") {
|
||||
throw new Error("Discord audio player owner is required");
|
||||
}
|
||||
this.assertOwnerAvailable(owner);
|
||||
|
||||
const resource = createAudioResource(stream, {
|
||||
inputType: options.inputType ?? StreamType.OggOpus,
|
||||
inlineVolume: options.inlineVolume ?? false,
|
||||
});
|
||||
|
||||
if (this.owner === owner) {
|
||||
this.player.stop();
|
||||
}
|
||||
this.resource = resource;
|
||||
this.owner = owner;
|
||||
if (owner === "music") {
|
||||
const nextVolume =
|
||||
options.volume !== undefined
|
||||
? this.normalizeVolume(options.volume)
|
||||
: this.musicVolume;
|
||||
this.musicVolume = nextVolume;
|
||||
this.setResourceVolume(nextVolume);
|
||||
}
|
||||
this.player.play(resource);
|
||||
this.unpause(owner);
|
||||
this.connection?.subscribe(this.player);
|
||||
}
|
||||
|
||||
public getStatus(): AudioPlayerStatus {
|
||||
return this.player.state.status;
|
||||
}
|
||||
|
||||
public pause(owner?: DiscordPlayerOwner) {
|
||||
if (!this.canControl(owner)) return;
|
||||
this.player.pause(true);
|
||||
}
|
||||
|
||||
public unpause(owner?: DiscordPlayerOwner): boolean {
|
||||
if (!this.canControl(owner)) return false;
|
||||
return this.player.unpause();
|
||||
}
|
||||
|
||||
public stop(owner?: DiscordPlayerOwner) {
|
||||
if (!this.canControl(owner)) return;
|
||||
this.player.stop();
|
||||
this.owner = "none";
|
||||
this.resource = null;
|
||||
}
|
||||
|
||||
public getMusicVolume(): number {
|
||||
return this.musicVolume;
|
||||
}
|
||||
|
||||
public setMusicVolume(volume: number): void {
|
||||
const nextVolume = this.normalizeVolume(volume);
|
||||
this.musicVolume = nextVolume;
|
||||
if (this.owner === "music") {
|
||||
this.setResourceVolume(nextVolume);
|
||||
}
|
||||
}
|
||||
|
||||
private assertOwnerAvailable(owner: DiscordPlayerOwner): void {
|
||||
if (this.owner !== "none" && this.owner !== owner) {
|
||||
throw new Error(`Discord audio player is owned by ${this.owner}`);
|
||||
}
|
||||
}
|
||||
|
||||
private canControl(owner?: DiscordPlayerOwner): boolean {
|
||||
return !owner || this.owner === "none" || this.owner === owner;
|
||||
}
|
||||
|
||||
private normalizeVolume(volume: number): number {
|
||||
if (!Number.isFinite(volume)) return this.musicVolume;
|
||||
return Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
private setResourceVolume(volume: number): void {
|
||||
if (!this.resource?.volume) return;
|
||||
this.resource.volume.setVolume(volume);
|
||||
}
|
||||
}
|
||||
|
||||
export const discordPlayer = new DiscordPlayer();
|
||||
@@ -0,0 +1,328 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
type DiscordGatewayAdapterCreator,
|
||||
EndBehaviorType,
|
||||
entersState,
|
||||
getVoiceConnection,
|
||||
joinVoiceChannel,
|
||||
type VoiceConnection,
|
||||
VoiceConnectionStatus,
|
||||
} from "@discordjs/voice";
|
||||
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import { createChildLogger } from "../../shared/logger/logger.js";
|
||||
import { PacketFilter } from "./packetFilter.js";
|
||||
import { subscribeToAudioStream } from "./recorder/audioStream.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 { retryWithBackoff } from "../../shared/utils/retry.js";
|
||||
import type { PcmBroadcaster } from "../message-capture/types.js";
|
||||
|
||||
const logger = createChildLogger("recorder");
|
||||
|
||||
const recordingsDir = config.RECORDINGS_DIR;
|
||||
|
||||
// Pastikan folder recordings ada
|
||||
if (!fs.existsSync(recordingsDir)) {
|
||||
fs.mkdirSync(recordingsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const activeSessions = new Map<string, RecordingSession>();
|
||||
|
||||
export function resetActiveSessions(): void {
|
||||
activeSessions.clear();
|
||||
}
|
||||
|
||||
function finalizeActiveRecordingSession(guildId: string): void {
|
||||
const session = activeSessions.get(guildId);
|
||||
if (!session) return;
|
||||
activeSessions.delete(guildId);
|
||||
finalizeRecordingSession(session).catch((error: unknown) => {
|
||||
logger.error({ error }, "Failed to finalize recording session");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Join ke voice channel dan mulai merekam semua user yang bicara.
|
||||
*/
|
||||
export async function startRecording(
|
||||
client: Client,
|
||||
channel: VoiceChannel,
|
||||
): Promise<VoiceConnection | null> {
|
||||
const connection = joinVoiceChannel({
|
||||
channelId: channel.id,
|
||||
guildId: channel.guild.id,
|
||||
adapterCreator: channel.guild
|
||||
.voiceAdapterCreator as DiscordGatewayAdapterCreator,
|
||||
selfDeaf: false,
|
||||
selfMute: false,
|
||||
debug: true,
|
||||
});
|
||||
|
||||
logger.info({ channelName: channel.name }, "Joining voice channel");
|
||||
|
||||
connection.on("debug", (msg) => {
|
||||
if (config.VERBOSE) {
|
||||
logger.debug({ message: msg }, "Voice debug");
|
||||
}
|
||||
});
|
||||
|
||||
connection.on("error", (err) => {
|
||||
logger.error({ error: err }, "Voice connection error");
|
||||
});
|
||||
|
||||
// Tunggu sampai benar-benar terhubung dengan retry logic
|
||||
try {
|
||||
await retryWithBackoff(
|
||||
() =>
|
||||
entersState(
|
||||
connection,
|
||||
VoiceConnectionStatus.Ready,
|
||||
config.VOICE_CONNECTION_TIMEOUT_MS,
|
||||
),
|
||||
{
|
||||
retries: 3,
|
||||
minTimeout: 1000,
|
||||
maxTimeout: 5000,
|
||||
logger,
|
||||
},
|
||||
);
|
||||
logger.info("Connected to voice channel. Recording started");
|
||||
|
||||
// Create recording session after connection is ready
|
||||
const sessionStartTime = Date.now();
|
||||
const session = createRecordingSession({
|
||||
guildId: channel.guild.id,
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
startTime: sessionStartTime,
|
||||
recordingsDir,
|
||||
});
|
||||
activeSessions.set(channel.guild.id, session);
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to connect to voice channel");
|
||||
connection.destroy();
|
||||
return null;
|
||||
}
|
||||
|
||||
const receiver = connection.receiver;
|
||||
const broadcaster = globalThis as typeof globalThis & PcmBroadcaster;
|
||||
|
||||
// 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
|
||||
broadcaster.updateActiveUser?.(userId, {
|
||||
username: userMetadata.username,
|
||||
avatar: userMetadata.avatarUrl,
|
||||
speaking: true,
|
||||
});
|
||||
|
||||
// Jangan record kalau sudah ada stream aktif untuk user ini
|
||||
if (receiver.subscriptions.has(userId)) return;
|
||||
|
||||
const userDir = path.join(recordingsDir, userId);
|
||||
if (!fs.existsSync(userDir)) {
|
||||
fs.mkdirSync(userDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
// --- OGG file recording with segment rotation ---
|
||||
const packetFilterForOgg = new PacketFilter(
|
||||
config.PACKET_FILTER_MIN_SIZE,
|
||||
);
|
||||
const audioStream = receiver.subscribe(userId, {
|
||||
end: {
|
||||
behavior: EndBehaviorType.AfterSilence,
|
||||
duration: config.AUDIO_STREAM_SILENCE_DURATION_MS,
|
||||
},
|
||||
});
|
||||
const oggPacketStream = audioStream.pipe(packetFilterForOgg);
|
||||
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) => {
|
||||
if (!broadcaster.broadcastPcmToWeb) return;
|
||||
// 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);
|
||||
}
|
||||
broadcaster.broadcastPcmToWeb(outBuf, userId);
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
currentSegment.jsonFilename,
|
||||
JSON.stringify(metadata, null, 2),
|
||||
);
|
||||
if (config.VERBOSE) {
|
||||
logger.info(
|
||||
{ jsonFile: currentSegment.jsonFilename },
|
||||
"Metadata saved",
|
||||
);
|
||||
}
|
||||
|
||||
// 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");
|
||||
});
|
||||
|
||||
// Feed Opus packets one-by-one
|
||||
subscribeToAudioStream(receiver, userId, {
|
||||
onPacket: (chunk) => {
|
||||
if (chunk.length < 8) return;
|
||||
segmentManager.rotateIfNeeded(oggPacketStream);
|
||||
if (!broadcaster.broadcastPcmToWeb) return;
|
||||
decoder.rotateIfNeeded();
|
||||
decoder.write(chunk);
|
||||
},
|
||||
onEnd: () => {
|
||||
segmentManager.close(oggPacketStream);
|
||||
decoder.destroy();
|
||||
broadcaster.updateActiveUser?.(userId, {
|
||||
username: userMetadata.username,
|
||||
avatar: userMetadata.avatarUrl,
|
||||
speaking: false,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
segmentManager.close(oggPacketStream);
|
||||
decoder.destroy();
|
||||
logger.error({ userId, error: error.message }, "Audio stream 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",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle disconnect yang tidak disengaja
|
||||
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
||||
if (config.VERBOSE) {
|
||||
logger.warn("Disconnected from voice channel. Reconnecting...");
|
||||
}
|
||||
try {
|
||||
await Promise.race([
|
||||
entersState(
|
||||
connection,
|
||||
VoiceConnectionStatus.Signalling,
|
||||
config.RECONNECT_TIMEOUT_MS,
|
||||
),
|
||||
entersState(
|
||||
connection,
|
||||
VoiceConnectionStatus.Connecting,
|
||||
config.RECONNECT_TIMEOUT_MS,
|
||||
),
|
||||
]);
|
||||
// Berhasil reconnect
|
||||
} catch {
|
||||
logger.error("Could not reconnect. Destroying connection");
|
||||
connection.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
connection.on(VoiceConnectionStatus.Destroyed, () => {
|
||||
finalizeActiveRecordingSession(channel.guild.id);
|
||||
if (config.VERBOSE) {
|
||||
logger.info("Voice connection destroyed");
|
||||
}
|
||||
});
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hentikan recording dan disconnect dari voice channel.
|
||||
*/
|
||||
export function stopRecording(guildId: string): void {
|
||||
const connection = getVoiceConnection(guildId);
|
||||
if (connection) {
|
||||
connection.destroy();
|
||||
if (config.VERBOSE) {
|
||||
logger.info("Recording stopped and disconnected");
|
||||
}
|
||||
} else {
|
||||
logger.warn("No active connection to stop");
|
||||
}
|
||||
|
||||
finalizeActiveRecordingSession(guildId);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { EndBehaviorType, type VoiceReceiver } from "@discordjs/voice";
|
||||
import { config } from "../../../shared/config/config.js";
|
||||
|
||||
export interface AudioStreamHandlers {
|
||||
onPacket: (chunk: Buffer) => void;
|
||||
onEnd: () => void;
|
||||
onError: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function subscribeToAudioStream(
|
||||
receiver: VoiceReceiver,
|
||||
userId: string,
|
||||
handlers: AudioStreamHandlers,
|
||||
): NodeJS.ReadableStream {
|
||||
const audioStream = receiver.subscribe(userId, {
|
||||
end: {
|
||||
behavior: EndBehaviorType.AfterSilence,
|
||||
duration: config.AUDIO_STREAM_SILENCE_DURATION_MS,
|
||||
},
|
||||
});
|
||||
|
||||
audioStream.on("data", handlers.onPacket);
|
||||
audioStream.on("end", handlers.onEnd);
|
||||
audioStream.on("error", handlers.onError);
|
||||
|
||||
return audioStream;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { createRequire } from "node:module";
|
||||
import * as prism from "prism-media";
|
||||
import { config } from "../../../shared/config/config.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
interface OpusDecoderRuntime {
|
||||
isBun: boolean;
|
||||
canLoadNativeOpus: boolean;
|
||||
}
|
||||
|
||||
export function shouldEnableDefaultOpusDecoder(
|
||||
runtime: OpusDecoderRuntime,
|
||||
): boolean {
|
||||
return !runtime.isBun || runtime.canLoadNativeOpus;
|
||||
}
|
||||
|
||||
function canLoadNativeOpus(): boolean {
|
||||
try {
|
||||
require("@discordjs/opus");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultDecoderEnabled = shouldEnableDefaultOpusDecoder({
|
||||
isBun: Boolean(process.versions.bun),
|
||||
canLoadNativeOpus: canLoadNativeOpus(),
|
||||
});
|
||||
|
||||
export interface OpusDecoderOptions {
|
||||
cooldownMs: number;
|
||||
rotateMs: number;
|
||||
createDecoder?: () => prism.opus.Decoder;
|
||||
onData: (pcm: Buffer) => void;
|
||||
}
|
||||
|
||||
export class OpusDecoder {
|
||||
private decoder: prism.opus.Decoder | null = null;
|
||||
private disabledUntil = 0;
|
||||
private createdAt = 0;
|
||||
private readonly cooldownMs: number;
|
||||
private readonly rotateMs: number;
|
||||
private readonly createDecoderFn: () => prism.opus.Decoder;
|
||||
private readonly onData: (pcm: Buffer) => void;
|
||||
|
||||
constructor(options: OpusDecoderOptions) {
|
||||
this.cooldownMs = options.cooldownMs;
|
||||
this.rotateMs = options.rotateMs;
|
||||
this.onData = options.onData;
|
||||
this.createDecoderFn =
|
||||
options.createDecoder ??
|
||||
(() => {
|
||||
if (!defaultDecoderEnabled) {
|
||||
throw new Error(
|
||||
"Native @discordjs/opus is unavailable under Bun; web PCM decode disabled to avoid opusscript aborts",
|
||||
);
|
||||
}
|
||||
|
||||
return new prism.opus.Decoder({
|
||||
frameSize: config.OPUS_FRAME_SIZE,
|
||||
channels: config.AUDIO_CHANNELS as 1 | 2,
|
||||
rate: config.AUDIO_SAMPLE_RATE as
|
||||
| 8000
|
||||
| 12000
|
||||
| 16000
|
||||
| 24000
|
||||
| 48000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
rotateIfNeeded(): void {
|
||||
if (!this.decoder || this.rotateMs <= 0) return;
|
||||
if (Date.now() - this.createdAt < this.rotateMs) return;
|
||||
this.destroy();
|
||||
this.ensureDecoder();
|
||||
}
|
||||
|
||||
write(chunk: Buffer): void {
|
||||
const decoder = this.ensureDecoder();
|
||||
if (!decoder) return;
|
||||
try {
|
||||
decoder.write(chunk);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[recorder] Opus decoder write failed, cooling down:",
|
||||
error,
|
||||
);
|
||||
this.coolDown();
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (!this.decoder) return;
|
||||
this.decoder.removeAllListeners();
|
||||
this.decoder.destroy();
|
||||
this.decoder = null;
|
||||
this.createdAt = 0;
|
||||
}
|
||||
|
||||
private ensureDecoder(): prism.opus.Decoder | null {
|
||||
if (this.decoder) return this.decoder;
|
||||
if (Date.now() < this.disabledUntil) return null;
|
||||
try {
|
||||
const decoder = this.createDecoderFn();
|
||||
decoder.on("data", this.onData);
|
||||
decoder.on("error", (error) => {
|
||||
console.warn("[recorder] Opus decoder error, cooling down:", error);
|
||||
this.coolDown();
|
||||
});
|
||||
this.decoder = decoder;
|
||||
this.createdAt = Date.now();
|
||||
return decoder;
|
||||
} catch (error) {
|
||||
console.warn("[recorder] Opus decoder init failed, cooling down:", error);
|
||||
this.disabledUntil = Date.now() + this.cooldownMs;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private coolDown(): void {
|
||||
this.disabledUntil = Date.now() + this.cooldownMs;
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import path from "node:path";
|
||||
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
|
||||
import { config } from "../../../shared/config/config.js";
|
||||
import type { SegmentMetadata, SegmentState, UserMetadata } from "../../message-capture/types.js";
|
||||
|
||||
export async function collectUserMetadata(
|
||||
client: Client,
|
||||
userId: string,
|
||||
channel: VoiceChannel,
|
||||
): Promise<UserMetadata> {
|
||||
const user =
|
||||
client.users.cache.get(userId) ||
|
||||
(await client.users.fetch(userId).catch(() => null));
|
||||
const member =
|
||||
channel.guild.members.cache.get(userId) ||
|
||||
(await channel.guild.members.fetch(userId).catch(() => null));
|
||||
const username = user?.username ?? "Unknown User";
|
||||
const roles =
|
||||
member?.roles.cache
|
||||
.filter((role) => role.id !== channel.guild.id)
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
position: role.position,
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
userId,
|
||||
username,
|
||||
tag: user?.tag ?? "Unknown#0000",
|
||||
displayName: member?.displayName ?? username,
|
||||
avatarUrl:
|
||||
user?.displayAvatarURL({
|
||||
format: "png",
|
||||
size: config.AVATAR_SIZE as
|
||||
| 16
|
||||
| 32
|
||||
| 64
|
||||
| 128
|
||||
| 256
|
||||
| 512
|
||||
| 1024
|
||||
| 2048
|
||||
| 4096,
|
||||
}) ?? "https://cdn.discordapp.com/embed/avatars/0.png",
|
||||
bot: user?.bot ?? false,
|
||||
roles,
|
||||
highestRole: roles[0] ?? null,
|
||||
joinedTimestamp: member?.joinedTimestamp ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSegmentMetadata(
|
||||
user: UserMetadata,
|
||||
segment: SegmentState,
|
||||
sessionId: string,
|
||||
recordingSessionId: string,
|
||||
sessionStartTime: number,
|
||||
recordingSegmentMs: number,
|
||||
): SegmentMetadata {
|
||||
const endTime = segment.endTime ?? Date.now();
|
||||
return {
|
||||
...user,
|
||||
sessionId,
|
||||
recordingSessionId,
|
||||
sessionStartTime,
|
||||
segmentIndex: segment.index,
|
||||
segmentMs: recordingSegmentMs,
|
||||
startTime: segment.startTime,
|
||||
endTime,
|
||||
durationMs: endTime - segment.startTime,
|
||||
filename: path.basename(segment.filename),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as prism from "prism-media";
|
||||
import type { SegmentState } from "../../message-capture/types.js";
|
||||
|
||||
export function buildSegmentPaths(
|
||||
userDir: string,
|
||||
startTime: number,
|
||||
): { filename: string; jsonFilename: string } {
|
||||
return {
|
||||
filename: path.join(userDir, `${startTime}.ogg`),
|
||||
jsonFilename: path.join(userDir, `${startTime}.json`),
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRotateSegment(
|
||||
startTime: number,
|
||||
now: number,
|
||||
recordingSegmentMs: number,
|
||||
): boolean {
|
||||
return recordingSegmentMs > 0 && now - startTime >= recordingSegmentMs;
|
||||
}
|
||||
|
||||
export class SegmentManager {
|
||||
private currentSegment: SegmentState | null = null;
|
||||
private segmentIndex = 0;
|
||||
|
||||
constructor(
|
||||
private readonly userDir: string,
|
||||
private readonly recordingSegmentMs: number,
|
||||
) {}
|
||||
|
||||
open(oggPacketStream: NodeJS.ReadableStream): SegmentState {
|
||||
const index = this.segmentIndex++;
|
||||
const startTime = Date.now();
|
||||
const { filename, jsonFilename } = buildSegmentPaths(
|
||||
this.userDir,
|
||||
startTime,
|
||||
);
|
||||
const oggStream = new prism.opus.OggLogicalBitstream({
|
||||
opusHead: new prism.opus.OpusHead({ channelCount: 2, sampleRate: 48000 }),
|
||||
pageSizeControl: { maxPackets: 10 },
|
||||
crc: true,
|
||||
});
|
||||
const out = fs.createWriteStream(filename);
|
||||
oggPacketStream.pipe(oggStream).pipe(out);
|
||||
|
||||
this.currentSegment = {
|
||||
index,
|
||||
startTime,
|
||||
endTime: null,
|
||||
filename,
|
||||
jsonFilename,
|
||||
oggStream,
|
||||
out,
|
||||
};
|
||||
return this.currentSegment;
|
||||
}
|
||||
|
||||
close(oggPacketStream: NodeJS.ReadableStream): SegmentState | null {
|
||||
if (!this.currentSegment) return null;
|
||||
const segment = this.currentSegment;
|
||||
segment.endTime = Date.now();
|
||||
oggPacketStream.unpipe(segment.oggStream);
|
||||
segment.oggStream.end();
|
||||
this.currentSegment = null;
|
||||
return segment;
|
||||
}
|
||||
|
||||
rotateIfNeeded(oggPacketStream: NodeJS.ReadableStream): SegmentState | null {
|
||||
if (!this.currentSegment) return null;
|
||||
if (
|
||||
!shouldRotateSegment(
|
||||
this.currentSegment.startTime,
|
||||
Date.now(),
|
||||
this.recordingSegmentMs,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
this.close(oggPacketStream);
|
||||
return this.open(oggPacketStream);
|
||||
}
|
||||
|
||||
getCurrent(): SegmentState | null {
|
||||
return this.currentSegment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildMuxFfmpegArgs,
|
||||
runFfmpeg as defaultRunFfmpeg,
|
||||
} from "../ffmpegProcess.js";
|
||||
import type { UserMetadata } from "../../message-capture/types.js";
|
||||
|
||||
export type SessionRecordingStatus =
|
||||
| "pending"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "empty";
|
||||
|
||||
export interface RecordingSessionOptions {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
startTime: number;
|
||||
recordingsDir: string;
|
||||
}
|
||||
|
||||
export interface SessionSegmentInput {
|
||||
user: UserMetadata;
|
||||
oggPath: string;
|
||||
jsonPath: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface SessionParticipant {
|
||||
userId: string;
|
||||
username: string;
|
||||
tag: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface SessionSegmentRef {
|
||||
userId: string;
|
||||
oggPath: string;
|
||||
jsonPath: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
durationMs: number;
|
||||
offsetMs: number;
|
||||
}
|
||||
|
||||
export interface SessionRecordingMetadata {
|
||||
sessionId: string;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
durationMs: number;
|
||||
status: SessionRecordingStatus;
|
||||
outputFile: string | null;
|
||||
participants: SessionParticipant[];
|
||||
segments: SessionSegmentRef[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RecordingSession {
|
||||
readonly sessionId: string;
|
||||
readonly recordingsDir: string;
|
||||
readonly startTime: number;
|
||||
registerSegment(input: SessionSegmentInput): void;
|
||||
snapshot(endTime: number): SessionRecordingMetadata;
|
||||
}
|
||||
|
||||
export interface FinalizeRecordingSessionDependencies {
|
||||
endTime?: number;
|
||||
mkdir?: (dir: string) => void;
|
||||
writeJson?: (file: string, metadata: SessionRecordingMetadata) => void;
|
||||
runFfmpeg?: (args: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createRecordingSession(
|
||||
options: RecordingSessionOptions,
|
||||
): RecordingSession {
|
||||
const sessionId = `${options.guildId}-${options.channelId}-${options.startTime}`;
|
||||
const participants = new Map<string, SessionParticipant>();
|
||||
const segments: SessionSegmentRef[] = [];
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
recordingsDir: options.recordingsDir,
|
||||
startTime: options.startTime,
|
||||
|
||||
registerSegment(input: SessionSegmentInput): void {
|
||||
participants.set(input.user.userId, {
|
||||
userId: input.user.userId,
|
||||
username: input.user.username,
|
||||
tag: input.user.tag,
|
||||
displayName: input.user.displayName,
|
||||
avatarUrl: input.user.avatarUrl,
|
||||
});
|
||||
segments.push({
|
||||
userId: input.user.userId,
|
||||
oggPath: input.oggPath,
|
||||
jsonPath: input.jsonPath,
|
||||
startTime: input.startTime,
|
||||
endTime: input.endTime,
|
||||
durationMs: input.endTime - input.startTime,
|
||||
offsetMs: input.startTime - options.startTime,
|
||||
});
|
||||
},
|
||||
|
||||
snapshot(endTime: number): SessionRecordingMetadata {
|
||||
return {
|
||||
sessionId,
|
||||
guildId: options.guildId,
|
||||
channelId: options.channelId,
|
||||
channelName: options.channelName,
|
||||
startTime: options.startTime,
|
||||
endTime,
|
||||
durationMs: endTime - options.startTime,
|
||||
status: "pending",
|
||||
outputFile: null,
|
||||
participants: Array.from(participants.values()),
|
||||
segments: [...segments],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSessionMuxFilter(
|
||||
segments: Array<{ startTime: number }>,
|
||||
sessionStartTime: number,
|
||||
): string {
|
||||
const filters = segments.map((segment, index) => {
|
||||
const delayMs = Math.max(0, segment.startTime - sessionStartTime);
|
||||
return `[${index}:a]adelay=${delayMs}|${delayMs}[pad${index}]`;
|
||||
});
|
||||
const inputs = segments.map((_, index) => `[pad${index}]`).join("");
|
||||
filters.push(
|
||||
`${inputs}amix=inputs=${segments.length}:dropout_transition=0[out]`,
|
||||
);
|
||||
return filters.join(";");
|
||||
}
|
||||
|
||||
export async function finalizeRecordingSession(
|
||||
session: RecordingSession,
|
||||
dependencies: FinalizeRecordingSessionDependencies = {},
|
||||
): Promise<void> {
|
||||
const endTime = dependencies.endTime ?? Date.now();
|
||||
const sessionDir = path.join(
|
||||
session.recordingsDir,
|
||||
"sessions",
|
||||
session.sessionId,
|
||||
);
|
||||
const outputFile = path.join(sessionDir, "full.ogg");
|
||||
const metadataFile = path.join(sessionDir, "session.json");
|
||||
const mkdir =
|
||||
dependencies.mkdir ?? ((dir) => fs.mkdirSync(dir, { recursive: true }));
|
||||
const writeJson =
|
||||
dependencies.writeJson ??
|
||||
((file, metadata) =>
|
||||
fs.writeFileSync(file, JSON.stringify(metadata, null, 2)));
|
||||
const runFfmpeg = dependencies.runFfmpeg ?? defaultRunFfmpeg;
|
||||
|
||||
mkdir(sessionDir);
|
||||
const metadata = session.snapshot(endTime);
|
||||
|
||||
if (metadata.segments.length === 0) {
|
||||
writeJson(metadataFile, { ...metadata, status: "empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runFfmpeg(
|
||||
buildMuxFfmpegArgs({
|
||||
inputs: metadata.segments.map((segment) => segment.oggPath),
|
||||
filter: buildSessionMuxFilter(metadata.segments, metadata.startTime),
|
||||
output: outputFile,
|
||||
codec: "libopus",
|
||||
}),
|
||||
);
|
||||
writeJson(metadataFile, {
|
||||
...metadata,
|
||||
status: "completed",
|
||||
outputFile,
|
||||
});
|
||||
} catch (error) {
|
||||
writeJson(metadataFile, {
|
||||
...metadata,
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { config } from "../../../shared/config/config.js";
|
||||
import {
|
||||
insertVoiceRecording,
|
||||
updateVoiceRecordingAsFailed,
|
||||
updateVoiceRecordingAsUploaded,
|
||||
} from "../../../shared/database/voiceRecordingRepo.js";
|
||||
import { createChildLogger } from "../../../shared/logger/logger.js";
|
||||
import { uploadToTele } from "../teleUpload.js";
|
||||
|
||||
const logger = createChildLogger("recording-uploader");
|
||||
|
||||
/**
|
||||
* Uploads a recorded segment OGG file to external server and registers in database
|
||||
*/
|
||||
export async function uploadRecordingSegment(input: {
|
||||
id: string;
|
||||
oggPath: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
avatarUrl: string | null;
|
||||
guildId: string | null;
|
||||
channelId: string | null;
|
||||
channelName: string | null;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
id,
|
||||
oggPath,
|
||||
userId,
|
||||
username,
|
||||
avatarUrl,
|
||||
guildId,
|
||||
channelId,
|
||||
channelName,
|
||||
} = input;
|
||||
const fileName = path.basename(oggPath);
|
||||
|
||||
try {
|
||||
// 1. Get file size and insert initial pending state to DB
|
||||
const stats = await fs.promises.stat(oggPath);
|
||||
await insertVoiceRecording({
|
||||
id,
|
||||
user_id: userId,
|
||||
username,
|
||||
avatar_url: avatarUrl,
|
||||
guild_id: guildId,
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
filename: fileName,
|
||||
size_bytes: stats.size,
|
||||
upload_status: "pending",
|
||||
created_at: Date.now(),
|
||||
});
|
||||
|
||||
// 2. Perform async upload with retry logic
|
||||
const fileBuffer = await fs.promises.readFile(oggPath);
|
||||
const uploadResult = await uploadToTele({
|
||||
buffer: fileBuffer,
|
||||
filename: fileName,
|
||||
contentType: "audio/ogg",
|
||||
uploadUrl: config.TELE_UPLOAD_URL,
|
||||
retries: 3,
|
||||
logger,
|
||||
});
|
||||
const downloadUrl = uploadResult.url;
|
||||
|
||||
// 3. Update DB to uploaded state
|
||||
await updateVoiceRecordingAsUploaded(id, downloadUrl, Date.now());
|
||||
logger.info({ id, downloadUrl }, "Recording segment uploaded successfully");
|
||||
|
||||
// 4. Broadcast via WebSocket if broadcaster exists globally
|
||||
const broadcaster = (globalThis as any).moderationBroadcaster;
|
||||
if (broadcaster) {
|
||||
const payload = JSON.stringify({
|
||||
type: "voice_recording_uploaded",
|
||||
data: {
|
||||
id,
|
||||
user_id: userId,
|
||||
username,
|
||||
avatar_url: avatarUrl,
|
||||
guild_id: guildId,
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
filename: fileName,
|
||||
size_bytes: stats.size,
|
||||
download_url: downloadUrl,
|
||||
upload_status: "uploaded",
|
||||
created_at: Date.now(),
|
||||
uploaded_at: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
broadcaster.getClients().forEach((client: any) => {
|
||||
if (client.readyState === 1) {
|
||||
try {
|
||||
client.send(payload);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
"Failed to send recording upload event to client",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error({ id, error: errorMsg }, "Failed to upload voice recording");
|
||||
await updateVoiceRecordingAsFailed(id, errorMsg).catch((err: unknown) => {
|
||||
logger.error({ id, err }, "Failed to write failure state to DB");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { CustomLogger } from "../../shared/logger/logger.js";
|
||||
import { retryWithBackoff } from "../../shared/utils/retry.js";
|
||||
|
||||
export interface TeleUploadResponse {
|
||||
download_url: string;
|
||||
public_id?: string;
|
||||
file_name?: string;
|
||||
size_bytes?: number;
|
||||
}
|
||||
|
||||
export interface TeleUploadResult {
|
||||
url: string;
|
||||
publicId?: string;
|
||||
filename?: string;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
|
||||
export function parseTeleUploadResponse(
|
||||
response: TeleUploadResponse,
|
||||
): TeleUploadResult {
|
||||
if (!response.download_url) {
|
||||
throw new Error("Missing download_url in response");
|
||||
}
|
||||
|
||||
return {
|
||||
url: response.download_url,
|
||||
publicId: response.public_id,
|
||||
filename: response.file_name,
|
||||
sizeBytes: response.size_bytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadToTele(input: {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
uploadUrl: string;
|
||||
timeoutMs?: number;
|
||||
retries: number;
|
||||
logger: CustomLogger;
|
||||
}): Promise<TeleUploadResult> {
|
||||
const {
|
||||
buffer,
|
||||
filename,
|
||||
contentType,
|
||||
uploadUrl,
|
||||
timeoutMs,
|
||||
retries,
|
||||
logger,
|
||||
} = input;
|
||||
|
||||
const response = await retryWithBackoff(
|
||||
async () => {
|
||||
const fileBlob = new Blob([new Uint8Array(buffer)], {
|
||||
type: contentType,
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append("file", fileBlob, filename);
|
||||
formData.append("fileName", filename);
|
||||
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
body: formData,
|
||||
...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Upload failed: Status ${res.status}`);
|
||||
}
|
||||
|
||||
return (await res.json()) as TeleUploadResponse;
|
||||
},
|
||||
{
|
||||
retries,
|
||||
minTimeout: 1000,
|
||||
maxTimeout: 5000,
|
||||
logger,
|
||||
},
|
||||
);
|
||||
|
||||
return parseTeleUploadResponse(response);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { getVoiceConnection, type VoiceConnection } from "@discordjs/voice";
|
||||
import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13";
|
||||
import { AppError } from "../../shared/errors/errors.js";
|
||||
import { createChildLogger } from "../../shared/logger/logger.js";
|
||||
import { discordPlayer } from "./player.js";
|
||||
import { startRecording, stopRecording } from "./recorder.js";
|
||||
|
||||
const logger = createChildLogger("voice-controller");
|
||||
|
||||
export interface VoiceStatus {
|
||||
ready: boolean;
|
||||
connected: boolean;
|
||||
activeGuildId: string | null;
|
||||
activeChannelId: string | null;
|
||||
activeChannelName: string | null;
|
||||
}
|
||||
|
||||
export interface GuildSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface VoiceChannelSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ChannelSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export class VoiceController {
|
||||
private activeGuildId: string | null = null;
|
||||
private activeChannelId: string | null = null;
|
||||
private activeChannelName: string | null = null;
|
||||
private connecting = false;
|
||||
|
||||
constructor(private readonly client: Client) {}
|
||||
|
||||
getStatus(): VoiceStatus {
|
||||
const connection = this.activeGuildId
|
||||
? getVoiceConnection(this.activeGuildId)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
ready: this.client.isReady(),
|
||||
connected: Boolean(connection),
|
||||
activeGuildId: this.activeGuildId,
|
||||
activeChannelId: this.activeChannelId,
|
||||
activeChannelName: this.activeChannelName,
|
||||
};
|
||||
}
|
||||
|
||||
listGuilds(): GuildSummary[] {
|
||||
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[]> {
|
||||
const guild = this.getGuild(guildId);
|
||||
await guild.channels.fetch().catch(() => null);
|
||||
|
||||
return guild.channels.cache
|
||||
.filter((channel) => channel.type === "GUILD_VOICE")
|
||||
.map((channel) => ({ id: channel.id, name: channel.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async listWatchableChannels(guildId: string): Promise<ChannelSummary[]> {
|
||||
const guild = this.getGuild(guildId);
|
||||
await guild.channels.fetch().catch(() => null);
|
||||
|
||||
return guild.channels.cache
|
||||
.filter((channel) => channel.type === "GUILD_TEXT")
|
||||
.map((channel) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async connect(guildId: string, channelId: string): Promise<VoiceStatus> {
|
||||
if (!this.client.isReady()) {
|
||||
throw new AppError(
|
||||
"Discord client is not ready",
|
||||
"CLIENT_NOT_READY",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.connecting) {
|
||||
throw new AppError(
|
||||
"Voice connection is already in progress",
|
||||
"CONNECT_IN_PROGRESS",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
this.connecting = true;
|
||||
|
||||
try {
|
||||
await this.disconnect();
|
||||
|
||||
const guild = this.getGuild(guildId);
|
||||
const channel =
|
||||
guild.channels.cache.get(channelId) ??
|
||||
(await guild.channels.fetch(channelId).catch(() => null));
|
||||
|
||||
if (!channel) {
|
||||
throw new AppError(
|
||||
"Voice channel not found",
|
||||
"VOICE_CHANNEL_NOT_FOUND",
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
if (channel.type !== "GUILD_VOICE") {
|
||||
throw new AppError(
|
||||
"Selected channel is not a voice channel",
|
||||
"INVALID_CHANNEL_TYPE",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const connection = await startRecording(
|
||||
this.client,
|
||||
channel as VoiceChannel,
|
||||
);
|
||||
if (!connection) {
|
||||
throw new AppError(
|
||||
"Failed to connect to voice channel",
|
||||
"VOICE_CONNECT_FAILED",
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
discordPlayer.setConnection(connection as VoiceConnection);
|
||||
this.activeGuildId = guildId;
|
||||
this.activeChannelId = channelId;
|
||||
this.activeChannelName = channel.name;
|
||||
|
||||
logger.info(
|
||||
{ guildId, channelId, channelName: channel.name },
|
||||
"Voice connected",
|
||||
);
|
||||
|
||||
return this.getStatus();
|
||||
} finally {
|
||||
this.connecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<VoiceStatus> {
|
||||
if (this.activeGuildId) {
|
||||
stopRecording(this.activeGuildId);
|
||||
}
|
||||
|
||||
discordPlayer.stop();
|
||||
this.activeGuildId = null;
|
||||
this.activeChannelId = null;
|
||||
this.activeChannelName = null;
|
||||
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
private getGuild(guildId: string): Guild {
|
||||
const guild = this.client.guilds.cache.get(guildId);
|
||||
|
||||
if (!guild) {
|
||||
throw new AppError("Guild not found", "GUILD_NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
return guild;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user