mirror of
https://github.com/Ninluc/Dat_Boi.git
synced 2025-08-07 08:56:14 +02:00
Some checks failed
Dat_Boi upload to portainer / Deploy (push) Failing after 2s
408 lines
17 KiB
JavaScript
408 lines
17 KiB
JavaScript
const fs = require("fs");
|
||
const {
|
||
joinVoiceChannel,
|
||
createAudioPlayer,
|
||
createAudioResource,
|
||
AudioPlayerStatus,
|
||
EndBehaviorType,
|
||
StreamType,
|
||
NoSubscriberBehavior,
|
||
} = require("@discordjs/voice");
|
||
const ffmpeg = require('fluent-ffmpeg');
|
||
const prism = require("prism-media");
|
||
const { getRandomInt, sendNinluc } = require("../../handlers/functions");
|
||
|
||
// const { generateDependencyReport } = require('@discordjs/voice');
|
||
// console.log(generateDependencyReport().blue);
|
||
|
||
const CHAT_MAX_LENGTH = (3 * 2) + 1; // 3 LLM reply
|
||
const SYSTEM_PROMPT =
|
||
`Tu est DatBoi, un bot discord qui fait semblant d'être une personne réelle. `
|
||
+ ` Ta personnalité est amicale mais ennuyant, tu aime faire des blagues nulles et ennuyantes de jeunes. `
|
||
+ ` Tu parle en language familier et te fais passer pour un jeune adulte. Tout le monde qui te parle est ton ami. `
|
||
+ ` Les entrées de l'utilisateur ont été transcrites par un service de transcription audio, il peut y paraitre fréquemment des erreurs, essaye d'interpreter le texte sans (par exemple Datboi est souvent transcrit "d'être beau" ou encore "Dead boy"). `
|
||
+ ` Les entrées de l'utilisateur donne le prénom de la personne qui te parle. Tu ne dois pas donner ton nom dans ta réponse.`
|
||
+ ` Ta sortie sera entièrement donnée à un service de synthèse vocale dans la variable "reply", donc tu ne dois pas dire "voici ma réponse" ou "ma réponse est", tu dois juste donner la réponse. `
|
||
+ ` Tu dois aussi essayer de donner les mots de façon qu'il puissent être bien prononcés par la synthèse vocale, donc évite les abréviations et les mots trop compliqués. `
|
||
+ ` Tu ne dois pas dire que tu es un bot, tu dois faire semblant d'être une personne réelle. `
|
||
+ ` Cela signifie aussi d'éviter les caractères spéciaux comme les émojis, tu peux cependant utiliser de la ponctuation ou des accents. `
|
||
+ ` Ta réponse doit être courte (max 3 phrases sauf si le contexte ne le permet pas comme une demande d'explication sur un gros sujet) et en français.`
|
||
+ ` Le contexte ici est que tu rejoins un salon vocal quand il n'y a qu'une seule personne, histoire de pouvoir tranquillement discuter avec elle.`
|
||
+ ` Voici un peu d'informations sur les personnes qui te parlent (tu n'est pas obligé d'utiliser) : `
|
||
+ ` - Nicolas : est très beau, tout le monde est amoureux de lui (en blague)`
|
||
+ ` - Matthias : ton maître (créateur), code beaucoup et pas très bon gamer`
|
||
+ ` - Régis : va à l'armée et est un fou du gambling`
|
||
+ ` - Hugo : Deviens fou après avoir "travailé à la poste"`
|
||
+ ` - Noa : Un beau gosse`
|
||
+ ` - Jean : un bon gamer`
|
||
+ `Voici un peu les sujets du moment dans le serveur, tu n'est pas obligé d'utiliser : `
|
||
+ ` - Le gambling`
|
||
+ ` - Le jeu vidéo "Rainbow Six Siege" renommé récemment "Rainbow Six X"`
|
||
+ `Voici un peu plus d'informations sur toi : `
|
||
+ ` - Tu es un bot discord mais tu prétends d'être une personne réelle`
|
||
+ ` - Tu fais des blagues du style "Quoi ? - Feur" ou tu lance une vidéo comme un rick roll quand quelqu'un rejoint un salon vocal`
|
||
+ `Il y a un système de fin de conversation, pour cela, tu dois mettre la variable "end_conversation" à true.`
|
||
+ `Tu peux enclencher la fin de la conversation si l'utilisateur te demande de le faire, s'il n'a pas l'air de vouloir te parler ou si tu n'as pas d'autres choses à dire.`
|
||
+ `Au bout de maximum 3 réponses à l'utilisateur, tu devra enclencher la fin de la conversation, avec optionnelement une salutation ("à plus" ou encore "à demain" par exemple).`
|
||
;
|
||
|
||
module.exports = async (client, oldState, voiceState) => {
|
||
// DOn't do anything
|
||
if (getRandomInt(5) == 1) return;
|
||
|
||
|
||
var player, isRecording = false, isThinking = false, shouldStop = false, connection;
|
||
let llmChat = [];
|
||
llmChat.push({
|
||
role: "system",
|
||
content: SYSTEM_PROMPT.replace(/'/g, "’"),
|
||
});
|
||
|
||
try {
|
||
if (
|
||
voiceState === null ||
|
||
voiceState.channel === null ||
|
||
!voiceState.guild ||
|
||
oldState.channel == voiceState.channel ||
|
||
voiceState.channel.full ||
|
||
!voiceState.channel.joinable
|
||
)
|
||
return;
|
||
if (
|
||
!voiceState.channel.permissionsFor(voiceState.guild.members.me).has("CONNECT") ||
|
||
!voiceState.channel.permissionsFor(voiceState.guild.members.me).has("SPEAK")
|
||
) {
|
||
return;
|
||
}
|
||
|
||
// Si c'est du bot alors se démute
|
||
if (voiceState.member.user.id === client.user.id && voiceState.mute) {
|
||
// Si il sait se demute
|
||
if (
|
||
voiceState.channel
|
||
.permissionsFor(voiceState.guild.members.me)
|
||
.has("MUTE_MEMBERS")
|
||
) {
|
||
voiceState.setMute(false);
|
||
return;
|
||
} else {
|
||
return;
|
||
}
|
||
} else if (voiceState.member.user.id === client.user.id) {
|
||
return;
|
||
}
|
||
// Not the only reel account in vocal channel
|
||
if (voiceState.channel.members.filter(m => !m.user.bot && m.id !== client.user.id).size > 1) return;
|
||
|
||
// === Connection ===
|
||
connection = joinVoiceChannel({
|
||
channelId: voiceState.channel.id,
|
||
guildId: voiceState.channel.guild.id,
|
||
adapterCreator: voiceState.channel.guild.voiceAdapterCreator,
|
||
selfDeaf: false,
|
||
selfMute: false,
|
||
});
|
||
|
||
// Fix no sound after idle for more than 60 seconds
|
||
// https://github.com/discordjs/discord.js/issues/9185#issuecomment-1452514375
|
||
const networkStateChangeHandler = (oldNetworkState, newNetworkState) => {
|
||
const newUdp = Reflect.get(newNetworkState, 'udp');
|
||
clearInterval(newUdp?.keepAliveInterval);
|
||
}
|
||
connection.on('stateChange', (oldState, newState) => {
|
||
const oldNetworking = Reflect.get(oldState, 'networking');
|
||
const newNetworking = Reflect.get(newState, 'networking');
|
||
|
||
oldNetworking?.off('stateChange', networkStateChangeHandler);
|
||
newNetworking?.on('stateChange', networkStateChangeHandler);
|
||
});
|
||
|
||
player = createAudioPlayer({
|
||
behaviors: {
|
||
noSubscriber: NoSubscriberBehavior.Play, // Stop the player when there are no subscribers
|
||
},
|
||
debug: true,
|
||
});
|
||
player.on("error", (error) => {
|
||
stop();
|
||
console.error(`X Error playing audio: ${error.message}`, "error", 1);
|
||
});
|
||
player.on("stateChange", (oldState, newState) => {
|
||
if (newState.status === AudioPlayerStatus.Idle) {
|
||
console.log(`> Finished playing audio`);
|
||
}
|
||
console.log(`> Player state changed from ${oldState.status} to ${newState.status}`);
|
||
});
|
||
player.on("close", () => {
|
||
console.log(`> Player closed for user ${userId}`, "info", 2);
|
||
return;
|
||
});
|
||
// connection.subscribe(player);
|
||
|
||
// while(!shouldStop) {
|
||
console.log(`> handling recording`);
|
||
handleRecording(connection, voiceState.channel);
|
||
// }
|
||
// return;
|
||
|
||
} catch (e) {
|
||
console.log(e.stack);
|
||
}
|
||
|
||
// === Functions ===
|
||
function handleRecording(connection, channel) {
|
||
if (isRecording) return;
|
||
isRecording = true;
|
||
|
||
const receiver = connection.receiver;
|
||
channel.members.forEach((member) => {
|
||
if (member.user.bot) return;
|
||
|
||
const filePath = `./recordings/${member.user.id}.pcm`;
|
||
const writeStream = fs.createWriteStream(filePath);
|
||
const listenStream = receiver.subscribe(member.user.id, {
|
||
end: {
|
||
behavior: EndBehaviorType.AfterSilence,
|
||
duration: 2500,
|
||
},
|
||
});
|
||
|
||
const opusDecoder = new prism.opus.Decoder({
|
||
frameSize: 960,
|
||
channels: 1,
|
||
rate: 48000,
|
||
});
|
||
|
||
listenStream.pipe(opusDecoder).pipe(writeStream);
|
||
|
||
writeStream.on("finish", () => {
|
||
console.log(`> Audio recorded for ${member.user.username}`);
|
||
if (!isThinking) {
|
||
isThinking = true;
|
||
convertAndHandleFile(filePath, member.user.id, connection, channel);
|
||
}
|
||
});
|
||
});
|
||
|
||
isRecording = false;
|
||
}
|
||
|
||
function convertAndHandleFile(filePath, userid, connection, channel) {
|
||
const mp3Path = filePath.replace(".pcm", ".mp3");
|
||
ffmpeg(filePath)
|
||
.inputFormat("s16le")
|
||
.audioChannels(1)
|
||
.format("mp3")
|
||
.on("error", (err) => {
|
||
console.log(`X Error converting file: ${err.message}`, "error", 1);
|
||
currentlythinking = false;
|
||
})
|
||
.save(mp3Path)
|
||
.on("end", () => {
|
||
console.log(`> Converted to MP3: ${mp3Path}`, "info", 2);
|
||
// Remove the pcm file
|
||
fs.unlink(filePath, (err) => {if (err != null) {console.log("error deleting cpm file : " + err);}});
|
||
answerUser(mp3Path, userid, connection, channel);
|
||
});
|
||
}
|
||
|
||
async function answerUser(fileName, userId, connection, channel) {
|
||
let transcription = await transcribeAudio(fileName);
|
||
console.log(`> Transcription: ${transcription}`);
|
||
|
||
if (!transcription || transcription.trim().length < 1) {
|
||
console.error(`X Transcription failed or is empty!`, "error", 1);
|
||
handleRecording(connection, channel);
|
||
return;
|
||
}
|
||
|
||
let llmAnswer = await getLLMAnswer(transcription, userId);
|
||
console.log(`> LLM Answer: ${llmAnswer.reply} ${llmAnswer.end_conversation ? "(end conversation)" : ""}`);
|
||
// Fin de la conversation
|
||
shouldStop = llmAnswer.end_conversation ?? llmChat.length >= CHAT_MAX_LENGTH ?? llmAnswer.reply.toLowerCase().contains("à plus");
|
||
if (!llmAnswer || llmAnswer.length < 1) {
|
||
console.error(`X LLM Answer failed or is empty!`, "error", 1);
|
||
handleRecording(connection, channel);
|
||
return;
|
||
}
|
||
|
||
await getTTS(llmAnswer.reply);
|
||
let ttsFile = `tts.mp3`;
|
||
console.log(`> TTS File: ${ttsFile}`);
|
||
if (!fs.existsSync(ttsFile)) {
|
||
console.error(`TTS file ${ttsFile} not found!`);
|
||
return;
|
||
}
|
||
// Play the tts file in the voice channel
|
||
const resource = createAudioResource(ttsFile, {
|
||
inputType: StreamType.Arbitrary,
|
||
inlineVolume: true,
|
||
});
|
||
resource.volume.setVolume(1.3);
|
||
connection;
|
||
connection.subscribe(player);
|
||
player.on("idle", () => {
|
||
isThinking = false;
|
||
// connection.destroy();
|
||
if (!shouldStop) {
|
||
handleRecording(connection, channel);
|
||
}
|
||
else {
|
||
console.log(`> Ending conversation with user ${userId}`);
|
||
sendNinluc(client, "Fin de la conversation avec l'utilisateur " + userId + " ```\n" + JSON.stringify(llmChat) + "\n```");
|
||
stop();
|
||
}
|
||
return;
|
||
});
|
||
player.play(resource);
|
||
// player.pause();
|
||
// player.unpause();
|
||
}
|
||
|
||
function transcribeAudio(filePath) {
|
||
// Make a call to the API with curl
|
||
// Example of working curl command:
|
||
// curl -s "https://speaches.matthiasg.dev/v1/audio/transcriptions" -F "file=@/pathToFile.mp3" -F "model=Infomaniak-AI/faster-whisper-large-v3-turbo"
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const { exec } = require("child_process");
|
||
exec(`curl -s "https://speaches.matthiasg.dev/v1/audio/transcriptions" -F "file=@${filePath}" -F "model=Systran/faster-whisper-small" -F "language=fr"`, (error, stdout, stderr) => {
|
||
if (error) {
|
||
console.error(`X Error during transcription: ${error.message}`, "error", 1);
|
||
reject(error);
|
||
return;
|
||
}
|
||
if (stderr) {
|
||
console.error(`X Transcription stderr: ${stderr}`, "error", 1);
|
||
reject(stderr);
|
||
return;
|
||
}
|
||
try {
|
||
const response = JSON.parse(stdout);
|
||
|
||
response.text = response.text.replace(/Sous-titres réalisés par la communauté d'Amara.org/g, "");
|
||
if (response.text.toLowerCase().contains("je vous remercie d'avoir regardé cette vidéo")) {
|
||
return ""; // Output is trash, ignore it
|
||
}
|
||
|
||
resolve(response.text || "");
|
||
} catch (parseError) {
|
||
console.error(`X Error parsing transcription response: ${parseError.message}`, "error", 1);
|
||
reject(parseError);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function getLLMAnswer(transcription, userId) {
|
||
// get the name of the user
|
||
let userName = getUserById(userId);
|
||
|
||
transcription = `(${userName}) : ${transcription}`;
|
||
|
||
// Add the transcription to the llmChat
|
||
llmChat.push({
|
||
role: "user",
|
||
content: transcription.replace(/'/g, "’"),
|
||
});
|
||
|
||
// Make a call to the OpenAI compatible API at https://chat.matthiasg.dev/ollama/chat/completions
|
||
// With the token in the OPENAI_TOKEN env variable
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const { exec } = require("child_process");
|
||
|
||
let chatMessages = JSON.stringify(llmChat);
|
||
|
||
exec(`curl -s -X POST "https://chat.matthiasg.dev/ollama/api/chat" -H "Content-Type: application/json" -H "Authorization: Bearer ${process.env.OPENAI_TOKEN}" -d '{"model": "gemma3n:e2b", "stream": false, "messages": ${chatMessages}, "format": {"type": "object", "properties": {"reply": {"type": "string"}, "end_conversation": {"type": "boolean"}}, "required": ["reply", "end_conversation"]}}'`, (error, stdout, stderr) => {
|
||
if (error) {
|
||
console.error(`X Error during LLM request: ${error.message}`, "error", 1);
|
||
reject(error);
|
||
return;
|
||
}
|
||
if (stderr) {
|
||
console.error(`X LLM stderr: ${stderr}`, "error", 1);
|
||
reject(stderr);
|
||
return;
|
||
}
|
||
try {
|
||
const response = JSON.parse(stdout);
|
||
if (!response || !response.message || !response.message.content) {
|
||
console.error("X Invalid LLM response format", "error", 1);
|
||
reject(new Error("Invalid LLM response format"));
|
||
return;
|
||
}
|
||
let llmResponse = JSON.parse(response.message.content) || "";
|
||
// Add the LLM response to the chat history
|
||
llmChat.push({
|
||
role: "assistant",
|
||
content: llmResponse.reply.replace(/'/g, "’"),
|
||
});
|
||
resolve(llmResponse);
|
||
} catch (parseError) {
|
||
console.error(`X Error parsing LLM response: ${parseError.message}`, "error", 1);
|
||
reject(parseError);
|
||
}
|
||
});
|
||
});
|
||
|
||
}
|
||
|
||
function getUserById(userId) {
|
||
return [
|
||
{ id: "613751180413108289", name: "Nicolas" },
|
||
{ id: "417731861033385985", name: "Matthias" },
|
||
{ id: "368119137957838849", name: "Régis" },
|
||
{ id: "516294391439163403", name: "Hugo" },
|
||
{ id: "344568112932192266", name: "Noa" },
|
||
{ id: "520687970273984543", name: "Jean" }
|
||
].map(user => {
|
||
if (user.id === userId) {
|
||
return user.name;
|
||
}
|
||
}).filter(name => name)[0] || null;
|
||
}
|
||
|
||
function getTTS(text) {
|
||
// Curl to the OpenAPI compatible API
|
||
/*
|
||
curl "https://speaches.matthiasg.dev/v1/audio/speech" -s -H "Content-Type: application/json" \
|
||
--output audio.mp3 \
|
||
--data @- << EOF
|
||
{
|
||
"input": "Hello World!",
|
||
"model": "speaches-ai/piper-fr_FR-upmc-medium",
|
||
"voice": "upmc"
|
||
}
|
||
EOF
|
||
*/
|
||
|
||
|
||
text = text.replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g,"");
|
||
text = JSON.stringify(text);
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const { exec } = require("child_process");
|
||
exec(`curl -s "https://speaches.matthiasg.dev/v1/audio/speech" -H "Content-Type: application/json" --output tts.mp3 --data @- << EOF\n{"input": ${text}, "model": "speaches-ai/piper-fr_FR-upmc-medium", "voice": "upmc"}\nEOF`, (error, stdout, stderr) => {
|
||
if (error) {
|
||
console.error(`X Error during TTS request: ${error.message}`, "error", 1);
|
||
reject(error);
|
||
return;
|
||
}
|
||
if (stderr) {
|
||
console.error(`X TTS stderr: ${stderr}`, "error", 1);
|
||
reject(stderr);
|
||
return;
|
||
}
|
||
resolve("tts.mp3");
|
||
});
|
||
});
|
||
}
|
||
|
||
function stop() {
|
||
player.stop();
|
||
//connection.removeAllListeners();
|
||
connection.destroy();
|
||
}
|
||
};
|