- Add 3-attempt retry logic with exponential backoff for voice connections - Add connection health checking during stream creation - Add specific error messages for different connection failure types - Add 'test voice' command for connectivity diagnostics - Better error handling for VOICE_CONNECTION_TIMEOUT scenarios - Should significantly improve connection reliability
588 lines
19 KiB
JavaScript
588 lines
19 KiB
JavaScript
import videoRecordingService from "./videoRecording.js";
|
|
import webcamRecordingService from "./webcamRecording.js";
|
|
import { VIDEO_CONFIG } from "../config/videoConfig.js";
|
|
|
|
class CommandHandler {
|
|
constructor() {
|
|
this.commands = new Map();
|
|
this._registerCommands();
|
|
}
|
|
|
|
_registerCommands() {
|
|
// DM pickup command
|
|
this.commands.set("dm_pickup", {
|
|
trigger: (msg) =>
|
|
msg.channel.type === "DM" && msg.content.toLowerCase().includes("teto"),
|
|
execute: async (msg, context) => {
|
|
msg.channel.send("I'm here! What do you need?");
|
|
return {
|
|
action: {
|
|
message: `Picked up DM from ${msg.author.tag}`,
|
|
channel: "DM",
|
|
icon: "📞",
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
// Xbox record that command
|
|
this.commands.set("start_recording", {
|
|
trigger: (msg) => msg.content.toLowerCase() === "xbox record that",
|
|
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(
|
|
VIDEO_CONFIG.MESSAGES.RECORDING_STARTING.replace(
|
|
"{channelName}",
|
|
voiceChannel.name
|
|
)
|
|
);
|
|
|
|
const result = await videoRecordingService.startRecording({
|
|
client,
|
|
voiceChannel,
|
|
user: msg.author,
|
|
textChannel: msg.channel,
|
|
});
|
|
|
|
if (result.success) {
|
|
msg.channel.send(VIDEO_CONFIG.MESSAGES.RECORDING_STARTED);
|
|
return {
|
|
action: {
|
|
message: `Started video recording ${msg.author.tag} in ${voiceChannel.name}`,
|
|
channel: `#${msg.channel.name}`,
|
|
icon: "🎥",
|
|
},
|
|
};
|
|
} else {
|
|
if (result.message === VIDEO_CONFIG.ERRORS.ALREADY_RECORDING) {
|
|
msg.channel.send(result.message);
|
|
} else {
|
|
msg.channel.send(VIDEO_CONFIG.ERRORS.VOICE_CONNECTION_FAILED);
|
|
}
|
|
return null;
|
|
}
|
|
},
|
|
});
|
|
|
|
// Stop recording command
|
|
this.commands.set("stop_recording", {
|
|
trigger: (msg) =>
|
|
msg.content.toLowerCase() === "stop recording" ||
|
|
msg.content.toLowerCase() === "xbox stop",
|
|
execute: async (msg, context) => {
|
|
const member = msg.guild?.members.cache.get(msg.author.id);
|
|
const voiceChannel = member?.voice?.channel;
|
|
|
|
if (
|
|
!voiceChannel ||
|
|
!videoRecordingService.isRecording(voiceChannel.id)
|
|
) {
|
|
msg.channel.send(VIDEO_CONFIG.ERRORS.NO_ACTIVE_RECORDING);
|
|
return null;
|
|
}
|
|
|
|
const result = await videoRecordingService.stopRecording(
|
|
voiceChannel.id,
|
|
msg.channel,
|
|
VIDEO_CONFIG.MESSAGES.RECORDING_STOPPED
|
|
);
|
|
|
|
if (result.success) {
|
|
return {
|
|
action: {
|
|
message: `Stopped video recording by ${msg.author.tag} in ${voiceChannel.name}`,
|
|
channel: `#${msg.channel.name}`,
|
|
icon: "⏹️",
|
|
},
|
|
};
|
|
}
|
|
|
|
return null;
|
|
},
|
|
});
|
|
|
|
// Status command
|
|
this.commands.set("status", {
|
|
trigger: (msg) => msg.content.toLowerCase() === "teto status",
|
|
execute: async (msg, context) => {
|
|
const videoStatus = videoRecordingService.getStatus();
|
|
const webcamStatus = webcamRecordingService.getStatus();
|
|
|
|
const statusMsg = [
|
|
VIDEO_CONFIG.STATUS_TEMPLATE.TITLE,
|
|
VIDEO_CONFIG.STATUS_TEMPLATE.ACTIVE_RECORDINGS.replace(
|
|
"{count}",
|
|
videoStatus.activeRecordings
|
|
),
|
|
`🎥 Active webcam recordings: ${webcamStatus.activeWebcamRecordings}`,
|
|
VIDEO_CONFIG.STATUS_TEMPLATE.CONTAINER_OUTPUT.replace(
|
|
"{path}",
|
|
videoStatus.outputDir
|
|
),
|
|
VIDEO_CONFIG.STATUS_TEMPLATE.HOST_OUTPUT,
|
|
VIDEO_CONFIG.STATUS_TEMPLATE.VIDEO_FILES.replace(
|
|
"{count}",
|
|
videoStatus.videoFiles
|
|
),
|
|
`🎥 Webcam files: ${webcamStatus.webcamFiles}`,
|
|
videoStatus.videoFiles > 0
|
|
? VIDEO_CONFIG.STATUS_TEMPLATE.RECENT_FILES.replace(
|
|
"{files}",
|
|
videoStatus.recentFiles.join(", ")
|
|
)
|
|
: "",
|
|
webcamStatus.webcamFiles > 0
|
|
? `📋 Recent webcam files: ${webcamStatus.recentWebcamFiles.join(
|
|
", "
|
|
)}`
|
|
: "",
|
|
videoStatus.activeRecordings > 0
|
|
? VIDEO_CONFIG.STATUS_TEMPLATE.CURRENTLY_RECORDING.replace(
|
|
"{channels}",
|
|
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())
|
|
),
|
|
VIDEO_CONFIG.STATUS_TEMPLATE.DOCKER_INFO,
|
|
]
|
|
.filter((line) => line)
|
|
.join("\n");
|
|
|
|
msg.channel.send(statusMsg);
|
|
|
|
return {
|
|
action: {
|
|
message: `Status requested by ${msg.author.tag}`,
|
|
channel: `#${msg.channel.name || "DM"}`,
|
|
icon: "📊",
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
// 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 voice server connectivity...");
|
|
|
|
try {
|
|
const testStart = Date.now();
|
|
const connection = await context.client.voice.joinChannel(
|
|
voiceChannel.id,
|
|
{
|
|
selfMute: true,
|
|
selfDeaf: true,
|
|
selfVideo: false,
|
|
}
|
|
);
|
|
|
|
const connectTime = Date.now() - testStart;
|
|
|
|
msg.channel.send(
|
|
`✅ Voice connection successful!\n` +
|
|
`• Connect time: ${connectTime}ms\n` +
|
|
`• Voice server: ${connection.voiceServerURL || "Unknown"}\n` +
|
|
`• Status: ${connection.status}\n` +
|
|
`• Channel: ${voiceChannel.name}`
|
|
);
|
|
|
|
// Disconnect after test
|
|
setTimeout(() => {
|
|
connection.disconnect();
|
|
}, 2000);
|
|
|
|
return {
|
|
action: {
|
|
message: `Voice connectivity test by ${msg.author.tag}`,
|
|
channel: `#${msg.channel.name}`,
|
|
icon: "🔧",
|
|
},
|
|
};
|
|
} catch (error) {
|
|
msg.channel.send(
|
|
`❌ Voice connection failed!\n` +
|
|
`• Error: ${error.message}\n` +
|
|
`• This may indicate Discord voice server issues\n` +
|
|
`• Try again in a few minutes`
|
|
);
|
|
|
|
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
|
|
this.commands.set("hello", {
|
|
trigger: (msg) => msg.content.toLowerCase().includes("hello teto"),
|
|
execute: async (msg, context) => {
|
|
msg.channel.send("Hello there!");
|
|
return {
|
|
action: {
|
|
message: `Responded to ${msg.author.tag}`,
|
|
channel: `#${msg.channel.name || "DM"}`,
|
|
icon: "💬",
|
|
},
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Process a message and execute matching commands
|
|
* @param {Object} msg - Discord message object
|
|
* @param {Object} context - Context object with client, logItem, etc.
|
|
* @returns {Promise<Array>} Array of actions to log
|
|
*/
|
|
async processMessage(msg, context) {
|
|
const actions = [];
|
|
|
|
for (const [commandName, command] of this.commands) {
|
|
if (command.trigger(msg)) {
|
|
try {
|
|
const result = await command.execute(msg, context);
|
|
if (result && result.action) {
|
|
actions.push(result.action);
|
|
}
|
|
// Only execute the first matching command
|
|
break;
|
|
} catch (error) {
|
|
console.error(`Error executing command ${commandName}:`, error);
|
|
msg.channel.send("An error occurred while processing your command.");
|
|
}
|
|
}
|
|
}
|
|
|
|
return actions;
|
|
}
|
|
|
|
/**
|
|
* Get list of available commands
|
|
* @returns {Array} Array of command names
|
|
*/
|
|
getAvailableCommands() {
|
|
return Array.from(this.commands.keys());
|
|
}
|
|
|
|
/**
|
|
* Add a new command
|
|
* @param {string} name - Command name
|
|
* @param {Object} command - Command object with trigger and execute functions
|
|
*/
|
|
addCommand(name, command) {
|
|
this.commands.set(name, command);
|
|
}
|
|
|
|
/**
|
|
* Remove a command
|
|
* @param {string} name - Command name to remove
|
|
*/
|
|
removeCommand(name) {
|
|
return this.commands.delete(name);
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const commandHandler = new CommandHandler();
|
|
export default commandHandler;
|