init
This commit is contained in:
commit
a062579d36
13 changed files with 782 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
bin/
|
||||
build/
|
||||
config.json
|
1
.npmrc
Normal file
1
.npmrc
Normal file
|
@ -0,0 +1 @@
|
|||
package-lock=false
|
22
README.MD
Normal file
22
README.MD
Normal file
|
@ -0,0 +1,22 @@
|
|||
# AudioBot
|
||||
A simple bot that relays audio from a QEMU VM to a Discord call. Developed for use with CollabVM.
|
||||
|
||||
## External dependencies
|
||||
|
||||
- libvncclient
|
||||
|
||||
## Running
|
||||
|
||||
1. Install depencencies: `npm i`
|
||||
2. Build the typescript: `npm run build`
|
||||
3. Build the C binaries: (TODO: Add build system)
|
||||
- `mkdir bin`
|
||||
- `gcc audio.c -lvncclient -o bin/audio`
|
||||
- `gcc name.c -lvncclient -o bin/name`
|
||||
4. Copy config.example.json to config.json, and fill out your discord token and client ID, as well as a list of available VMs and their VNC servers
|
||||
5. **Run it:** `node build/index.js`
|
||||
|
||||
## Credits
|
||||
|
||||
- QEMU Audio/C portion: DarkOK
|
||||
- Discord bot/TypeScript portion: Elijah R
|
125
audio.c
Normal file
125
audio.c
Normal file
|
@ -0,0 +1,125 @@
|
|||
#include <stdio.h>
|
||||
#include <rfb/rfbclient.h>
|
||||
|
||||
// The world if libvncclient let you easily connect to a server without using their own retarded argv shit
|
||||
// (audio and name wouldn't even have to be 2 different binaries and password could easily be specified!!!)
|
||||
|
||||
#define VNC_PASSWORD "D0gg0!!!"
|
||||
|
||||
#define VNC_ENCODING_AUDIO 0XFFFFFEFD /* -259 */
|
||||
|
||||
#define VNC_MSG_CLIENT_QEMU 255
|
||||
#define VNC_MSG_CLIENT_QEMU_AUDIO 1
|
||||
|
||||
#define VNC_MSG_CLIENT_QEMU_AUDIO_ENABLE 0
|
||||
#define VNC_MSG_CLIENT_QEMU_AUDIO_DISABLE 1
|
||||
#define VNC_MSG_CLIENT_QEMU_AUDIO_SET_FORMAT 2
|
||||
|
||||
#define VNC_MSG_SERVER_QEMU_AUDIO_END 0
|
||||
#define VNC_MSG_SERVER_QEMU_AUDIO_BEGIN 1
|
||||
#define VNC_MSG_SERVER_QEMU_AUDIO_DATA 2
|
||||
|
||||
#define AUDIO_FORMAT_U8 0
|
||||
#define AUDIO_FORMAT_S8 1
|
||||
#define AUDIO_FORMAT_U16 2
|
||||
#define AUDIO_FORMAT_S16 3
|
||||
#define AUDIO_FORMAT_U32 4
|
||||
#define AUDIO_FORMAT_S32 5
|
||||
|
||||
#define VNC_QEMU_AUDIO_RATE 48000
|
||||
#define VNC_QEMU_AUDIO_CHANNELS 2
|
||||
#define VNC_QEMU_AUDIO_BPS 16
|
||||
|
||||
char * getpassword(rfbClient *client) {
|
||||
return strdup(VNC_PASSWORD);
|
||||
};
|
||||
|
||||
static rfbBool vnc_qemu_audio_encoding(rfbClient* client, rfbFramebufferUpdateRectHeader* rect) {
|
||||
struct {
|
||||
uint8_t type;
|
||||
uint8_t msg_id;
|
||||
uint16_t audio_id;
|
||||
struct {
|
||||
uint8_t format;
|
||||
uint8_t channels;
|
||||
char frequency[sizeof(uint32_t)];
|
||||
} set_format;
|
||||
} audio_format_msg = {
|
||||
VNC_MSG_CLIENT_QEMU,
|
||||
VNC_MSG_CLIENT_QEMU_AUDIO,
|
||||
rfbClientSwap16IfLE(VNC_MSG_CLIENT_QEMU_AUDIO_SET_FORMAT),
|
||||
AUDIO_FORMAT_S16,
|
||||
VNC_QEMU_AUDIO_CHANNELS
|
||||
};
|
||||
|
||||
*(uint32_t*) audio_format_msg.set_format.frequency = rfbClientSwap32IfLE(VNC_QEMU_AUDIO_RATE);
|
||||
|
||||
if (!WriteToRFBServer(client, (char*)& audio_format_msg, sizeof(audio_format_msg))) return FALSE;
|
||||
|
||||
struct {
|
||||
uint8_t type;
|
||||
uint8_t msg_id;
|
||||
uint16_t audio_id;
|
||||
} audio_enable_msg = {
|
||||
VNC_MSG_CLIENT_QEMU,
|
||||
VNC_MSG_CLIENT_QEMU_AUDIO,
|
||||
rfbClientSwap16IfLE(VNC_MSG_CLIENT_QEMU_AUDIO_ENABLE)
|
||||
};
|
||||
|
||||
if (!WriteToRFBServer(client, (char*)& audio_enable_msg, sizeof(audio_enable_msg))) return FALSE;
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static rfbBool vnc_qemu_audio_msg(rfbClient* client, rfbServerToClientMsg* message) {
|
||||
if (message->type != VNC_MSG_CLIENT_QEMU) return FALSE;
|
||||
|
||||
struct {
|
||||
uint8_t msg_id;
|
||||
char audio_id[sizeof(uint16_t)];
|
||||
} msg;
|
||||
|
||||
if (!ReadFromRFBServer(client, (char*)&msg, sizeof(msg))) return TRUE;
|
||||
if (msg.msg_id != VNC_MSG_CLIENT_QEMU_AUDIO) return TRUE;
|
||||
|
||||
switch (rfbClientSwap16IfLE(*(uint16_t*)msg.audio_id)) {
|
||||
case VNC_MSG_SERVER_QEMU_AUDIO_BEGIN:
|
||||
break;
|
||||
case VNC_MSG_SERVER_QEMU_AUDIO_DATA: {
|
||||
uint32_t size;
|
||||
if (!ReadFromRFBServer(client, (char*) &size, sizeof(uint32_t))) return TRUE;
|
||||
size = rfbClientSwap32IfLE(size);
|
||||
char* data = malloc(size);
|
||||
if (ReadFromRFBServer(client, data, size)) fwrite(data, sizeof(char), size, stdout);
|
||||
free(data);
|
||||
break;
|
||||
};
|
||||
case VNC_MSG_SERVER_QEMU_AUDIO_END:
|
||||
break;
|
||||
};
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static int QEMU_AUDIO_ENCODING[] = {VNC_ENCODING_AUDIO, 0};
|
||||
static rfbClientProtocolExtension qemu_audio_extension = {
|
||||
QEMU_AUDIO_ENCODING,
|
||||
vnc_qemu_audio_encoding,
|
||||
vnc_qemu_audio_msg,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL
|
||||
};
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
rfbClient* client = rfbGetClient(8,3,4);
|
||||
client->GetPassword = getpassword;
|
||||
rfbClientRegisterExtension(&qemu_audio_extension);
|
||||
if (!rfbInitClient(client,&argc,argv)) return 1;
|
||||
while (1) {
|
||||
if (WaitForMessage(client,50) < 0) break;
|
||||
if (!HandleRFBServerMessage(client)) break;
|
||||
};
|
||||
rfbClientCleanup(client);
|
||||
return 0;
|
||||
}
|
10
config.example.json
Normal file
10
config.example.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"DiscordToken": "",
|
||||
"DiscordClientID": "",
|
||||
"VMs": [
|
||||
{
|
||||
"name": "vm1",
|
||||
"vncaddr": "127.0.0.1:5900"
|
||||
}
|
||||
]
|
||||
}
|
312
index_old.js
Normal file
312
index_old.js
Normal file
|
@ -0,0 +1,312 @@
|
|||
// This is the old version of the bot, written by DarkOK, and is kept here for archival purposes.
|
||||
// It no longer works due to discord api changes, hence the rewrite.
|
||||
|
||||
|
||||
const Discord = require('discord.js-v11'); // update this you lazy prick and rewrite the bot
|
||||
const client = new Discord.Client();
|
||||
const {execSync, exec, spawnSync, spawn} = require('child_process');
|
||||
|
||||
var token = ""; // token used for authentication to discord
|
||||
|
||||
var ownerIds = []; // user ids of people that can eval, change servers to non-whitelisted ones, etc
|
||||
|
||||
var pf = "cvm!"; // command prefix
|
||||
var vncServers = [
|
||||
"localhost:5900"
|
||||
]; // servers that appears in the list, and ones that people don't need admin to switch to (first item in array is default server)
|
||||
// (if this is public, keep this as the one (Hm i should really make all VMs use one broadcast for all servers to fix this))
|
||||
|
||||
// dont modify
|
||||
var vncServer = vncServers[0];
|
||||
var vncName = "";
|
||||
|
||||
// especially dont modify these
|
||||
var arecord;
|
||||
var broadcast;
|
||||
var bp={txt:"",type:"",status:""};
|
||||
|
||||
function changeStatus(txt, type, status) {
|
||||
bp.txt = txt;
|
||||
bp.type = type ? type : "LISTENING";
|
||||
bp.status = status ? status : "online";
|
||||
|
||||
if (bp.txt != txt || bp.type != type || bp.status != status) {
|
||||
client.user.setPresence({
|
||||
activity: {
|
||||
name: pf + "help" + " | " + bp.txt,
|
||||
type: bp.type
|
||||
},
|
||||
status: bp.status
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function startStream() {
|
||||
console.log("Connecting to " + vncServer);
|
||||
|
||||
changeStatus("Connecting");
|
||||
|
||||
exec("./name " + vncServer, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
//vncName = "Failed to get VM name";
|
||||
console.log("Error getting VM name: " + error.message);
|
||||
} else {
|
||||
vncName = stdout;
|
||||
console.log("VNC name: " + vncName);
|
||||
changeStatus(vncName);
|
||||
};
|
||||
});
|
||||
|
||||
arecord = spawn('./audio', [vncServer]);
|
||||
|
||||
arecord.stderr.setEncoding("utf8");
|
||||
arecord.stderr.on("data", data => {
|
||||
console.log(data);
|
||||
});
|
||||
|
||||
arecord.on("close", code => {
|
||||
console.log("Audio process exited with " + code);
|
||||
setTimeout(()=>{
|
||||
startStream();
|
||||
for (var a of client.voice.connections.values()) {
|
||||
a.play(broadcast);
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
broadcast = client.voice.createBroadcast();
|
||||
broadcast.play(
|
||||
arecord.stdout.on("data", data => {
|
||||
return data;
|
||||
}),
|
||||
{
|
||||
type: "converted",
|
||||
volume: false,
|
||||
highWaterMark: 1
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
client.login(token);
|
||||
|
||||
client.on("ready", ()=>{
|
||||
console.log(`Logged in as: ${client.user.tag} (ID: ${client.user.id})`);
|
||||
console.log(`Prefix is: ${pf}`);
|
||||
console.log(`Currently in ${Array.from(client.guilds.cache.values()).length} servers\n`);
|
||||
|
||||
startStream();
|
||||
});
|
||||
|
||||
client.on("error", async err => {
|
||||
console.log("An error occurred: " + err.message);
|
||||
});
|
||||
|
||||
// HURR NO GOOD COMMAND HANDLER HURR DURR
|
||||
client.on("message", async message => {
|
||||
if (!message.guild) return;
|
||||
if (!message.member) return;
|
||||
if (message.member.id == client.user.id) return;
|
||||
// i could put it in the same one if i want to BUT I CANT BE BOTHERED
|
||||
|
||||
var args = message.content.split(' ');
|
||||
var cmd = args.shift();
|
||||
|
||||
//console.log(`[Message] <${message.author.tag} in ${message.guild.name}> ${message.content}`);
|
||||
|
||||
if (cmd.startsWith(pf)) {
|
||||
cmd = cmd.slice(pf.length).toLowerCase();
|
||||
console.log(`[Command] <${message.author.tag} in #${message.channel.name}@${message.guild.name}> ${message.content}`);
|
||||
switch (cmd) {
|
||||
|
||||
case "play":
|
||||
case "join":
|
||||
case "connect":
|
||||
if (message.member.voice.channel) {
|
||||
message.member.voice.channel.join().then(connection => {
|
||||
connection.play(broadcast);
|
||||
message.reply("joined");
|
||||
}).catch(err =>{
|
||||
message.reply("an error occurred while trying to join the voice channel: `" + err.message + '`');
|
||||
console.log(err.message);
|
||||
});
|
||||
} else {
|
||||
message.reply("join a voice channel");
|
||||
};
|
||||
break;
|
||||
|
||||
case "stop":
|
||||
case "leave":
|
||||
case "disconnect":
|
||||
if (message.member.voice.channel) {
|
||||
message.member.voice.channel.leave();
|
||||
message.reply("left");
|
||||
} else {
|
||||
message.reply("join a voice channel so I know where to leave");
|
||||
// i can probably make it not do it this way but Cannot Be Bothered
|
||||
};
|
||||
break;
|
||||
|
||||
case "eval":
|
||||
if (ownerIds.includes(message.member.id)) {
|
||||
try {
|
||||
//console.log("Evaluating " + args.join(' '));
|
||||
var evalOutput = eval(args.join(' '));
|
||||
//var evalOutputChunks = evalOutput.match(/.{1,1950}/g);
|
||||
message.reply("```\n" + evalOutput + "\n```");
|
||||
//for (var i = 0; i < evalOutputChunks.length; i++) {
|
||||
// message.channel.send(`Part ${i + 1} of ${evalOutputChunks.length}` + "```\n" + evalOutputChunks[i] + "\n```");
|
||||
//}
|
||||
console.log(evalOutput);
|
||||
} catch (err) {
|
||||
message.reply("fuck!\n```\n" + err.stack + "\n```");
|
||||
}
|
||||
} else {
|
||||
message.reply("you need to be in the owner list");
|
||||
};
|
||||
break;
|
||||
|
||||
case "rs":
|
||||
case "restartstream":
|
||||
if (ownerIds.includes(message.member.id)) {
|
||||
arecord.kill("SIGTERM");
|
||||
console.log("Terminated the audio process");
|
||||
message.reply("terminated the audio process");
|
||||
} else {
|
||||
message.reply("you need to be in the owner list");
|
||||
};
|
||||
break;
|
||||
|
||||
case "ss":
|
||||
case "setserver":
|
||||
// to do: maybe I could make it so each server can choose which VM in the list it plays audio from, instead of using the same broadcast
|
||||
// (although maybe still use broadcasts for multiple connections to the same server, for optimisation, and less connections to the same server)
|
||||
if (vncServers.includes(args[0]) || ownerIds.includes(message.member.id)) {
|
||||
if (args[0] != undefined) {
|
||||
vncServer = args[0];
|
||||
arecord.kill("SIGTERM");
|
||||
console.log("Set the VNC server to " + args[0] + " and terminated the audio process");
|
||||
message.reply("set the VNC server to `" + args[0] + "` and terminated the audio process");
|
||||
} else {
|
||||
message.reply("specify a server you dumb fuck");
|
||||
};
|
||||
} else {
|
||||
message.reply("you need to be in the owner list to connect to VNC servers not in the VNC server list (check `" + pf + "list`)");
|
||||
// having the ability for anybody to connect to literally any host + tcp port maybe isn't a good idea
|
||||
};
|
||||
break;
|
||||
|
||||
case "cs":
|
||||
case "currentserver":
|
||||
if (ownerIds.includes(message.member.id)) {
|
||||
message.reply(`\nIP: \`${vncServer}\`\nName: \`${vncName}\``);
|
||||
} else {
|
||||
message.reply("you need to be in the owner list");
|
||||
};
|
||||
break;
|
||||
|
||||
case "list":
|
||||
var vmlist = "VM List:\n";
|
||||
vncServers.forEach(server => {
|
||||
var vmname = spawnSync("./name", [server], {timeout: 2000});
|
||||
if (!vmname.status && vmname.stdout) {
|
||||
vmlist += `\`${server}\` - **${vmname.stdout}**\n`;
|
||||
} else {
|
||||
vmlist += `\`${server}\` - Failed to get name\n`;
|
||||
};
|
||||
});
|
||||
if (vmlist.length < 2000) message.reply(vmlist);
|
||||
else message.reply("VM list is too big to fit in a message (oops!)");
|
||||
break;
|
||||
|
||||
case "guilds":
|
||||
if (ownerIds.includes(message.member.id)) {
|
||||
let guildsOnEachPage = 10;
|
||||
let guildsTmp = Array.from(client.guilds.cache.values());
|
||||
let pageCount = Math.ceil(guildsTmp.length / guildsOnEachPage);
|
||||
|
||||
if (args[0] == "all") {
|
||||
for (var i = 1; i <= pageCount; ++i) {
|
||||
let guildList = `Guild list - __**Page ${i}/${pageCount}**__:\n`;
|
||||
|
||||
guildsTmp.slice((i * guildsOnEachPage) - guildsOnEachPage, (i * guildsOnEachPage)).forEach(guild => {
|
||||
guildList += `**\`${guild.name}\`** - owned by ${guild.ownerID}, with ${guild.memberCount} users ${client.voice.connections.has(guild.id) ? `__**[Voice connected, ${Array.from(client.voice.connections.get(guild.id).channel.members).length} users]**__` : ""}\n`; // obese as shit holy FUCK
|
||||
});
|
||||
|
||||
if (guildList.length < 2000) message.channel.send(guildList);
|
||||
else message.reply("Guild list is too big to fit in a message (oops!)");
|
||||
};
|
||||
} else {
|
||||
let page = isNaN(parseInt(args[0])) ? 1 : parseInt(args[0]);
|
||||
let guildList = `Guild list - __**Page ${page}/${pageCount}**__:\n`;
|
||||
|
||||
guildsTmp.slice((page * guildsOnEachPage) - guildsOnEachPage, (page * guildsOnEachPage)).forEach(guild => {
|
||||
guildList += `**\`${guild.name}\`** - owned by ${guild.ownerID}, with ${guild.memberCount} users ${client.voice.connections.has(guild.id) ? `__**[Voice connected, ${Array.from(client.voice.connections.get(guild.id).channel.members).length} users]**__` : ""}\n`; // obese as shit holy FUCK
|
||||
});
|
||||
|
||||
if (guildList.length < 2000) message.channel.send(guildList);
|
||||
else message.reply("Guild list is too big to fit in a message (oops!)");
|
||||
};
|
||||
} else {
|
||||
message.reply("you need to be in the owner list");
|
||||
};
|
||||
break;
|
||||
|
||||
case "stats":
|
||||
let uptimeSeconds = (client.uptime / 1000);
|
||||
let botStats = "Bot stats:\n";
|
||||
botStats += "**Server count**: " + Array.from(client.guilds.cache).length + '\n';
|
||||
botStats += "**Voice connection count**: " + Array.from(client.voice.connections).length + '\n';
|
||||
|
||||
botStats += "**Bot uptime**: ";
|
||||
botStats += Math.floor(uptimeSeconds / 86400) + " days, ";
|
||||
uptimeSeconds %= 86400;
|
||||
botStats += Math.floor(uptimeSeconds / 3600) + " hrs, ";
|
||||
uptimeSeconds %= 3600;
|
||||
botStats += Math.floor(uptimeSeconds / 60) + " mins, ";
|
||||
botStats += Math.floor(uptimeSeconds % 60) + " secs\n";
|
||||
|
||||
botStats += "**Current VM name**: `" + vncName + "`\n";
|
||||
|
||||
if (botStats.length < 2000) message.reply(botStats);
|
||||
else message.reply("stats are too big to fit in a message (oops!)");
|
||||
// i should probably calculate how big the mention part is too, but this somewhat works for now
|
||||
break;
|
||||
|
||||
case "about":
|
||||
message.reply("this uses DarkOK's own QEMU_VNC_Audio->Discord bot");
|
||||
break;
|
||||
|
||||
case "invite":
|
||||
message.reply(`my invite URL is: https://discord.com/oauth2/authorize?client_id=${client.user.id}&permissions=36703232&scope=bot`);
|
||||
break;
|
||||
|
||||
case "help":
|
||||
message.reply(
|
||||
"Command list:\n" +
|
||||
`**${pf}join** - Join a voice channel\n` +
|
||||
`**${pf}leave** - Leave a voice channel\n` +
|
||||
`**${pf}invite** - Sends the invite URL for the bot\n` +
|
||||
`**${pf}stats** - Display some statistics about the bot\n` +
|
||||
'\n' +
|
||||
"and that's about it\n" +
|
||||
"sometimes audio just stops working, try making the bot leave and rejoin the voice channel to see if it solves the issue\n" +
|
||||
"this bot is relatively unstable as of now, expect crashes/things not working\n\n" +
|
||||
"contact? dark@darkok.xyz"
|
||||
);
|
||||
// really shitty but it's the best I can do since there's no proper command handler
|
||||
break;
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
client.on("guildCreate", async guild => {
|
||||
console.log(`[Server Joined] ${guild.name} - ${guild.memberCount} members, ID: ${guild.id}`);
|
||||
});
|
||||
|
||||
client.on("guildDelete", async guild => {
|
||||
console.log(`[Server Left] ${guild.name} - ${guild.memberCount} members, ID: ${guild.id}`);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", async err => {
|
||||
console.error("Unhandled promise rejection:", err);
|
||||
});
|
24
name.c
Normal file
24
name.c
Normal file
|
@ -0,0 +1,24 @@
|
|||
#include <stdio.h>
|
||||
#include <rfb/rfbclient.h>
|
||||
|
||||
#define VNC_PASSWORD "D0gg0!!!"
|
||||
|
||||
char * getpassword(rfbClient *client) {
|
||||
return strdup(VNC_PASSWORD);
|
||||
};
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
rfbClient* client = rfbGetClient(8,3,4);
|
||||
rfbEnableClientLogging = FALSE;
|
||||
client->GetPassword = getpassword;
|
||||
if (!rfbInitClient(client,&argc,argv) || WaitForMessage(client,50) < 0 || !HandleRFBServerMessage(client)) return 1;
|
||||
|
||||
if (*client->desktopName) {
|
||||
fputs(client->desktopName, stdout);
|
||||
} else {
|
||||
printf("a virtual machine");
|
||||
};
|
||||
|
||||
rfbClientCleanup(client);
|
||||
return 0;
|
||||
}
|
25
package.json
Normal file
25
package.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "collabvm-audiobot",
|
||||
"version": "1.0.0",
|
||||
"description": "QEMU VNC Audio to Discord bridge",
|
||||
"main": "build/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"author": "DarkOK, Elijah R",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/memorystream": "^0.3.4",
|
||||
"@types/node": "^20.11.5",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/opus": "^0.9.0",
|
||||
"@discordjs/voice": "^0.16.1",
|
||||
"discord.js": "^14.14.1",
|
||||
"libsodium-wrappers": "^0.7.13",
|
||||
"memorystream": "^0.3.1",
|
||||
"sodium": "^3.0.2"
|
||||
}
|
||||
}
|
21
src/commands.ts
Normal file
21
src/commands.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { SlashCommandBuilder, APIApplicationCommandOptionChoice, SlashCommandStringOption } from "discord.js";
|
||||
import IConfig from './config.js';
|
||||
import { readFileSync } from "fs";
|
||||
const config : IConfig = JSON.parse(readFileSync('./config.json', 'utf8'));
|
||||
|
||||
const Commands = [
|
||||
new SlashCommandBuilder()
|
||||
.setName('connect')
|
||||
.setDescription('Connect the bot to the specified VM')
|
||||
.addStringOption(option => {
|
||||
option.setName('vm');
|
||||
option.setDescription('The QEMU instance to connect to');
|
||||
option.setRequired(true);
|
||||
config.VMs.forEach(vm => option.addChoices({name: vm.name, value: vm.name}));
|
||||
return option;
|
||||
}).toJSON(),
|
||||
new SlashCommandBuilder()
|
||||
.setName('disconnect')
|
||||
.setDescription('Leave the voice channel'),
|
||||
];
|
||||
export default Commands;
|
8
src/config.ts
Normal file
8
src/config.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export default interface IConfig {
|
||||
DiscordToken: string;
|
||||
DiscordClientID: string;
|
||||
VMs : {
|
||||
name : string;
|
||||
vncaddr : string;
|
||||
}[];
|
||||
}
|
118
src/index.ts
Normal file
118
src/index.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
import {REST, Routes, Client, GatewayIntentBits, ChatInputCommandInteraction, Guild, GuildMember} from 'discord.js';
|
||||
import {AudioPlayer, AudioPlayerStatus, StreamType, VoiceConnectionStatus, createAudioPlayer, createAudioResource, getVoiceConnection, joinVoiceChannel} from '@discordjs/voice';
|
||||
import Commands from './commands.js';
|
||||
import IConfig from './config.js';
|
||||
import log from './log.js';
|
||||
import {readFileSync} from 'fs';
|
||||
import { ChildProcessWithoutNullStreams, exec, spawn } from 'child_process';
|
||||
import { Readable } from 'stream';
|
||||
import MemoryStream from 'memorystream';
|
||||
|
||||
log("INFO", "QEMU Discord Audio bot starting...");
|
||||
const config : IConfig = JSON.parse(readFileSync('./config.json', 'utf8'));
|
||||
const rest = new REST({version: '10'}).setToken(config.DiscordToken);
|
||||
const client = new Client({intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates]});
|
||||
|
||||
var player : AudioPlayer = createAudioPlayer();
|
||||
player.on('error', (error) => {
|
||||
log("ERROR", `Audio player: ${error.message}`);
|
||||
});
|
||||
player.on(AudioPlayerStatus.Playing, () => log("INFO", "Audio playing"));
|
||||
player.on(AudioPlayerStatus.Idle, () => {
|
||||
log("INFO", "Audio idle");
|
||||
stream.destroy();
|
||||
if (audioprc) audioStream();
|
||||
})
|
||||
var vncname : string = "";
|
||||
var audioprc : ChildProcessWithoutNullStreams | null = null;
|
||||
var stream : MemoryStream;
|
||||
|
||||
client.on('ready', () => {
|
||||
log("INFO", `Logged into discord as ${client.user?.tag}`);
|
||||
});
|
||||
client.on('interactionCreate', async i => {
|
||||
if (!i.isChatInputCommand) return;
|
||||
var cmd = i as ChatInputCommandInteraction;
|
||||
switch (cmd.commandName) {
|
||||
case "connect": {
|
||||
await cmd.deferReply();
|
||||
// kill the old process if it exists
|
||||
if (audioprc != null) audioprc.kill("SIGTERM");
|
||||
// get vnc address
|
||||
var vm = config.VMs.find(vm => vm.name == cmd.options.getString('vm'));
|
||||
if (!vm) {
|
||||
await cmd.editReply("Invalid VM");
|
||||
return;
|
||||
}
|
||||
var vncaddr = vm.vncaddr;
|
||||
// get vnc name
|
||||
var error = false;
|
||||
await new Promise<void>((res, rej) => {
|
||||
exec(`./bin/name ${vncaddr}`, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
log("ERROR", `Failed to get name of VM: ${err}`);
|
||||
cmd.editReply("Failed to connect to VNC");
|
||||
error = true;
|
||||
} else vncname = stdout;
|
||||
res();
|
||||
});
|
||||
})
|
||||
if (error) return;
|
||||
log("INFO", `Connecting to VNC ${vncname} at ${vncaddr}`);
|
||||
// start the audio process
|
||||
audioprc = spawn("./bin/audio", [vncaddr]);
|
||||
audioprc.stderr.setEncoding('utf8');
|
||||
audioprc.stderr.on('data', console.log);
|
||||
audioprc.on('exit', (code) => {
|
||||
audioprc = null;
|
||||
log("INFO", `Audio process exited with code ${code}`);
|
||||
});
|
||||
audioStream();
|
||||
// connect to voice channel if not already connected
|
||||
var guild = cmd.guild!;
|
||||
if (getVoiceConnection(guild.id)) {
|
||||
await cmd.editReply(`Connected to ${vncname}`);
|
||||
return;
|
||||
}
|
||||
var channel = (cmd.member! as GuildMember).voice.channel;
|
||||
if (!channel) {
|
||||
await cmd.editReply(`Connected to ${vncname}`);
|
||||
return;
|
||||
}
|
||||
var con = joinVoiceChannel({
|
||||
guildId: guild.id,
|
||||
channelId: channel.id,
|
||||
adapterCreator: guild.voiceAdapterCreator,
|
||||
});
|
||||
con.on(VoiceConnectionStatus.Ready, () => log("INFO", `Connected to voice channel ${channel!.name}`));
|
||||
con.subscribe(player);
|
||||
await cmd.editReply(`Connected to ${vncname}`);
|
||||
break;
|
||||
}
|
||||
case "disconnect": {
|
||||
var conn = getVoiceConnection(cmd.guild!.id);
|
||||
if (conn == undefined) {
|
||||
await cmd.reply("Not connected to a voice channel");
|
||||
return;
|
||||
}
|
||||
conn.disconnect();
|
||||
await cmd.reply("Disconnected");
|
||||
}
|
||||
}
|
||||
});
|
||||
async function main() {
|
||||
log("INFO", "Publishing slash commands...");
|
||||
//await rest.put(Routes.applicationCommands(config.DiscordClientID), {body: Commands});
|
||||
client.login(config.DiscordToken);
|
||||
}
|
||||
main();
|
||||
|
||||
function audioStream() {
|
||||
if (!audioprc) return;
|
||||
stream = new MemoryStream();
|
||||
audioprc.stdout.pipe(stream);
|
||||
player.play(createAudioResource(stream, {
|
||||
inputType: StreamType.Raw,
|
||||
inlineVolume: false
|
||||
}));
|
||||
}
|
3
src/log.ts
Normal file
3
src/log.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default function log(loglevel : string, message : string) {
|
||||
console.log(`[${new Date().toLocaleString()}] [${loglevel}] ${message}`);
|
||||
}
|
109
tsconfig.json
Normal file
109
tsconfig.json
Normal file
|
@ -0,0 +1,109 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "es2022", /* Specify what module code is generated. */
|
||||
"rootDir": "./src", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "./build", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue