From 81a2318bd2a423ae9c998a9f20769593f0bc09f4 Mon Sep 17 00:00:00 2001 From: Mikolaj Wojciech Gorski Date: Sat, 26 Jul 2025 16:13:19 +0200 Subject: [PATCH] feat: add auto-stop functionality for webcam and video recordings - Add automatic recording stop when user turns off camera/screenshare - Listen for startStreaming events to detect video_ssrc=0 (camera/screen off) - Add disconnect and connection close event handling - Implement proper cleanup of event listeners to prevent memory leaks - Add targetUserId tracking for accurate event filtering - Update both videoRecording.js and webcamRecording.js services - Update discord.js-selfbot-v13 submodule with latest webcam recording fixes --- .gitmodules | 2 +- Dockerfile.dev | 6 + discord.js-selfbot-v13 | 2 +- ...ompose.yaml => docker-compose.yaml.release | 0 docker-compose.dev.yml => docker-compose.yml | 8 +- docs/docker-compose-examples.md | 2 +- src/services/commandHandler.js | 269 +++++++++++- src/services/videoRecording.js | 68 +++ src/services/webcamRecording.js | 397 ++++++++++++++++++ 9 files changed, 738 insertions(+), 16 deletions(-) rename docker-compose.yaml => docker-compose.yaml.release (100%) rename docker-compose.dev.yml => docker-compose.yml (89%) create mode 100644 src/services/webcamRecording.js diff --git a/.gitmodules b/.gitmodules index 4eced46..932b81c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "discord.js-selfbot-v13"] path = discord.js-selfbot-v13 - url = ssh://git@ssh.opencodebox.work:23/MikolajG/discord.js-selfbot-v13.git + url = https://git.opencodebox.work/MikolajG/discord.js-selfbot-v13.git diff --git a/Dockerfile.dev b/Dockerfile.dev index 269c84d..9f66742 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,6 +1,12 @@ # --- Stage 1: Install node_modules --- FROM node:20-slim as dependencies +# Install build tools needed for native modules like sodium +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential libtool autoconf automake libsodium-dev python3 \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /opt/bot COPY package*.json ./ RUN npm install --no-audit --no-fund diff --git a/discord.js-selfbot-v13 b/discord.js-selfbot-v13 index 2b1a26f..c33ddab 160000 --- a/discord.js-selfbot-v13 +++ b/discord.js-selfbot-v13 @@ -1 +1 @@ -Subproject commit 2b1a26f4694aeb7c6ffe4a795c1091c0a5a71a81 +Subproject commit c33ddab4ed5cbae5fef0e45a5003b7c6da0c6f03 diff --git a/docker-compose.yaml b/docker-compose.yaml.release similarity index 100% rename from docker-compose.yaml rename to docker-compose.yaml.release diff --git a/docker-compose.dev.yml b/docker-compose.yml similarity index 89% rename from docker-compose.dev.yml rename to docker-compose.yml index 710887b..65cf4ad 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.yml @@ -20,13 +20,11 @@ services: # Mount the current directory into the container for live-reloading - .:/opt/bot # Use a named volume to keep the container's node_modules from being overwritten - - node_modules:/opt/bot/node_modules + # - node_modules:/opt/bot/node_modules ports: - "5901:5901" # VNC - "3000:3000" # optional HTTP server if you add one tmpfs: - /tmp:size=512M - restart: unless-stopped - -volumes: - node_modules: +# volumes: +# node_modules: diff --git a/docs/docker-compose-examples.md b/docs/docker-compose-examples.md index 21590d3..9349914 100644 --- a/docs/docker-compose-examples.md +++ b/docs/docker-compose-examples.md @@ -164,4 +164,4 @@ services: networks: - backend ``` -This allows the Teto bot to communicate with `vllm-openai`, `wyoming-whisper`, and `wyoming-piper` using their service names as hostnames. \ No newline at end of file +This allows the Teto bot to communicate with `vllm-openai`, `wyoming-whisper`, and `wyoming-piper` using their service names as hostnames. diff --git a/src/services/commandHandler.js b/src/services/commandHandler.js index 0bab52f..e8a114f 100644 --- a/src/services/commandHandler.js +++ b/src/services/commandHandler.js @@ -1,4 +1,5 @@ import videoRecordingService from "./videoRecording.js"; +import webcamRecordingService from "./webcamRecording.js"; import { VIDEO_CONFIG } from "../config/videoConfig.js"; class CommandHandler { @@ -114,37 +115,50 @@ class CommandHandler { this.commands.set("status", { trigger: (msg) => msg.content.toLowerCase() === "teto status", execute: async (msg, context) => { - const status = videoRecordingService.getStatus(); + const videoStatus = videoRecordingService.getStatus(); + const webcamStatus = webcamRecordingService.getStatus(); const statusMsg = [ VIDEO_CONFIG.STATUS_TEMPLATE.TITLE, VIDEO_CONFIG.STATUS_TEMPLATE.ACTIVE_RECORDINGS.replace( "{count}", - status.activeRecordings + videoStatus.activeRecordings ), + `đŸŽĨ Active webcam recordings: ${webcamStatus.activeWebcamRecordings}`, VIDEO_CONFIG.STATUS_TEMPLATE.CONTAINER_OUTPUT.replace( "{path}", - status.outputDir + videoStatus.outputDir ), VIDEO_CONFIG.STATUS_TEMPLATE.HOST_OUTPUT, VIDEO_CONFIG.STATUS_TEMPLATE.VIDEO_FILES.replace( "{count}", - status.videoFiles + videoStatus.videoFiles ), - status.videoFiles > 0 + `đŸŽĨ Webcam files: ${webcamStatus.webcamFiles}`, + videoStatus.videoFiles > 0 ? VIDEO_CONFIG.STATUS_TEMPLATE.RECENT_FILES.replace( "{files}", - status.recentFiles.join(", ") + videoStatus.recentFiles.join(", ") ) : "", - status.activeRecordings > 0 + webcamStatus.webcamFiles > 0 + ? `📋 Recent webcam files: ${webcamStatus.recentWebcamFiles.join( + ", " + )}` + : "", + videoStatus.activeRecordings > 0 ? VIDEO_CONFIG.STATUS_TEMPLATE.CURRENTLY_RECORDING.replace( "{channels}", - status.activeRecordingsList + videoStatus.activeRecordingsList .map((r) => r.voiceChannelName) .join(", ") ) : "", + webcamStatus.activeWebcamRecordings > 0 + ? `đŸŽĨ Currently recording webcams in: ${webcamStatus.activeWebcamRecordingsList + .map((r) => r.voiceChannelName) + .join(", ")}` + : "", VIDEO_CONFIG.STATUS_TEMPLATE.UPTIME.replace( "{uptime}", Math.round(process.uptime()) @@ -166,6 +180,245 @@ class CommandHandler { }, }); + // Start webcam recording command + this.commands.set("start_webcam_recording", { + trigger: (msg) => + msg.content.toLowerCase() === "record webcam" || + msg.content.toLowerCase() === "xbox record webcam", + execute: async (msg, context) => { + const { client } = context; + + // Check if user is in a voice channel + const member = msg.guild?.members.cache.get(msg.author.id); + const voiceChannel = member?.voice?.channel; + + if (!voiceChannel) { + msg.channel.send(VIDEO_CONFIG.ERRORS.NO_VOICE_CHANNEL); + return null; + } + + msg.channel.send( + `đŸŽĨ Trying to start webcam recording... in ${voiceChannel.name}` + ); + + const result = await webcamRecordingService.startRecording({ + client, + voiceChannel, + user: msg.author, + textChannel: msg.channel, + }); + + if (result.success) { + msg.channel.send("đŸŽĨ Starting webcam recording... 📹"); + return { + action: { + message: `Started webcam recording ${msg.author.tag} in ${voiceChannel.name}`, + channel: `#${msg.channel.name}`, + icon: "đŸŽĨ", + }, + }; + } else { + if (result.message.includes("Already recording")) { + msg.channel.send(result.message); + } else if (result.message.includes("does not have webcam")) { + msg.channel.send(result.message); + } else { + msg.channel.send( + "Failed to start webcam recording. Check container logs for details!" + ); + } + return null; + } + }, + }); + + // Stop webcam recording command + this.commands.set("stop_webcam_recording", { + trigger: (msg) => + msg.content.toLowerCase() === "stop webcam" || + msg.content.toLowerCase() === "xbox stop webcam", + execute: async (msg, context) => { + const member = msg.guild?.members.cache.get(msg.author.id); + const voiceChannel = member?.voice?.channel; + + if ( + !voiceChannel || + !webcamRecordingService.isRecording(voiceChannel.id) + ) { + msg.channel.send("No active webcam recording in your voice channel!"); + return null; + } + + const result = await webcamRecordingService.stopRecording( + voiceChannel.id, + msg.channel, + "đŸŽĨ Webcam recording stopped by user." + ); + + if (result.success) { + return { + action: { + message: `Stopped webcam recording by ${msg.author.tag} in ${voiceChannel.name}`, + channel: `#${msg.channel.name}`, + icon: "âšī¸", + }, + }; + } + + return null; + }, + }); + + // Enhanced status command to include webcam recordings + this.commands.set("webcam_status", { + trigger: (msg) => msg.content.toLowerCase() === "webcam status", + execute: async (msg, context) => { + const videoStatus = videoRecordingService.getStatus(); + const webcamStatus = webcamRecordingService.getStatus(); + + const statusMsg = [ + "🤖 **Teto Recording Status (Docker)**", + `📹 Active stream recordings: ${videoStatus.activeRecordings}`, + `đŸŽĨ Active webcam recordings: ${webcamStatus.activeWebcamRecordings}`, + `📁 Container output: ${videoStatus.outputDir}`, + "📁 Host output: ./output (volume mounted)", + `đŸŽŦ Stream video files: ${videoStatus.videoFiles}`, + `đŸŽĨ Webcam video files: ${webcamStatus.webcamFiles}`, + videoStatus.videoFiles > 0 + ? `📋 Recent stream files: ${videoStatus.recentFiles.join(", ")}` + : "", + webcamStatus.webcamFiles > 0 + ? `📋 Recent webcam files: ${webcamStatus.recentWebcamFiles.join( + ", " + )}` + : "", + videoStatus.activeRecordings > 0 + ? `🔴 Currently recording streams in: ${videoStatus.activeRecordingsList + .map((r) => r.voiceChannelName) + .join(", ")}` + : "", + webcamStatus.activeWebcamRecordings > 0 + ? `đŸŽĨ Currently recording webcams in: ${webcamStatus.activeWebcamRecordingsList + .map((r) => r.voiceChannelName) + .join(", ")}` + : "", + `⏰ Bot uptime: ${Math.round(process.uptime())}s`, + "đŸŗ Running in Docker container", + ] + .filter((line) => line) + .join("\n"); + + msg.channel.send(statusMsg); + + return { + action: { + message: `Webcam status requested by ${msg.author.tag}`, + channel: `#${msg.channel.name || "DM"}`, + icon: "📊", + }, + }; + }, + }); + + // Debug voice state command + this.commands.set("debug_voice", { + trigger: (msg) => msg.content.toLowerCase() === "debug voice", + execute: async (msg, context) => { + const member = msg.guild?.members.cache.get(msg.author.id); + const voiceChannel = member?.voice?.channel; + + if (!voiceChannel) { + msg.channel.send( + "❌ You need to be in a voice channel for voice state debugging!" + ); + return null; + } + + const voiceState = + voiceChannel.guild?.voiceStates.cache.get(msg.author.id) || + context.client.voiceStates.cache.get(msg.author.id); + + const debugInfo = [ + "🔍 **Voice State Debug Info**", + `📍 Channel: ${voiceChannel.name} (${voiceChannel.id})`, + `👤 User: ${msg.author.tag} (${msg.author.id})`, + "", + "**Voice State Properties:**", + `â€ĸ Has voice state: ${!!voiceState}`, + `â€ĸ Self video: ${voiceState?.selfVideo ?? "undefined"}`, + `â€ĸ Streaming: ${voiceState?.streaming ?? "undefined"}`, + `â€ĸ Server mute: ${voiceState?.serverMute ?? "undefined"}`, + `â€ĸ Server deaf: ${voiceState?.serverDeaf ?? "undefined"}`, + `â€ĸ Self mute: ${voiceState?.selfMute ?? "undefined"}`, + `â€ĸ Self deaf: ${voiceState?.selfDeaf ?? "undefined"}`, + `â€ĸ Channel ID: ${voiceState?.channelId ?? "undefined"}`, + `â€ĸ Session ID: ${voiceState?.sessionId ?? "undefined"}`, + "", + "**Other Users in Channel:**", + ...voiceChannel.members.map((m) => { + const otherState = voiceChannel.guild?.voiceStates.cache.get(m.id); + return `â€ĸ ${m.user.tag}: selfVideo=${otherState?.selfVideo}, streaming=${otherState?.streaming}`; + }), + ].join("\n"); + + msg.channel.send(debugInfo); + + return { + action: { + message: `Voice debug requested by ${msg.author.tag}`, + channel: `#${msg.channel.name}`, + icon: "🔍", + }, + }; + }, + }); + + // Help command + this.commands.set("help", { + trigger: (msg) => + msg.content.toLowerCase() === "teto help" || + msg.content.toLowerCase() === "help", + execute: async (msg, context) => { + const helpMsg = [ + "🤖 **Teto Recording Bot Commands**", + "", + "**📹 Stream Recording:**", + "`xbox record that` - Start recording screen share", + "`stop recording` or `xbox stop` - Stop stream recording", + "", + "**đŸŽĨ Webcam Recording:**", + "`record webcam` or `xbox record webcam` - Start recording webcam", + "`stop webcam` or `xbox stop webcam` - Stop webcam recording", + "", + "**📊 Status & Info:**", + "`teto status` - Show overall recording status", + "`webcam status` - Show detailed webcam status", + "`debug voice` - Show voice state debug info", + "`teto help` or `help` - Show this help message", + "", + "**đŸ’Ŧ General:**", + "`hello teto` - Say hello", + "", + "**Notes:**", + "â€ĸ You must be in a voice channel to start recordings", + "â€ĸ For webcam recording, you must have your camera turned ON in Discord", + "â€ĸ Recordings auto-stop after 30 seconds (demo limit)", + "â€ĸ Both stream and webcam can be recorded simultaneously", + "â€ĸ Files are saved to ./output/ directory", + ].join("\n"); + + msg.channel.send(helpMsg); + + return { + action: { + message: `Help requested by ${msg.author.tag}`, + channel: `#${msg.channel.name || "DM"}`, + icon: "❓", + }, + }; + }, + }); + // Hello teto command this.commands.set("hello", { trigger: (msg) => msg.content.toLowerCase().includes("hello teto"), diff --git a/src/services/videoRecording.js b/src/services/videoRecording.js index a1921e6..0525990 100644 --- a/src/services/videoRecording.js +++ b/src/services/videoRecording.js @@ -64,6 +64,14 @@ class VideoRecordingService { // Set up video stream event handlers this._setupVideoStreamHandlers(videoStream, textChannel, voiceChannel.id); + // Set up voice connection event handlers for auto-stop + this._setupVoiceConnectionHandlers( + connection, + user.id, + voiceChannel.id, + textChannel + ); + // Store recording info const recordingInfo = { connection, @@ -76,6 +84,7 @@ class VideoRecordingService { textChannelId: textChannel.id, voiceChannelId: voiceChannel.id, voiceChannelName: voiceChannel.name, + targetUserId: user.id, }; activeRecordings.set(voiceChannel.id, recordingInfo); @@ -239,6 +248,58 @@ class VideoRecordingService { }); } + _setupVoiceConnectionHandlers( + connection, + targetUserId, + voiceChannelId, + textChannel + ) { + // Listen for streaming events to detect when screenshare is turned off + connection.on("startStreaming", ({ video_ssrc, user_id, audio_ssrc }) => { + console.log( + `[Docker] Video streaming event - User: ${user_id}, Video SSRC: ${video_ssrc}, Target: ${targetUserId}` + ); + + // Check if this is our target user and they turned off their screenshare + if (user_id === targetUserId && (video_ssrc === 0 || !video_ssrc)) { + console.log( + `[Docker] Target user ${targetUserId} turned off screenshare, stopping video recording` + ); + this.stopRecording( + voiceChannelId, + textChannel, + "📹 Video recording stopped - user turned off screenshare." + ); + } + }); + + // Listen for user disconnect events + connection.on("disconnect", (userId) => { + if (userId === targetUserId) { + console.log( + `[Docker] Target user ${targetUserId} disconnected, stopping video recording` + ); + this.stopRecording( + voiceChannelId, + textChannel, + "📹 Video recording stopped - user disconnected." + ); + } + }); + + // Listen for connection close + connection.on("close", () => { + console.log(`[Docker] Voice connection closed, stopping video recording`); + if (this.isRecording(voiceChannelId)) { + this.stopRecording( + voiceChannelId, + textChannel, + "📹 Video recording stopped - voice connection closed." + ); + } + }); + } + _setupAutoStop(voiceChannelId, textChannel) { setTimeout(() => { if (activeRecordings.has(voiceChannelId)) { @@ -252,6 +313,13 @@ class VideoRecordingService { } _cleanupRecording(recording) { + // Remove all event listeners first + if (recording.connection) { + recording.connection.removeAllListeners("startStreaming"); + recording.connection.removeAllListeners("disconnect"); + recording.connection.removeAllListeners("close"); + } + if (recording.videoStream) { recording.videoStream.destroy(); } diff --git a/src/services/webcamRecording.js b/src/services/webcamRecording.js new file mode 100644 index 0000000..d7c2677 --- /dev/null +++ b/src/services/webcamRecording.js @@ -0,0 +1,397 @@ +import fs from "fs"; +import path from "path"; +import { createWriteStream } from "fs"; +import { VIDEO_CONFIG } from "../config/videoConfig.js"; + +// Store active webcam recordings +const activeWebcamRecordings = new Map(); + +class WebcamRecordingService { + constructor() { + this.outputDir = VIDEO_CONFIG.OUTPUT_DIR; + this.autoStopTimeout = VIDEO_CONFIG.AUTO_STOP_TIMEOUT; + this.processingDelay = VIDEO_CONFIG.PROCESSING_DELAY; + } + + /** + * Start recording a user's webcam in a voice channel + * @param {Object} options - Recording options + * @param {Object} options.client - Discord client + * @param {Object} options.voiceChannel - Voice channel to record in + * @param {Object} options.user - User to record + * @param {Object} options.textChannel - Text channel for notifications + * @returns {Promise} Recording result + */ + async startRecording({ client, voiceChannel, user, textChannel }) { + try { + // Check if already recording webcam + if (activeWebcamRecordings.has(voiceChannel.id)) { + return { + success: false, + message: "Already recording webcam in this channel!", + }; + } + + // Ensure output directory exists + this._ensureOutputDirectory(); + + // Create unique filename with timestamp + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const webcamFilename = `webcam-${timestamp}${VIDEO_CONFIG.VIDEO_EXTENSION}`; + const webcamPath = path.join(this.outputDir, webcamFilename); + + console.log( + `[Docker] Attempting to join voice channel for webcam recording: ${voiceChannel.name}` + ); + + // Join the voice channel + const connection = await client.voice.joinChannel( + voiceChannel.id, + VIDEO_CONFIG.VOICE_SETTINGS + ); + + console.log( + `[Docker] Successfully joined voice channel for webcam recording` + ); + + // Wait for connection to be fully established + await new Promise((resolve) => setTimeout(resolve, 2000)); + + console.log(`[Docker] Creating webcam stream for user ${user.id}`); + + // Create webcam stream directly through voice connection receiver + const webcamStream = connection.receiver.createWebcamStream( + user.id, + createWriteStream(webcamPath) + ); + + // Set up webcam stream event handlers + this._setupWebcamStreamHandlers( + webcamStream, + textChannel, + voiceChannel.id + ); + + // Set up voice connection event handlers for auto-stop + this._setupVoiceConnectionHandlers( + connection, + user.id, + voiceChannel.id, + textChannel + ); + + // Store recording info + const recordingInfo = { + connection, + webcamStream, + webcamFilename, + webcamPath, + startTime: Date.now(), + userId: user.id, + textChannelId: textChannel.id, + voiceChannelId: voiceChannel.id, + voiceChannelName: voiceChannel.name, + targetUserId: user.id, + }; + + activeWebcamRecordings.set(voiceChannel.id, recordingInfo); + + console.log(`[Docker] Started webcam recording in ${voiceChannel.name}`); + console.log(`[Docker] Webcam output will be saved to: ${this.outputDir}`); + + // Set up auto-stop timeout + this._setupAutoStop(voiceChannel.id, textChannel); + + return { + success: true, + message: "Webcam recording started successfully", + recordingInfo, + }; + } catch (error) { + console.error("[Docker] Error starting webcam recording:", error); + + // Handle specific webcam errors + if (error.message === "VOICE_USER_NO_WEBCAM") { + return { + success: false, + message: "❌ User does not have webcam/camera enabled.", + error: error.message, + }; + } + + return { + success: false, + message: "Failed to start webcam recording", + error: error.message, + }; + } + } + + /** + * Stop webcam recording in a voice channel + * @param {string} voiceChannelId - Voice channel ID + * @param {Object} textChannel - Text channel for notifications + * @param {string} stopMessage - Message to display when stopping + * @returns {Object} Stop result + */ + async stopRecording( + voiceChannelId, + textChannel, + stopMessage = "Webcam recording stopped." + ) { + const recording = activeWebcamRecordings.get(voiceChannelId); + + if (!recording) { + return { + success: false, + message: "No active webcam recording found", + }; + } + + try { + console.log( + `[Docker] Stopping webcam recording for user: ${recording.userId}` + ); + + // Clean up streams and connections + this._cleanupRecording(recording); + + // Remove from active recordings + activeWebcamRecordings.delete(voiceChannelId); + + const duration = Math.round((Date.now() - recording.startTime) / 1000); + + textChannel.send( + `${stopMessage} Duration: ${duration}s. Processing webcam file...` + ); + + // Process the webcam file after a delay + setTimeout(() => { + this._processWebcamFile(recording, textChannel, duration); + }, this.processingDelay); + + return { + success: true, + message: "Webcam recording stopped successfully", + duration, + filename: recording.webcamFilename, + }; + } catch (error) { + console.error("[Docker] Error stopping webcam recording:", error); + return { + success: false, + message: + "Error stopping webcam recording, but file may have been saved", + error: error.message, + }; + } + } + + /** + * Get status of all active webcam recordings and saved files + * @returns {Object} Status information + */ + getStatus() { + const activeCount = activeWebcamRecordings.size; + let webcamFileCount = 0; + let webcamFiles = []; + + try { + if (fs.existsSync(this.outputDir)) { + const dirContents = fs.readdirSync(this.outputDir); + webcamFiles = dirContents.filter( + (file) => + file.startsWith("webcam-") && + file.endsWith(VIDEO_CONFIG.VIDEO_EXTENSION) + ); + webcamFileCount = webcamFiles.length; + } + } catch (error) { + console.error("Error reading output directory for webcam files:", error); + } + + return { + activeWebcamRecordings: activeCount, + outputDir: this.outputDir, + webcamFiles: webcamFileCount, + recentWebcamFiles: webcamFiles.slice(-3), + activeWebcamRecordingsList: Array.from( + activeWebcamRecordings.values() + ).map((r) => ({ + voiceChannelName: r.voiceChannelName, + userId: r.userId, + startTime: r.startTime, + duration: Math.round((Date.now() - r.startTime) / 1000), + })), + }; + } + + /** + * Check if a voice channel has an active webcam recording + * @param {string} voiceChannelId - Voice channel ID + * @returns {boolean} Whether webcam recording is active + */ + isRecording(voiceChannelId) { + return activeWebcamRecordings.has(voiceChannelId); + } + + /** + * Get webcam recording info for a specific voice channel + * @param {string} voiceChannelId - Voice channel ID + * @returns {Object|null} Recording info or null if not found + */ + getRecordingInfo(voiceChannelId) { + return activeWebcamRecordings.get(voiceChannelId) || null; + } + + // Private methods + + _ensureOutputDirectory() { + if (!fs.existsSync(this.outputDir)) { + fs.mkdirSync(this.outputDir, { recursive: true }); + } + } + + _setupWebcamStreamHandlers(webcamStream, textChannel, voiceChannelId) { + webcamStream.on("ready", () => { + console.log("[Docker] FFmpeg process ready for webcam recording!"); + textChannel.send("đŸŽĨ Webcam recording started successfully!"); + webcamStream.stream.stderr.on("data", (data) => { + console.log(`[Docker] Webcam FFmpeg: ${data}`); + }); + }); + + webcamStream.on("error", (error) => { + console.error("[Docker] Webcam recording error:", error); + textChannel.send("đŸŽĨ Webcam recording encountered an error!"); + this._handleRecordingError(voiceChannelId); + }); + } + + _setupVoiceConnectionHandlers( + connection, + targetUserId, + voiceChannelId, + textChannel + ) { + // Listen for streaming events to detect when camera is turned off + connection.on("startStreaming", ({ video_ssrc, user_id, audio_ssrc }) => { + console.log( + `[Docker] Streaming event - User: ${user_id}, Video SSRC: ${video_ssrc}, Target: ${targetUserId}` + ); + + // Check if this is our target user and they turned off their camera + if (user_id === targetUserId && (video_ssrc === 0 || !video_ssrc)) { + console.log( + `[Docker] Target user ${targetUserId} turned off camera, stopping webcam recording` + ); + this.stopRecording( + voiceChannelId, + textChannel, + "đŸŽĨ Webcam recording stopped - user turned off camera." + ); + } + }); + + // Listen for user disconnect events + connection.on("disconnect", (userId) => { + if (userId === targetUserId) { + console.log( + `[Docker] Target user ${targetUserId} disconnected, stopping webcam recording` + ); + this.stopRecording( + voiceChannelId, + textChannel, + "đŸŽĨ Webcam recording stopped - user disconnected." + ); + } + }); + + // Listen for connection close + connection.on("close", () => { + console.log( + `[Docker] Voice connection closed, stopping webcam recording` + ); + if (this.isRecording(voiceChannelId)) { + this.stopRecording( + voiceChannelId, + textChannel, + "đŸŽĨ Webcam recording stopped - voice connection closed." + ); + } + }); + } + + _setupAutoStop(voiceChannelId, textChannel) { + setTimeout(() => { + if (activeWebcamRecordings.has(voiceChannelId)) { + this.stopRecording( + voiceChannelId, + textChannel, + "đŸŽĨ Webcam recording stopped automatically after 30 seconds (demo limit)." + ); + } + }, this.autoStopTimeout); + } + + _cleanupRecording(recording) { + // Remove all event listeners first + if (recording.connection) { + recording.connection.removeAllListeners("startStreaming"); + recording.connection.removeAllListeners("disconnect"); + recording.connection.removeAllListeners("close"); + } + + if (recording.webcamStream) { + recording.webcamStream.destroy(); + } + if (recording.connection) { + recording.connection.disconnect(); + } + } + + _handleRecordingError(voiceChannelId) { + const recording = activeWebcamRecordings.get(voiceChannelId); + if (recording) { + this._cleanupRecording(recording); + activeWebcamRecordings.delete(voiceChannelId); + } + } + + _processWebcamFile(recording, textChannel, duration) { + try { + if (fs.existsSync(recording.webcamPath)) { + const stats = fs.statSync(recording.webcamPath); + if (stats.size > 0) { + textChannel.send( + `đŸŽĨ Webcam recording saved as: ${recording.webcamFilename}` + ); + textChannel.send( + "📁 File location: Container: /tmp/output/ | Host: ./output/" + ); + console.log( + `[Docker] Webcam recording completed: ${recording.webcamFilename}, Duration: ${duration}s` + ); + } else { + textChannel.send( + `❌ No webcam video data was captured during the recording.` + ); + fs.unlinkSync(recording.webcamPath); // Delete empty file + } + } else { + textChannel.send( + `❌ Webcam recording file was not created in container.` + ); + } + } catch (error) { + console.error("[Docker] Error processing webcam file:", error); + textChannel.send( + `âš ī¸ Webcam recording may have been saved but processing failed. File: ${recording.webcamFilename}` + ); + } + } +} + +// Export singleton instance +export const webcamRecordingService = new WebcamRecordingService(); +export default webcamRecordingService;