diff options
| -rw-r--r-- | LICENSE | 28 | ||||
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r-- | config.json | 23 | ||||
| -rw-r--r-- | main.py | 275 | ||||
| -rw-r--r-- | requirements.txt | 2 |
5 files changed, 336 insertions, 0 deletions
@@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2025 + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..eba68e7 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# discord-vm + +## Requirements +flask, discord.py, and qemu (ofc) + +## Setup + +To get this running, you will firstly want to have QEMU installed on your system. You will then want to edit the config.json, change the flags passed to QEMU to match your environment. Once configured, ensure the users you want to authorise have a role named "doas" and are actively connected to a voice channel without being deafened (you can remove this very easily, but this creates a more fun experience in my opinion) Finally, you can run the bot using python main.py, which will automatically launch the VM and begin listening for commands and text input in your designated Discord channel. diff --git a/config.json b/config.json new file mode 100644 index 0000000..88604be --- /dev/null +++ b/config.json @@ -0,0 +1,23 @@ +{ + "discord_token": "TOKEN", + "command_channel_id": 1458501093553213450, + "qmp_host": "127.0.0.1", + "qmp_port": 4445, + "qemu_command": [ + "qemu-system-x86_64", + "-m", "6G", + "-smp", "2", + "-drive", "file=disk.qcow2,format=qcow2,if=none,id=drive0", + "-device", "virtio-blk-pci,drive=drive0,bootindex=2", + "-drive", "file=artix.iso,format=raw,if=none,id=cdrom0,media=cdrom", + "-device", "virtio-scsi-pci,id=scsi0", + "-device", "scsi-cd,drive=cdrom0,bootindex=1", + + "-netdev", "user,id=net0,hostfwd=tcp::2953-:22", + "-device", "virtio-net-pci,netdev=net0", + "-qmp", "tcp:127.0.0.1:4445,server,nowait", + "-usb", + "-device", "usb-tablet", + "-display", "none" + ] +} @@ -0,0 +1,275 @@ +import discord +from discord.ext import commands +import asyncio +import io +from PIL import Image +import socket +import json +import time +import re +import subprocess +import os +import logging + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler("vm_bot.log"), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +def load_config(): + try: + if os.path.exists('config.json'): + with open('config.json', 'r') as f: + return json.load(f) + return { + 'qmp_host': os.getenv('QMP_HOST', '127.0.0.1'), + 'qmp_port': int(os.getenv('QMP_PORT', 4444)), + 'discord_token': os.getenv('DISCORD_TOKEN'), + 'command_channel_id': int(os.getenv('COMMAND_CHANNEL_ID', 0)), + 'qemu_command': os.getenv('QEMU_COMMAND', '').split(' ') + } + except Exception as e: + logger.error(f"Config error: {e}") + raise + +CONFIG = load_config() + +class VMController: + def __init__(self): + self.qmp_host = CONFIG['qmp_host'] + self.qmp_port = CONFIG['qmp_port'] + self.process = None + self.mouse_x = 16384 + self.mouse_y = 16384 + self.key_map = { + 'enter': 'ret', 'ret': 'ret', 'return': 'ret', + 'spc': 'spc', 'space': 'spc', ' ': 'spc', + 'tab': 'tab', '\t': 'tab', + 'backspace': 'backspace', 'bsp': 'backspace', + 'esc': 'esc', 'escape': 'esc', + 'up': 'up', 'down': 'down', 'left': 'left', 'right': 'right', + 'del': 'delete', 'delete': 'delete', + 'pgup': 'pgup', 'pgdn': 'pgdn', + 'ctrl': 'ctrl', 'alt': 'alt', 'shift': 'shift', 'meta': 'meta_l', + 'win': 'meta_l', 'cmd': 'meta_l', + '.': 'dot', ',': 'comma', '-': 'minus', '/': 'slash', + ';': 'semicolon', "'": 'apostrophe', '[': 'bracket_left', + ']': 'bracket_right', '\\': 'backslash', '`': 'grave_accent', '=': 'equal' + } + + def start_vm(self): + if self.process and self.process.poll() is None: + return False + self.process = subprocess.Popen(CONFIG['qemu_command']) + return True + + def stop_vm(self): + if self.process: + self.process.terminate() + self.process.wait() + self.process = None + + def qmp_command(self, command_dict): + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3.0) + sock.connect((self.qmp_host, self.qmp_port)) + sock.recv(4096) + sock.send(b'{"execute": "qmp_capabilities"}\n') + sock.recv(4096) + sock.send((json.dumps(command_dict) + '\n').encode()) + response = sock.recv(4096).decode().strip() + sock.close() + resp_data = json.loads(response) + if "error" in resp_data: + logger.error(f"QMP Error: {resp_data['error']['desc']}") + return False + return True + except Exception as e: + logger.error(f"QMP Socket Error: {e}") + return False + + def send_raw_key(self, key_name): + keys_to_send = [] + for p in key_name.split('+'): + p = p.strip().lower() + qcode = self.key_map.get(p, p) + keys_to_send.append({"type": "qcode", "data": qcode}) + + return self.qmp_command({ + "execute": "send-key", + "arguments": {"keys": keys_to_send} + }) + + def send_mouse_move(self, dx=0, dy=0): + scale = 32 + self.mouse_x = max(0, min(32767, self.mouse_x + (dx * scale))) + self.mouse_y = max(0, min(32767, self.mouse_y + (dy * scale))) + return self.qmp_command({ + "execute": "input-send-event", + "arguments": { + "events": [ + {"type": "abs", "data": {"axis": "x", "value": self.mouse_x}}, + {"type": "abs", "data": {"axis": "y", "value": self.mouse_y}} + ] + } + }) + + def send_mouse_button(self, button='left', down=True): + return self.qmp_command({ + "execute": "input-send-event", + "arguments": { + "events": [{"type": "btn", "data": {"button": button, "down": down}}] + } + }) + + def send_literal_text(self, text): + shift_map = { + '!': '1', '@': '2', '#': '3', '$': '4', '%': '5', + '^': '6', '&': '7', '*': '8', '(': '9', ')': '0', + '_': 'minus', '+': 'equal', '{': 'bracket_left', + '}': 'bracket_right', '|': 'backslash', ':': 'semicolon', + '"': 'apostrophe', '<': 'comma', '>': 'dot', '?': 'slash', + '~': 'grave_accent' + } + for char in text: + if char == ' ': + self.send_raw_key('spc') + elif char in shift_map: + self.qmp_command({ + "execute": "send-key", + "arguments": { + "keys": [{"type": "qcode", "data": "shift"}, {"type": "qcode", "data": shift_map[char]}] + } + }) + elif char.isupper(): + self.qmp_command({ + "execute": "send-key", + "arguments": { + "keys": [{"type": "qcode", "data": "shift"}, {"type": "qcode", "data": char.lower()}] + } + }) + else: + self.send_raw_key(char) + time.sleep(0.01) + + def parse_and_execute(self, message_content): + parts = re.findall(r'(\[[^\]]+\]|[^\[]+)', message_content) + for part in parts: + if part.startswith('[') and part.endswith(']'): + content = part[1:-1].lower().strip() + if content == 'mreset': + self.mouse_x = 16384 + self.mouse_y = 16384 + self.send_mouse_move(0, 0) + elif content.startswith('mx,') or content.startswith('my,'): + try: + axis, val = content.split(',') + val = int(val) + if axis == 'mx': self.send_mouse_move(dx=val, dy=0) + else: self.send_mouse_move(dx=0, dy=val) + except ValueError: continue + elif content.startswith('mclick'): + btn = content.split(',')[1].strip() if ',' in content else 'left' + self.send_mouse_button(btn, True) + time.sleep(0.05) + self.send_mouse_button(btn, False) + else: + count = 1 + cmd = content + if '^' in content: + try: + cmd, count_str = content.rsplit('^', 1) + count = int(count_str) + except ValueError: pass + for _ in range(count): + self.send_raw_key(cmd.strip()) + time.sleep(0.05) + else: + self.send_literal_text(part) + return True + + def take_screenshot(self): + try: + path = "/tmp/vm_dump.ppm" + if os.path.exists(path): os.remove(path) + if not self.qmp_command({"execute": "screendump", "arguments": {"filename": path}}): + return None + for _ in range(15): + if os.path.exists(path) and os.path.getsize(path) > 0: break + time.sleep(0.1) + img = Image.open(path) + output = io.BytesIO() + img.save(output, format='PNG') + output.seek(0) + return output + except Exception as e: + logger.error(f"Screenshot Error: {e}") + return None + +intents = discord.Intents.default() +intents.message_content = True +intents.voice_states = True +bot = commands.Bot(command_prefix="!", intents=intents) +vm = VMController() + +def is_authorized(member): + if not isinstance(member, discord.Member): return False + has_role = any(role.name.lower() == "doas" for role in member.roles) + return has_role and member.voice and member.voice.channel and not (member.voice.self_deaf or member.voice.deaf) + +@bot.event +async def on_ready(): + logger.info(f'Bot active: {bot.user}') + vm.start_vm() + +@bot.event +async def on_message(message): + if message.author == bot.user or message.channel.id != CONFIG['command_channel_id'] or not is_authorized(message.author): + return + + if message.content.startswith('!'): + await bot.process_commands(message) + return + + content = message.content + if content.endswith(';e'): + content = content[:-2] + '[enter]' + + async with message.channel.typing(): + if await asyncio.to_thread(vm.parse_and_execute, content): + await asyncio.sleep(0.8) + shot = await asyncio.to_thread(vm.take_screenshot) + if shot: + await message.reply(file=discord.File(shot, filename='preview.png')) + +@bot.command(name='s') +async def say_text(ctx, *, text: str): + if not is_authorized(ctx.author): return + await ctx.message.add_reaction("💬") + +@bot.command(name='reboot') +async def reboot_vm(ctx): + if not is_authorized(ctx.author): return + vm.stop_vm() + await asyncio.sleep(1) + vm.start_vm() + await ctx.send("VM Restarted.") + +@bot.command(name='screenshot') +async def just_screenshot(ctx): + if not is_authorized(ctx.author): return + shot = await asyncio.to_thread(vm.take_screenshot) + if shot: + await ctx.send(file=discord.File(shot, filename='vm.png')) + +if __name__ == "__main__": + try: + bot.run(CONFIG['discord_token']) + finally: + vm.stop_vm() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..120a235 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pillow +discord |
