teto_ai/src/services/commandHandler.js
Mikolaj Wojciech Gorski c5cf8f0488 feat: add voice connection retry logic and diagnostic tools
- 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
2025-07-26 18:29:01 +02:00

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;