Compare commits

..

No commits in common. "603032f1dbeebef014122f1ecb62f241e7da2d46" and "2e94820164e7955b86fe574d0cb6573b0ad41f33" have entirely different histories.

10 changed files with 22 additions and 1128 deletions

2
.gitmodules vendored
View file

@ -1,3 +1,3 @@
[submodule "discord.js-selfbot-v13"] [submodule "discord.js-selfbot-v13"]
path = discord.js-selfbot-v13 path = discord.js-selfbot-v13
url = https://git.opencodebox.work/MikolajG/discord.js-selfbot-v13.git url = ssh://git@ssh.opencodebox.work:23/MikolajG/discord.js-selfbot-v13.git

View file

@ -1,12 +1,6 @@
# --- Stage 1: Install node_modules --- # --- Stage 1: Install node_modules ---
FROM node:20-slim as dependencies 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 WORKDIR /opt/bot
COPY package*.json ./ COPY package*.json ./
RUN npm install --no-audit --no-fund RUN npm install --no-audit --no-fund

@ -1 +1 @@
Subproject commit 174c89f18e2171461d838b0c1586acbabb9be7e1 Subproject commit 683b280067068958a22411d414d0a581b65d3813

View file

@ -20,11 +20,13 @@ services:
# Mount the current directory into the container for live-reloading # Mount the current directory into the container for live-reloading
- .:/opt/bot - .:/opt/bot
# Use a named volume to keep the container's node_modules from being overwritten # 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: ports:
- "5901:5901" # VNC - "5901:5901" # VNC
- "3000:3000" # optional HTTP server if you add one - "3000:3000" # optional HTTP server if you add one
tmpfs: tmpfs:
- /tmp:size=512M - /tmp:size=512M
# volumes: restart: unless-stopped
# node_modules:
volumes:
node_modules:

View file

@ -13,12 +13,12 @@ openbox &
x11vnc -display :99 -shared -forever -nopw -rfbport 5901 -bg & x11vnc -display :99 -shared -forever -nopw -rfbport 5901 -bg &
# Launch discord in the background. THIS IS THE FIX. # Launch discord in the background. THIS IS THE FIX.
# discord \ discord \
# --no-sandbox \ --no-sandbox \
# --disable-dev-shm-usage \ --disable-dev-shm-usage \
# --disable-gpu \ --disable-gpu \
# --disable-background-timer-throttling \ --disable-background-timer-throttling \
# --disable-renderer-backgrounding & --disable-renderer-backgrounding &
# Give Discord a moment to start before launching the controller script # Give Discord a moment to start before launching the controller script
sleep 10 sleep 10

View file

@ -1,5 +1,4 @@
import videoRecordingService from "./videoRecording.js"; import videoRecordingService from "./videoRecording.js";
import webcamRecordingService from "./webcamRecording.js";
import { VIDEO_CONFIG } from "../config/videoConfig.js"; import { VIDEO_CONFIG } from "../config/videoConfig.js";
class CommandHandler { class CommandHandler {
@ -115,50 +114,37 @@ class CommandHandler {
this.commands.set("status", { this.commands.set("status", {
trigger: (msg) => msg.content.toLowerCase() === "teto status", trigger: (msg) => msg.content.toLowerCase() === "teto status",
execute: async (msg, context) => { execute: async (msg, context) => {
const videoStatus = videoRecordingService.getStatus(); const status = videoRecordingService.getStatus();
const webcamStatus = webcamRecordingService.getStatus();
const statusMsg = [ const statusMsg = [
VIDEO_CONFIG.STATUS_TEMPLATE.TITLE, VIDEO_CONFIG.STATUS_TEMPLATE.TITLE,
VIDEO_CONFIG.STATUS_TEMPLATE.ACTIVE_RECORDINGS.replace( VIDEO_CONFIG.STATUS_TEMPLATE.ACTIVE_RECORDINGS.replace(
"{count}", "{count}",
videoStatus.activeRecordings status.activeRecordings
), ),
`🎥 Active webcam recordings: ${webcamStatus.activeWebcamRecordings}`,
VIDEO_CONFIG.STATUS_TEMPLATE.CONTAINER_OUTPUT.replace( VIDEO_CONFIG.STATUS_TEMPLATE.CONTAINER_OUTPUT.replace(
"{path}", "{path}",
videoStatus.outputDir status.outputDir
), ),
VIDEO_CONFIG.STATUS_TEMPLATE.HOST_OUTPUT, VIDEO_CONFIG.STATUS_TEMPLATE.HOST_OUTPUT,
VIDEO_CONFIG.STATUS_TEMPLATE.VIDEO_FILES.replace( VIDEO_CONFIG.STATUS_TEMPLATE.VIDEO_FILES.replace(
"{count}", "{count}",
videoStatus.videoFiles status.videoFiles
), ),
`🎥 Webcam files: ${webcamStatus.webcamFiles}`, status.videoFiles > 0
videoStatus.videoFiles > 0
? VIDEO_CONFIG.STATUS_TEMPLATE.RECENT_FILES.replace( ? VIDEO_CONFIG.STATUS_TEMPLATE.RECENT_FILES.replace(
"{files}", "{files}",
videoStatus.recentFiles.join(", ") status.recentFiles.join(", ")
) )
: "", : "",
webcamStatus.webcamFiles > 0 status.activeRecordings > 0
? `📋 Recent webcam files: ${webcamStatus.recentWebcamFiles.join(
", "
)}`
: "",
videoStatus.activeRecordings > 0
? VIDEO_CONFIG.STATUS_TEMPLATE.CURRENTLY_RECORDING.replace( ? VIDEO_CONFIG.STATUS_TEMPLATE.CURRENTLY_RECORDING.replace(
"{channels}", "{channels}",
videoStatus.activeRecordingsList status.activeRecordingsList
.map((r) => r.voiceChannelName) .map((r) => r.voiceChannelName)
.join(", ") .join(", ")
) )
: "", : "",
webcamStatus.activeWebcamRecordings > 0
? `🎥 Currently recording webcams in: ${webcamStatus.activeWebcamRecordingsList
.map((r) => r.voiceChannelName)
.join(", ")}`
: "",
VIDEO_CONFIG.STATUS_TEMPLATE.UPTIME.replace( VIDEO_CONFIG.STATUS_TEMPLATE.UPTIME.replace(
"{uptime}", "{uptime}",
Math.round(process.uptime()) Math.round(process.uptime())
@ -180,362 +166,6 @@ 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);
// Get voice connection and SSRC info if available
const voiceConnection = context.client.voice.connections.get(
voiceChannel.guild.id
);
let ssrcInfo = [];
if (voiceConnection && voiceConnection.ssrcMap) {
ssrcInfo = [
"",
"**🔊 SSRC Mapping:**",
`• Connection status: ${voiceConnection.status}`,
`• Total mapped SSRCs: ${voiceConnection.ssrcMap.size}`,
...Array.from(voiceConnection.ssrcMap.entries()).map(
([ssrc, data]) => {
const streamType = data.streamType
? ` (${data.streamType})`
: "";
const quality = data.quality ? ` Q:${data.quality}` : "";
return `• SSRC ${ssrc}: ${data.userId}${streamType}${quality}`;
}
),
];
}
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}`;
}),
...ssrcInfo,
].join("\n");
msg.channel.send(debugInfo);
return {
action: {
message: `Voice debug requested by ${msg.author.tag}`,
channel: `#${msg.channel.name}`,
icon: "🔍",
},
};
},
});
// Test voice connectivity command
this.commands.set("test_voice", {
trigger: (msg) => msg.content.toLowerCase() === "test 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 to test voice connectivity!"
);
return null;
}
msg.channel.send("🔧 Testing basic voice server connectivity...");
try {
const testStart = Date.now();
// Simple connection test with timeout promise
const connectionPromise = context.client.voice.joinChannel(
voiceChannel.id,
{
selfMute: true,
selfDeaf: true,
selfVideo: false,
}
);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(
() => reject(new Error("Manual timeout after 45 seconds")),
45000
);
});
const connection = await Promise.race([
connectionPromise,
timeoutPromise,
]);
const connectTime = Date.now() - testStart;
msg.channel.send(
`✅ Voice connection successful!\n` +
`• Connect time: ${connectTime}ms\n` +
`• Status: ${connection.status}\n` +
`• Channel: ${voiceChannel.name}\n` +
`• Authentication: ${
connection.authentication ? "Ready" : "Pending"
}`
);
// Disconnect after test
setTimeout(() => {
try {
connection.disconnect();
} catch (e) {
console.log(
"[Docker] Error disconnecting test connection:",
e.message
);
}
}, 3000);
return {
action: {
message: `Voice connectivity test by ${msg.author.tag}`,
channel: `#${msg.channel.name}`,
icon: "🔧",
},
};
} catch (error) {
const connectTime = Date.now() - testStart;
msg.channel.send(
`❌ Voice connection failed after ${connectTime}ms!\n` +
`• Error: ${error.message}\n` +
`• This indicates Discord voice server or network issues\n` +
`• Try switching voice channels or try again later`
);
return {
action: {
message: `Voice connectivity test failed 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",
"`test voice` - Test voice server connectivity",
"`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 // Hello teto command
this.commands.set("hello", { this.commands.set("hello", {
trigger: (msg) => msg.content.toLowerCase().includes("hello teto"), trigger: (msg) => msg.content.toLowerCase().includes("hello teto"),

View file

@ -64,14 +64,6 @@ class VideoRecordingService {
// Set up video stream event handlers // Set up video stream event handlers
this._setupVideoStreamHandlers(videoStream, textChannel, voiceChannel.id); 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 // Store recording info
const recordingInfo = { const recordingInfo = {
connection, connection,
@ -84,7 +76,6 @@ class VideoRecordingService {
textChannelId: textChannel.id, textChannelId: textChannel.id,
voiceChannelId: voiceChannel.id, voiceChannelId: voiceChannel.id,
voiceChannelName: voiceChannel.name, voiceChannelName: voiceChannel.name,
targetUserId: user.id,
}; };
activeRecordings.set(voiceChannel.id, recordingInfo); activeRecordings.set(voiceChannel.id, recordingInfo);
@ -248,54 +239,6 @@ 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 }) => {
// 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) { _setupAutoStop(voiceChannelId, textChannel) {
setTimeout(() => { setTimeout(() => {
if (activeRecordings.has(voiceChannelId)) { if (activeRecordings.has(voiceChannelId)) {
@ -309,13 +252,6 @@ class VideoRecordingService {
} }
_cleanupRecording(recording) { _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) { if (recording.videoStream) {
recording.videoStream.destroy(); recording.videoStream.destroy();
} }

View file

@ -1,668 +0,0 @@
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<Object>} 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 with retry logic
let connection;
let connectionRetries = 0;
const maxConnectionRetries = 3;
while (connectionRetries < maxConnectionRetries) {
try {
connection = await client.voice.joinChannel(
voiceChannel.id,
VIDEO_CONFIG.VOICE_SETTINGS
);
console.log(
`[Docker] Successfully joined voice channel for webcam recording`
);
break;
} catch (error) {
connectionRetries++;
console.log(
`[Docker] Voice connection failed (attempt ${connectionRetries}/${maxConnectionRetries}):`,
error.message
);
if (connectionRetries === maxConnectionRetries) {
throw new Error(
`Failed to join voice channel after ${maxConnectionRetries} attempts: ${error.message}`
);
}
// Wait before retry with exponential backoff
const delay = Math.min(
5000 * Math.pow(2, connectionRetries - 1),
30000
);
console.log(`[Docker] Retrying voice connection in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
// Wait longer for connection and SSRC mapping to be fully established
await new Promise((resolve) => setTimeout(resolve, 8000));
// Wait for SSRC mapping to be populated
let ssrcRetries = 0;
const maxSSRCRetries = 15;
while (ssrcRetries < maxSSRCRetries) {
if (connection.ssrcMap && connection.ssrcMap.size > 0) {
console.log(
`[Docker] SSRC mapping ready with ${connection.ssrcMap.size} entries after ${ssrcRetries} retries`
);
break;
}
console.log(
`[Docker] Waiting for SSRC mapping... (${
ssrcRetries + 1
}/${maxSSRCRetries})`
);
await new Promise((resolve) => setTimeout(resolve, 1000));
ssrcRetries++;
}
// Wait for user's camera to be detected in voice state
let retries = 0;
const maxRetries = 10;
while (retries < maxRetries) {
const voiceState = voiceChannel.guild?.voiceStates.cache.get(user.id);
if (voiceState && voiceState.selfVideo) {
console.log(
`[Docker] Camera detected for user ${user.id} after ${retries} retries`
);
break;
}
console.log(
`[Docker] Waiting for camera to be detected for user ${user.id}... (${
retries + 1
}/${maxRetries})`
);
await new Promise((resolve) => setTimeout(resolve, 2000));
retries++;
}
if (retries === maxRetries) {
console.log(
`[Docker] Camera not detected after ${maxRetries} retries, proceeding anyway...`
);
}
// Log final SSRC state before starting recording
console.log(
`[Docker] Starting webcam recording with SSRC map:`,
Array.from(connection.ssrcMap.entries()).map(([ssrc, data]) => ({
ssrc,
userId: data.userId?.slice(-4),
streamType: data.streamType,
hasVideo: data.hasVideo,
}))
);
console.log(`[Docker] Creating webcam stream for user ${user.id}`);
// Create webcam stream with retry logic and connection monitoring
let webcamStream;
let streamRetries = 0;
const maxStreamRetries = 3;
while (streamRetries < maxStreamRetries) {
try {
webcamStream = connection.receiver.createWebcamStream(
user.id,
createWriteStream(webcamPath)
);
console.log(`[Docker] Webcam stream created successfully`);
break;
} catch (error) {
streamRetries++;
console.log(
`[Docker] Failed to create webcam stream (attempt ${streamRetries}/${maxStreamRetries}):`,
error.message
);
if (streamRetries === maxStreamRetries) {
throw error;
}
// Wait before retry and check connection health
await new Promise((resolve) => setTimeout(resolve, 2000));
// Check if connection is still healthy
if (!this._isConnectionHealthy(connection)) {
console.log(
`[Docker] Connection status changed to: ${connection.status}`
);
throw new Error("Voice connection lost during stream creation");
}
}
}
// 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 error types
if (error.message === "VOICE_USER_NO_WEBCAM") {
return {
success: false,
message: "❌ User does not have webcam/camera enabled.",
error: error.message,
};
}
if (error.message.includes("VOICE_CONNECTION_TIMEOUT")) {
return {
success: false,
message:
"❌ Voice connection timed out. Discord voice servers may be slow or unavailable.",
error: "VOICE_CONNECTION_TIMEOUT",
};
}
if (error.message.includes("Failed to join voice channel")) {
return {
success: false,
message:
"❌ Could not connect to voice channel after multiple attempts. Try again later.",
error: "VOICE_CONNECTION_FAILED",
};
}
if (error.message.includes("Voice connection lost")) {
return {
success: false,
message: "❌ Voice connection was lost during setup.",
error: "VOICE_CONNECTION_LOST",
};
}
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) {
let frameCount = 0;
let lastFrameTime = Date.now();
let dataReceived = false;
let connectionTimeouts = 0;
const maxConnectionTimeouts = 3;
webcamStream.on("ready", () => {
console.log("[Docker] FFmpeg process ready for webcam recording!");
textChannel.send("🎥 Webcam recording started successfully!");
webcamStream.stream.stderr.on("data", (data) => {
const dataStr = data.toString();
dataReceived = true;
// Only log important FFmpeg messages to reduce spam
if (
dataStr.includes("frame=") ||
dataStr.includes("error") ||
dataStr.includes("timeout")
) {
console.log(`[Docker] Webcam FFmpeg: ${dataStr}`);
}
// Track frame production
const frameMatch = dataStr.match(/frame=\s*(\d+)/);
if (frameMatch) {
frameCount = parseInt(frameMatch[1]);
lastFrameTime = Date.now();
connectionTimeouts = 0; // Reset timeout counter on successful frames
}
// Check for connection timeouts
if (
dataStr.includes("Connection timed out") ||
dataStr.includes("pipe:")
) {
connectionTimeouts++;
console.log(
`[Docker] FFmpeg connection timeout detected (${connectionTimeouts}/${maxConnectionTimeouts})`
);
if (connectionTimeouts >= maxConnectionTimeouts) {
console.log(
"[Docker] Too many connection timeouts, stopping webcam recording"
);
textChannel.send(
"⚠️ Webcam recording stopped - connection unstable."
);
this.stopRecording(
voiceChannelId,
textChannel,
"🎥 Webcam recording stopped - connection timeouts."
);
}
}
});
// Monitor for frame production - stop if no frames after 45 seconds (increased from 30)
setTimeout(() => {
if (frameCount === 0) {
console.log(
"[Docker] No frames produced after 45 seconds, stopping webcam recording"
);
textChannel.send(
"⚠️ Webcam recording stopped - no video frames were captured. User may not have camera enabled."
);
this.stopRecording(
voiceChannelId,
textChannel,
"🎥 Webcam recording stopped - no frames detected."
);
}
}, 45000);
// Check for stalled recording every 30 seconds (reduced from 60)
const stallCheckInterval = setInterval(() => {
const timeSinceLastFrame = Date.now() - lastFrameTime;
// More aggressive stall detection
if (frameCount > 0 && timeSinceLastFrame > 45000) {
console.log(
`[Docker] Webcam recording stalled - no frames for ${timeSinceLastFrame}ms`
);
textChannel.send("⚠️ Webcam recording appears stalled - stopping.");
this.stopRecording(
voiceChannelId,
textChannel,
"🎥 Webcam recording stopped - stream stalled."
);
clearInterval(stallCheckInterval);
}
// Check if we're receiving any data at all
if (!dataReceived && Date.now() - lastFrameTime > 30000) {
console.log(
"[Docker] No FFmpeg data received, connection may be broken"
);
textChannel.send("⚠️ Webcam recording stopped - no data received.");
this.stopRecording(
voiceChannelId,
textChannel,
"🎥 Webcam recording stopped - no data received."
);
clearInterval(stallCheckInterval);
}
}, 30000);
// Store interval for cleanup
const recording = activeWebcamRecordings.get(voiceChannelId);
if (recording) {
recording.stallCheckInterval = stallCheckInterval;
}
});
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, streams }) => {
// Check if this is our target user
if (user_id === targetUserId) {
// Check if any stream is active for video
const hasActiveVideo =
streams &&
streams.some((stream) => stream.active && stream.type === "video");
// Stop recording if no active video streams or video_ssrc is 0
if (!hasActiveVideo || video_ssrc === 0 || !video_ssrc) {
console.log(
`[Docker] Target user ${targetUserId} turned off camera (active video: ${hasActiveVideo}, video_ssrc: ${video_ssrc}), 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) {
// Clear any monitoring intervals
if (recording.stallCheckInterval) {
clearInterval(recording.stallCheckInterval);
}
// 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}`
);
}
}
/**
* Check if voice connection is healthy
* @param {Object} connection - Voice connection object
* @returns {boolean} Whether connection is healthy
*/
_isConnectionHealthy(connection) {
if (!connection) return false;
const healthyStatuses = ["connected", "connecting", "ready"];
return healthyStatuses.includes(connection.status);
}
}
// Export singleton instance
export const webcamRecordingService = new WebcamRecordingService();
export default webcamRecordingService;