diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 380133b..4c62514 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -1,44 +1,16 @@ name: Pylint -on: [push] +on: [push, pull_request] jobs: - build-linux: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pylint - - name: Analysing the code with pylint - run: | - pylint --enable-all-extensions --extension-pkg-allow-list=orjson,ujson --disable=R,C,W $(git ls-files '*.py') + linux-standard: + name: "Linux (Standard)" + uses: ./.github/workflows/pylint_standard.yml + with: + os: ubuntu-latest - build-windows: - runs-on: windows-latest - strategy: - matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pylint - - name: Analysing the code with pylint - run: | - pylint --enable-all-extensions --extension-pkg-allow-list=orjson,ujson --disable=R,C,W $(git ls-files '*.py') + windows-standard: + name: "Windows (Standard)" + uses: ./.github/workflows/pylint_standard.yml + with: + os: windows-latest diff --git a/.github/workflows/pylint_standard.yml b/.github/workflows/pylint_standard.yml new file mode 100644 index 0000000..da50445 --- /dev/null +++ b/.github/workflows/pylint_standard.yml @@ -0,0 +1,31 @@ +name: Pylint + +on: + workflow_call: + inputs: + os: + type: string + description: "The operating system to run the workflow on." + default: ubuntu-latest + +jobs: + build-linux: + name: "Standard" + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9","3.10","3.11","3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pylint + - name: Pylint analysis + run: | + pylint --enable-all-extensions --extension-pkg-allow-list=orjson,ujson --disable=R,C,W $(git ls-files '*.py') diff --git a/README.md b/README.md index 2b97d66..45bb402 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- + Unifier Micro

@@ -22,7 +22,7 @@ speed, as well as limits the bot to support Discord only. ## Who should use this? Unifier Micro is built for small communities just wanting to give Unifier a spin, or communities with very limited -resources to run Unifier. For communities of scale with decent resources, we recommend using the [full-scale +resources to run Unifier. For larger communities than have decent resources, we recommend using the [full-scale version](https://github.com/UnifierHQ/unifier) instead. ## Features diff --git a/boot/bootloader.py b/boot/bootloader.py new file mode 100644 index 0000000..427da35 --- /dev/null +++ b/boot/bootloader.py @@ -0,0 +1,302 @@ +""" +Unifier - A sophisticated Discord bot uniting servers and platforms +Copyright (C) 2024 Green, ItsAsheer + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +import os +import sys +import shutil +import json +import time +import getpass + +reinstall = '--reinstall' in sys.argv +depinstall = '--install-deps' in sys.argv +manage_tokens = '--tokens' in sys.argv +clear_tokens = '--clear-tokens' in sys.argv + +if os.getcwd().endswith('/boot'): + print('\x1b[31;1mYou are running the bootloader directly. Please run the run.sh file instead.\x1b[0m') + sys.exit(1) + +with open('boot/internal.json') as file: + internal = json.load(file) + +install_options = internal['options'] + +boot_config = {} +try: + with open('boot_config.json') as file: + boot_config = json.load(file) +except: + if os.path.exists('update'): + shutil.copy2('update/boot_config.json', 'boot_config.json') + with open('boot_config.json') as file: + boot_config = json.load(file) + +bootloader_config = boot_config.get('bootloader', {}) + +binary = bootloader_config.get('binary', 'py -3' if sys.platform == 'win32' else 'python3') +options = bootloader_config.get('options') +boot_file = bootloader_config.get('boot_file', internal["base_bootfile"]) +autoreboot = bootloader_config.get('autoreboot', False) +threshold = bootloader_config.get('autoreboot_threshold', 60) + +if not options: + options = '' +else: + options = ' ' + ' '.join(options) + +if not '.install.json' in os.listdir() or reinstall or depinstall: + if os.path.isdir('update') and not reinstall and not depinstall: + # unifier was likely updated from v2 or older + print('\x1b[33;1mLegacy installation detected, skipping installer.\x1b[0m') + with open('.install.json', 'w+') as file: + # noinspection PyTypeChecker + json.dump( + { + 'product': internal["product"], + 'setup': False, + 'option': 'optimized' + }, + file + ) + else: + # this installation is fresh + if manage_tokens or clear_tokens: + print(f'\x1b[31;1mNo Unifier installation was detected.\x1b[0m') + sys.exit(1) + elif not depinstall: + if not reinstall: + print('\x1b[33;1mInstallation not detected, running installer...\x1b[0m') + + if len(install_options) == 1: + install_option = install_options[0]['id'] + else: + print(f'\x1b[33;1mYou have {len(install_options)} install options available.\x1b[0m\n') + + for index in range(len(install_options)): + option = install_options[index] + print(f'{option["color"]};1m{option["name"]} (option {index})\x1b[0m') + print(f'{option["color"]}m{option["description"]}\x1b[0m') + + print(f'\n\x1b[33;1mWhich installation option would you like to install? (0-{len(install_options)-1})\x1b[0m') + + try: + install_option = int(input()) + + if install_option < 0 or install_option >= len(install_options): + raise ValueError() + except: + print(f'\x1b[31;1mAborting.\x1b[0m') + sys.exit(1) + + install_option = install_options[install_option]['id'] + + print('\x1b[33;1mPlease review the following before continuing:\x1b[0m') + print(f'- Product to install: {internal["product_name"]}') + print(f'- Installation option: {install_option}') + print(f'- Install directory: {os.getcwd()}') + print(f'- Python command/binary: {binary}\n') + print('\x1b[33;1mProceed with installation? (y/n)\x1b[0m') + + try: + answer = input().lower() + except: + print(f'\x1b[31;1mAborting.\x1b[0m') + sys.exit(1) + + if not answer == 'y': + print(f'\x1b[31;1mAborting.\x1b[0m') + sys.exit(1) + else: + try: + with open('.install.json') as file: + install_data = json.load(file) + except: + print('\x1b[31;1mPlease install Unifier first.\x1b[0m') + sys.exit(1) + + print('\x1b[33;1mInstalling dependencies...\x1b[0m') + + install_option = install_data['option'] + + exit_code = os.system(f'{binary} boot/dep_installer.py {install_option}{options}') + if not exit_code == 0: + sys.exit(exit_code) + + if depinstall: + print('\x1b[36;1mDependencies installed successfully.\x1b[0m') + sys.exit(0) + + exit_code = os.system(f'{binary} boot/installer.py {install_option}{options}') + + if not exit_code == 0: + print('\x1b[31;1mInstaller has crashed or has been aborted.\x1b[0m') + sys.exit(exit_code) + + # sleep to prevent 429s + time.sleep(5) + +# tomli should be installed by now +try: + import tomli # pylint: disable=import-error +except: + print('\x1b[31;1mCould not import tomli. It should have been installed, please restart the bootloader.\x1b[0m') + sys.exit(1) + +with open('config.toml', 'rb') as file: + # noinspection PyTypeChecker + bot_config = tomli.load(file) + +if clear_tokens: + print('\x1b[37;41;1mWARNING: ALL TOKENS WILL BE CLEARED!\x1b[0m') + print('\x1b[33;1mYou should only clear your tokens if you forgot your password.\x1b[0m') + print('\x1b[33;1mThis process is irreversible. Once it\'s done, there\'s no going back!\x1b[0m') + print() + print('\x1b[33;1mProceed anyways? (y/n)\x1b[0m') + + try: + confirm = input().lower() + if not confirm == 'y': + raise ValueError() + except: + print('\x1b[31;1mAborting.\x1b[0m') + sys.exit(1) + + encryption_password = getpass.getpass('New password: ') + confirm_password = getpass.getpass('Confirm new password: ') + if not encryption_password == confirm_password: + print('\x1b[31;1mPasswords do not match.\x1b[0m') + sys.exit(1) + + os.remove('.encryptedenv') + os.environ['UNIFIER_ENCPASS'] = str(encryption_password) + os.system(f'{binary} boot/tokenmgr.py') + sys.exit(0) + +if manage_tokens: + encryption_password = getpass.getpass('Password: ') + os.environ['UNIFIER_ENCPASS'] = str(encryption_password) + os.system(f'{binary} boot/tokenmgr.py') + sys.exit(0) + +if not boot_file in os.listdir(): + if os.path.isdir('update'): + print(f'\x1b[33;1m{boot_file} is missing, copying from update folder.\x1b[0m') + try: + shutil.copy2(f'update/{boot_file}', boot_file) + except: + print(f'\x1b[31;1mCould not find {boot_file}. Your installation may be corrupted.\x1b[0m') + print(f'Please install a fresh copy of {internal["product_name"]} from {internal["repo"]}.') + sys.exit(1) + +first_boot = False +last_boot = time.time() + +print(f'\x1b[36;1mStarting {internal["product_name"]}...\x1b[0m') + +if '.restart' in os.listdir(): + os.remove('.restart') + print('\x1b[33;1mAn incomplete restart was detected.\x1b[0m') + +restart_options = '' + +choice = None + +while True: + plain = os.path.isfile('.env') + encrypted = os.path.isfile('.encryptedenv') + if not choice is None and os.environ.get('UNIFIER_ENCPASS') is None: + # choice is set but not the password, likely due to wrong password + encryption_password = getpass.getpass('Password: ') + os.environ['UNIFIER_ENCPASS'] = str(encryption_password) + elif not choice is None: + # choice is set and password is correct + if choice == 1: + # do not reencrypt .env + choice = 0 + elif plain and encrypted: + print('\x1b[33;1m.env and .encryptedenv are present. What would you like to do?\x1b[0m') + print('\x1b[33m1. Use .encryptedenv') + print('2. Replace .encryptedenv with .env\x1b[0m') + + try: + choice = int(input()) - 1 + if choice < 0 or choice > 1: + raise ValueError() + except: + print(f'\x1b[31;1mAborting.\x1b[0m') + sys.exit(1) + elif plain: + print( + '\x1b[33;1mNo .encryptedenv file could not be found, but a .env file was found, .env will be encrypted and used.\x1b[0m') + choice = 1 + elif encrypted: + choice = 0 + else: + print('\x1b[31;1mNo .env or .encryptedenv file could be found.\x1b[0m') + print('More info: https://wiki.unifierhq.org/setup-selfhosted/getting-started/unifier#set-bot-token') + sys.exit(1) + + if choice == 0: + encryption_password = os.environ.get('UNIFIER_ENCPASS') + if not encryption_password: + encryption_password = getpass.getpass('Password: ') + + os.environ['UNIFIER_ENCPASS'] = str(encryption_password) + del encryption_password + elif choice == 1: + encryption_password = getpass.getpass('New password: ') + confirm_password = getpass.getpass('Confirm password: ') + os.environ['UNIFIER_ENCPASS'] = str(encryption_password) + del encryption_password + del confirm_password + should_encrypt = True + + os.environ['UNIFIER_ENCOPTION'] = str(choice) + + exit_code = os.system(f'{binary} {boot_file}{restart_options}{options}') + + crash_reboot = False + if not exit_code == 0: + diff = time.time() - last_boot + if autoreboot and first_boot and diff > threshold: + print(f'\x1b[31;1m{internal["product_name"]} has crashed, restarting...\x1b[0m') + crash_reboot = True + else: + print(f'\x1b[31;1m{internal["product_name"]} has crashed.\x1b[0m') + sys.exit(exit_code) + + if crash_reboot or '.restart' in os.listdir(): + if '.restart' in os.listdir(): + x = open('.restart', 'r', encoding='utf-8') + data = x.read().split(' ') + x.close() + + restart_options = (' ' + data[1]) if len(data) > 1 else '' + os.remove('.restart') + + print(f'\x1b[33;1mRestarting {internal["product_name"]}...\x1b[0m') + else: + print(f'\x1b[36;1m{internal["product_name"]} shutdown successful.\x1b[0m') + sys.exit(0) + + first_boot = True + last_boot = time.time() + + # sleep to prevent 429s + time.sleep(5) diff --git a/boot/dep_installer.py b/boot/dep_installer.py new file mode 100644 index 0000000..8316fe4 --- /dev/null +++ b/boot/dep_installer.py @@ -0,0 +1,69 @@ +""" +Unifier - A sophisticated Discord bot uniting servers and platforms +Copyright (C) 2024 Green, ItsAsheer + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +import json +import os +import sys + +install_option = sys.argv[1] if len(sys.argv) > 1 else None + +with open('boot/internal.json') as file: + internal = json.load(file) + +install_options = internal['options'] + +prefix = None + +if not install_option: + for option in install_options: + if option['default']: + install_option = option['id'] + break +else: + for option in install_options: + if option['id'] == install_option: + prefix = option['prefix'] + if prefix == '': + prefix = None + break + +boot_config = {} +try: + with open('boot_config.json') as file: + boot_config = json.load(file) +except: + pass + +binary = boot_config['bootloader'].get('binary', 'py -3' if sys.platform == 'win32' else 'python3') + +print('\x1b[36;1mInstalling dependencies, this may take a while...\x1b[0m') + +user_arg = ' --user' if not boot_config['bootloader'].get('global_dep_install',False) else '' + +if prefix: + code = os.system(f'{binary} -m pip install{user_arg} -U -r requirements_{prefix}.txt') +else: + code = os.system(f'{binary} -m pip install{user_arg} -U -r requirements.txt') + +if not code == 0: + print('\x1b[31;1mCould not install dependencies.\x1b[0m') + print('\x1b[31;1mIf you\'re using a virtualenv, you might want to set global_dep_install to true in bootloader configuration to fix this.\x1b[0m') + sys.exit(code) + +print('\x1b[36;1mDependencies successfully installed.\x1b[0m') +sys.exit(0) diff --git a/boot/installer.py b/boot/installer.py new file mode 100644 index 0000000..9e04d3a --- /dev/null +++ b/boot/installer.py @@ -0,0 +1,188 @@ +""" +Unifier - A sophisticated Discord bot uniting servers and platforms +Copyright (C) 2024 Green, ItsAsheer + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +import asyncio +import sys +import getpass +import json +import nextcord +import tomli +import tomli_w +import traceback +from nextcord.ext import commands +from utils import secrets + +install_option = sys.argv[1] if len(sys.argv) > 1 else None + +with open('boot/internal.json') as file: + internal = json.load(file) + +install_options = internal['options'] + +if not install_option: + for option in install_options: + if option['default']: + install_option = option['id'] + break + +bot = commands.Bot( + command_prefix='u!', + intents=nextcord.Intents.all() +) + +user_id = 0 +server_id = 0 + +if sys.version_info.minor < internal['required_py_version']: + print(f'\x1b[31;49mCannot install {internal["product_name"]}. Python 3.{internal["required_py_version"]} or later is required.\x1b[0m') + sys.exit(1) + +@bot.event +async def on_ready(): + global server_id + + print(f'\x1b[33;1mIs {bot.user.name} ({bot.user.id}) the correct bot? (y/n)\x1b[0m') + answer = input().lower() + + if not answer == 'y': + print(f'\x1b[31;1mAborting.\x1b[0m') + sys.exit(1) + + print(f'\x1b[36;1mAttempting to DM user {user_id}...\x1b[0m') + + user = bot.get_user(user_id) + + for guild in bot.guilds: + for member in guild.members: + if member.id == user_id: + server_id = guild.id + break + + if not server_id == 0: + break + + available = 10 + tries = 0 + + while True: + try: + await user.send('If you can see this message, please return to the console, then type "y".') + break + except: + tries += 1 + + if tries >= available: + print(f'\x1b[31;1mCould not DM user after {available} attempts, aborting.\x1b[0m') + sys.exit(1) + if user: + print(f'\x1b[33;1mCould not DM user. Please enable your DMs for a server you and the bot share.\x1b[0m') + else: + print(f'\x1b[33;1mCould not find user. Please add the bot to a server you are in.\x1b[0m') + print(f'Use this link to add the bot: https://discord.com/api/oauth2/authorize?client_id={bot.user.id}&permissions=537259008&scope=bot') + print(f'\x1b[33;1mTrying again in 30 seconds, {available-tries} tries remaining. Press Ctrl+C to abort.\x1b[0m') + + try: + await asyncio.sleep(30) + except: + print(f'\x1b[31;1mAborting.\x1b[0m') + sys.exit(1) + + print(f'\x1b[33;1mDid you receive a DM from the bot? (y/n)\x1b[0m') + answer = input().lower() + + if not answer == 'y': + print(f'\x1b[31;1mAborting.\x1b[0m') + sys.exit(1) + + print('\x1b[36;1mOwner verified successfully, closing bot.\x1b[0m') + await bot.close() + +print('\x1b[33;1mWe need the ID of the user who will be the instance owner. In most cases this is your user ID.\x1b[0m') +print(f'\x1b[33;1mThe owner will have access to special commands for maintaining your {internal["product_name"]} instance.\x1b[0m') +print('\x1b[33;1mTo copy your ID, go to your Discord settings, then Advanced, then enable Developer mode.\x1b[0m') + +while True: + try: + user_id = int(input()) + break + except KeyboardInterrupt: + print('\x1b[31;49mAborted.\x1b[0m') + sys.exit(1) + except: + print('\x1b[31;49mThis isn\'t an integer, try again.\x1b[0m') + +print('\x1b[33;1mWe will now ask for your bot token.\x1b[0m') +print('\x1b[33;1mThe user verifier will use this token to log on to Discord.\x1b[0m\n') +print(f'\x1b[37;41;1mWARNING: DO NOT SHARE THIS TOKEN, NOT EVEN WITH {internal["maintainer"].upper()}.\x1b[0m') +print(f'\x1b[31;49m{internal["maintainer"]} will NEVER ask for your token. Please keep this token to yourself and only share it with trusted instance maintainers.\x1b[0m') +print('\x1b[31;49mFor security reasons, the installer will hide the input.\x1b[0m') + +token = getpass.getpass() + +encryption_password = '' +salt = '' + +print('\x1b[33;1mWe will now ask for the token encryption salt. This must be an integer.\x1b[0m') +print('\x1b[33;1mAs of Unifier v3.2.0, all tokens must be stored encrypted, even if it\'s stored as an environment variable.\x1b[0m') + +while True: + try: + salt = int(input()) + break + except KeyboardInterrupt: + print('\x1b[31;49mAborted.\x1b[0m') + sys.exit(1) + except: + print('\x1b[31;49mThis isn\'t an integer, try again.\x1b[0m') + +print('\x1b[33;1mWe will now ask for the token encryption password. This is NOT your bot token.\x1b[0m') +print(f'\x1b[37;41;1mWARNING: DO NOT SHARE THIS TOKEN, NOT EVEN WITH {internal["maintainer"].upper()}.\x1b[0m') +print(f'\x1b[31;49m{internal["maintainer"]} will NEVER ask for your encryption password. Please keep this password to yourself and only share it with trusted instance maintainers.\x1b[0m') +print('\x1b[31;49mFor security reasons, the installer will hide the input.\x1b[0m') + +encryption_password = getpass.getpass() + +print('\x1b[36;1mStarting bot...\x1b[0m') + +try: + bot.run(token) +except: + traceback.print_exc() + print('\x1b[31;49mLogin failed. Perhaps your token is invalid?\x1b[0m') + print('\x1b[31;49mMake sure all privileged intents are enabled for the bot.\x1b[0m') + sys.exit(1) + +tokenstore = secrets.TokenStore(False, password=encryption_password, salt=salt, content_override={'TOKEN': token}) +print('\x1b[36;1mYour tokens have been stored securely.\x1b[0m') + +with open('config.toml', 'rb') as file: + config = tomli.load(file) + +config['roles']['owner'] = user_id + +if not internal['skip_server']: + config['moderation']['home_guild'] = server_id + +with open('config.toml', 'wb') as file: + tomli_w.dump(config, file) + +with open('.install.json','w+') as file: + # noinspection PyTypeChecker + json.dump({'product': internal["product"],'setup': False,'option': install_option}, file) + +print(f'\x1b[36;1m{internal["product_name"]} installed successfully.\x1b[0m') diff --git a/boot/internal.json b/boot/internal.json new file mode 100644 index 0000000..2579251 --- /dev/null +++ b/boot/internal.json @@ -0,0 +1,19 @@ +{ + "maintainer": "UnifierHQ", + "product": "unifier-micro", + "product_name": "Unifier Micro", + "base_bootfile": "microfier.py", + "repo": "https://github.com/UnifierHQ/unifie-micror", + "required_py_version": 9, + "skip_server": true, + "options": [ + { + "id": "standard", + "name": "\uD83D\uDC8E Standard", + "description": "Uses the latest stable Nextcord version.", + "default": true, + "prefix": "", + "color": "\\x1b[32" + } + ] +} \ No newline at end of file diff --git a/boot/tokenmgr.py b/boot/tokenmgr.py new file mode 100644 index 0000000..57ddd10 --- /dev/null +++ b/boot/tokenmgr.py @@ -0,0 +1,227 @@ +""" +Unifier - A sophisticated Discord bot uniting servers and platforms +Copyright (C) 2024 Green, ItsAsheer + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +import traceback +import tomli +import tomli_w +import sys +import os +import getpass + +try: + sys.path.insert(0, '.') + from utils import secrets +except: + print('\x1b[31;1mSomething went wrong.\x1b[0m') + sys.exit(1) + + +with open('config.toml', 'rb') as file: + # noinspection PyTypeChecker + config = tomli.load(file) + +salt = config['system']['encrypted_env_salt'] + +try: + tokenmgr = secrets.TokenStore(True, password=os.environ.get('UNIFIER_ENCPASS'), salt=salt) +except ValueError: + print('\x1b[31;1mYou must provide a password.\x1b[0m') + sys.exit(1) + +if not tokenmgr.test_decrypt(): + print('\x1b[31;1mInvalid password. Your encryption password is needed to manage tokens.\x1b[0m') + print('\x1b[31;1mIf you\'ve forgot your password, run the bootscript again with --clear-tokens\x1b[0m') + sys.exit(1) + +def add_token(): + identifier = input('Token identifier: ').upper() + if identifier == '': + print('\x1b[31;1mIdentifier cannot be empty.\x1b[0m') + return + token = getpass.getpass('Token: ') + + try: + tokens = tokenmgr.add_token(identifier, token) + except KeyError: + print('\x1b[31;1mToken already exists.\x1b[0m') + return + + print(f'\x1b[36;1mToken added successfully. You now have {tokens-1} tokens.\x1b[0m') + +def replace_token(): + identifier = input('Token identifier: ').upper() + if identifier == '': + print('\x1b[31;1mIdentifier cannot be empty.\x1b[0m') + return + token = getpass.getpass('New token: ') + password = getpass.getpass('Encryption password: ') + + print('\x1b[37;41;1mWARNING: THIS TOKEN WILL BE REPLACED!\x1b[0m') + print('\x1b[33;1mThis process is irreversible. Once it\'s done, there\'s no going back!\x1b[0m') + print() + print('\x1b[33;1mProceed anyways? (y/n)\x1b[0m') + + try: + confirm = input().lower() + if not confirm == 'y': + raise ValueError() + except: + print('\x1b[31;1mAborting.\x1b[0m') + return + + try: + tokenmgr.replace_token(identifier, token, password) + except KeyError: + print('\x1b[31;1mToken does not exist.\x1b[0m') + return + except ValueError: + print('\x1b[31;1mInvalid password. Your encryption password is needed to replace or delete tokens.\x1b[0m') + return + + print('\x1b[36;1mToken replaced successfully.\x1b[0m') + + +def delete_token(): + identifier = input('Token identifier: ').upper() + if identifier == '': + print('\x1b[31;1mIdentifier cannot be empty.\x1b[0m') + return + password = getpass.getpass('Encryption password: ') + + print('\x1b[37;41;1mWARNING: THIS TOKEN WILL BE DELETED!\x1b[0m') + print('\x1b[33;1mThis process is irreversible. Once it\'s done, there\'s no going back!\x1b[0m') + print() + print('\x1b[33;1mProceed anyways? (y/n)\x1b[0m') + + try: + confirm = input().lower() + if not confirm == 'y': + raise ValueError() + except: + print('\x1b[31;1mAborting.\x1b[0m') + return + + try: + tokens = tokenmgr.delete_token(identifier, password) + except KeyError: + print('\x1b[31;1mToken does not exist.\x1b[0m') + return + except ValueError: + print('\x1b[31;1mInvalid password. Your encryption password is needed to replace or delete tokens.\x1b[0m') + return + + print(f'\x1b[36;1mToken deleted successfully. You now have {tokens-1} tokens.\x1b[0m') + +def list_tokens(): + print(f'\x1b[36;1mYou have {len(tokenmgr.tokens)} tokens.\x1b[0m') + + for index in range(len(tokenmgr.tokens)): + token = tokenmgr.tokens[index] + print(f'\x1b[36m{index + 1}. {token}\x1b[0m') + +def reencrypt_tokens(): + salt = input('New salt integer (leave empty to keep current): ') + if salt == '': + salt = config['system']['encrypted_env_salt'] + else: + try: + salt = int(salt) + except: + print('\x1b[31;1mSalt must be an integer.\x1b[0m') + return + + current_password = getpass.getpass('Current encryption password: ') + password = getpass.getpass('New encryption password: ') + confirm_password = getpass.getpass('Confirm encryption password: ') + + if not password == confirm_password: + print('\x1b[31;1mPasswords do not match.\x1b[0m') + return + + del confirm_password + + print('\x1b[37;41;1mWARNING: YOUR TOKENS WILL BE RE-ENCRYPTED!\x1b[0m') + print('\x1b[33;1mYou will need to use your new encryption password to start Unifier.\x1b[0m') + print('\x1b[33;1mThis process is irreversible. Once it\'s done, there\'s no going back!\x1b[0m') + print() + print('\x1b[33;1mProceed anyways? (y/n)\x1b[0m') + + try: + confirm = input().lower() + if not confirm == 'y': + raise ValueError() + except: + print('\x1b[31;1mAborting.\x1b[0m') + return + + try: + tokenmgr.reencrypt(current_password, password, salt) + except ValueError: + print('\x1b[31;1mInvalid password. Your current encryption password is needed to re-encrypt tokens.\x1b[0m') + return + + if not salt == config['system']['encrypted_env_salt']: + config['system']['encrypted_env_salt'] = salt + with open('config.toml', 'wb') as file: + tomli_w.dump(config, file) + + print('\x1b[36;1mTokens have been re-encrypted successfully.\x1b[0m') + +def command_help(): + print('\x1b[36;1mCommands:\x1b[0m') + print('\x1b[36madd-token\x1b[0m') + print('\x1b[36mreplace-token\x1b[0m') + print('\x1b[36mdelete-token\x1b[0m') + print('\x1b[36mlist-tokens\x1b[0m') + print('\x1b[36mreencrypt-tokens\x1b[0m') + print('\x1b[36mhelp\x1b[0m') + print('\x1b[36mexit\x1b[0m') + + +list_tokens() + +print('Type "help" for a list of commands.') + +while True: + try: + command = input('> ').lower() + except KeyboardInterrupt: + break + + try: + if command == 'add-token': + add_token() + elif command == 'replace-token': + replace_token() + elif command == 'delete-token': + delete_token() + elif command == 'list-tokens': + list_tokens() + elif command == 'reencrypt-tokens': + reencrypt_tokens() + elif command == 'help': + command_help() + elif command == 'exit': + break + else: + print('\x1b[33;1mInvalid command. Type "help" for a list of commands.\x1b[0m') + except KeyboardInterrupt: + pass + except: + traceback.print_exc() + print('\x1b[31;1mAn error occurred.\x1b[0m') diff --git a/boot_config.json b/boot_config.json new file mode 100644 index 0000000..d1ad722 --- /dev/null +++ b/boot_config.json @@ -0,0 +1,4 @@ +{ + "note": "Refer to https://wiki.unifierhq.org/setup-selfhosted/configuring-the-bootloader for more information.", + "bootloader": {} +} \ No newline at end of file diff --git a/config.json b/config.json deleted file mode 100644 index 9b3adf9..0000000 --- a/config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "debug": false, - "package": "microfier", - "owner": 356456393491873795, - "prefix": "u!", - "repo": "https://github.com/greeeen-dev/unifier-micro", - "ping": 0, - "admin_ids": [356456393491873795] -} diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..2b9a7d1 --- /dev/null +++ b/config.toml @@ -0,0 +1,15 @@ +[system] +debug = false +package = "microfier" +encrypted_env_salt = 10 # change this to whatever you want, as long as the bot doesn't crash + +[roles] +owner = -1 +admin_ids = [] + +[bot] +prefix = "u!" +ping = 0 + +[plugin] +"repo" = "https://github.com/greeeen-dev/unifier-micro" diff --git a/microfier.py b/microfier.py index 77cee5f..cc44bb5 100644 --- a/microfier.py +++ b/microfier.py @@ -26,10 +26,13 @@ import sys import os import re -from utils import log, ui +from utils import log, ui, secrets import math +import tomli +import tomli_w +import traceback -version = '2.0.1' +version = '3.0.0' def timetoint(t,timeoutcap=False): try: @@ -94,8 +97,7 @@ def __init__(self, *args, **kwargs): self.file_path = 'data.json' # Ensure necessary keys exist - self.update({'rules': {}, 'rooms': {}, 'restricted': [], 'locked': [], - 'blocked': {}, 'banned': {}, 'moderators': []}) + self.update({'rooms': {}, 'blocked': {}, 'banned': {}, 'moderators': []}) # Load data self.load_data() @@ -110,12 +112,27 @@ def load_data(self): def save_data(self): with open(self.file_path, 'w') as file: + # noinspection PyTypeChecker json.dump(self, file, indent=4) -def is_user_admin(id): +class Colors: # format: 0xHEXCODE + greens_hair = 0xa19e78 + unifier = 0xed4545 + green = 0x2ecc71 + dark_green = 0x1f8b4c + purple = 0x9b59b6 + red = 0xe74c3c + blurple = 0x7289da + gold = 0xd4a62a + error = 0xff838c + warning = 0xe4aa54 + success = 0x11ad79 + critical = 0xff0000 + +def is_user_admin(user_id): try: global admin_ids - if id in admin_ids: + if user_id in admin_ids or user_id == config['owner']: return True else: return False @@ -124,7 +141,7 @@ def is_user_admin(id): def is_room_restricted(room,db): try: - if room in db['restricted']: + if db['rooms'][room]['meta']['restricted']: return True else: return False @@ -133,7 +150,7 @@ def is_room_restricted(room,db): def is_room_locked(room,db): try: - if room in db['locked']: + if db['rooms'][room]['meta']['locked']: return True else: return False @@ -146,9 +163,102 @@ async def fetch_message(message_id): return message raise ValueError("No message found") +async def convert_1(): + """Converts data structure to be v3.0.0-compatible. + Eliminates the need for a lot of unneeded keys.""" + if not 'rules' in db.keys(): + # conversion is not needed + return + for room in db['rooms']: + db['rooms'][room] = {'meta':{ + 'rules': db['rules'][room], + 'restricted': room in db['restricted'], + 'locked': room in db['locked'], + 'private': False, + 'private_meta': { + 'server': None, + 'allowed': [], + 'invites': [], + 'platform': 'discord' + }, + 'emoji': None, + 'description': None, + 'display_name': None, + 'banned': [] + },'discord': db['rooms'][room]} + + db.pop('rules') + db.pop('restricted') + db.pop('locked') + + # not sure what to do about the data stored in rooms_revolt key now... + # maybe delete the key entirely? or keep it in case conversion went wrong? + + db.save_data() + + +try: + with open('.install.json') as file: + install_info = json.load(file) + + if not install_info['product'] == 'unifier-micro': + print('This installation is not compatible with Unifier Micro.') + sys.exit(1) +except: + if sys.platform == 'win32': + print('To start the bot, please run "run.bat" instead.') + else: + print('To start the bot, please run "./run.sh" instead.') + print('If you get a "Permission denied" error, run "chmod +x run.sh" and try again.') + sys.exit(1) + +config_file = 'config.toml' +if 'devmode' in sys.argv: + config_file = 'devconfig.toml' + +valid_toml = False +try: + with open(config_file, 'rb') as file: + config = tomli.load(file) + valid_toml = True +except: + try: + with open('config.json') as file: + config = json.load(file) + except: + traceback.print_exc() + print('\nFailed to load config.toml file.\nIf the error is a JSONDecodeError, it\'s most likely a syntax error.') + sys.exit(1) + + # toml is likely in update files, pull from there + with open('update/config.toml', 'rb') as file: + newdata = tomli.load(file) -with open('config.json', 'r') as file: - config = json.load(file) + def update_toml(old, new): + for key in new: + for newkey in new[key]: + if newkey in old.keys(): + new[key].update({newkey: old[newkey]}) + return new + + config = update_toml(config, newdata) + + with open(config_file, 'wb+') as file: + tomli_w.dump(config, file) + +try: + with open('boot_config.json', 'r') as file: + boot_data = json.load(file) +except: + boot_data = {} + +newdata = {} + +for key in config: + for newkey in config[key]: + newdata.update({newkey: config[key][newkey]}) + +config = newdata env_loaded = load_dotenv() @@ -158,6 +268,24 @@ async def fetch_message(message_id): logger = log.buildlogger(package,'core',level) +should_encrypt = int(os.environ['UNIFIER_ENCOPTION']) == 1 +tokenstore = secrets.TokenStore(not should_encrypt, os.environ['UNIFIER_ENCPASS'], config['encrypted_env_salt'], config['debug']) + +if should_encrypt: + tokenstore.to_encrypted(os.environ['UNIFIER_ENCPASS'], config['encrypted_env_salt']) + os.remove('.env') + +room_template = { + 'rules': [], 'restricted': False, 'locked': False, 'private': False, + 'private_meta': { + 'server': None, + 'allowed': [], + 'invites': [], + 'platform': 'discord' + }, + 'emoji': None, 'description': None, 'display_name': None, 'banned': [] +} + if not '.welcome.txt' in os.listdir(): x = open('.welcome.txt','w+') x.close() @@ -170,6 +298,10 @@ async def fetch_message(message_id): logger.critical('Unifier is licensed under the AGPLv3, meaning you need to make your source code available to users. Please add a repository to the config file under the repo key.') sys.exit(1) +if not valid_toml: + logger.warning('From v3.0.0, Unifier will use config.toml rather than config.json.') + logger.warning('To change your Unifier configuration, please use the new file.') + if not env_loaded or not "TOKEN" in os.environ: logger.critical('Could not find token from .env file! More info: https://unichat-wiki.pixels.onl/setup-selfhosted/getting-started/unifier#set-bot-token') sys.exit(1) @@ -180,6 +312,8 @@ async def fetch_message(message_id): db = AutoSaveDict({}) db.load_data() +colors = Colors + messages = [] ut_total = round(time.time()) @@ -230,6 +364,8 @@ async def on_ready(): logger.debug(f'Pinging servers every {round(config["ping"])} seconds') elif config['ping'] <= 0: logger.debug(f'Periodic pinging disabled') + logger.debug('Restructuring room data...') + await convert_1() logger.info('Unifier is ready!') @bot.event @@ -237,6 +373,86 @@ async def on_disconnect(): global disconnects disconnects += 1 +async def bot_shutdown(ctx, restart=False): + embed = nextcord.Embed(color=colors.warning) + + if restart: + embed.title = f':warning: Restart the bot?' + embed.description = 'The bot will automatically restart in 60 seconds.' + else: + embed.title = f':warning: Shut the bot down?' + embed.description = 'The bot will automatically shut down in 60 seconds.' + + components = ui.MessageComponents() + + btns_row = ui.ActionRow( + nextcord.ui.Button( + style=nextcord.ButtonStyle.red, + label='Restart' if restart else 'Shut down', + custom_id='shutdown' + ), + nextcord.ui.Button( + style=nextcord.ButtonStyle.gray, + label='Nevermind', + custom_id='cancel' + ) + ) + + components.add_row(btns_row) + + msg = await ctx.send(embed=embed, view=components) + + def check(interaction): + return interaction.user.id == ctx.author.id and interaction.message.id == msg.id + + try: + interaction = await bot.wait_for('interaction', check=check, timeout=60) + + await interaction.response.edit_message(view=None) + except: + await msg.edit(view=None) + return + + if interaction.data['custom_id'] == 'cancel': + return + + embed.title = embed.title.replace(':warning:', ':hourglass:', 1) + await msg.edit(embed=embed) + + logger.info("Attempting graceful shutdown...") + try: + logger.info("Backing up data...") + db.save_data() + logger.info("Backup complete") + if restart: + embed.title = f':white_check_mark: Restarting...' + embed.description = 'Bot will now restart.' + else: + embed.title = f':white_check_mark: Shutting down...' + embed.description = 'Bot will now shut down.' + embed.colour = colors.success + await msg.edit(embed=embed) + except: + logger.exception("Graceful shutdown failed") + if restart: + embed.title = f':x: Restart failed' + embed.description = 'The restart failed.' + else: + embed.title = f':x: Shutdown failed' + embed.description = 'The shutdown failed.' + embed.colour = colors.error + await msg.edit(embed=embed) + return + + if restart: + x = open('.restart', 'w+') + x.write(f'{time.time()}') + x.close() + + logger.info("Shutdown complete") + await bot.close() + sys.exit(0) + @bot.command(description='Shows this command.') async def help(ctx): show_sysmgr = False @@ -334,6 +550,7 @@ def search_filter(query, query_cmd): max_values=1, min_values=1, custom_id='selection', placeholder='Command...' ) + # noinspection PyTypeChecker cmds = await bot.loop.run_in_executor( None,lambda: sorted( cmds, @@ -555,6 +772,18 @@ async def uptime(ctx): ) await ctx.send(embed=embed) +@bot.command(aliases=['poweroff'], hidden=True, description='Gracefully shuts the bot down.') +async def shutdown(ctx): + if not ctx.author.id == config['owner']: + return + await bot_shutdown(ctx) + +@bot.command(aliases=['reboot'], hidden=True, description='Gracefully restarts the bot.') +async def restart(ctx): + if not ctx.author.id == config['owner']: + return + await bot_shutdown(ctx, restart=True) + @bot.command(hidden=True,description='Adds a moderator to the instance.') async def addmod(ctx,*,userid): if not is_user_admin(ctx.author.id): @@ -616,21 +845,88 @@ async def make(ctx,*,room): return await ctx.send('Room names may only contain alphabets, numbers, dashes, and underscores.') if room in list(db['rooms'].keys()): return await ctx.send('This room already exists!') - db['rooms'].update({room:{}}) - db['rules'].update({room:[]}) + db['rooms'].update({room:{'meta': dict(room_template), 'discord': {}}}) db.save_data() await ctx.send(f'Created room `{room}`!') +@bot.command(description='Disbands a room.') +async def disband(ctx, room): + room = room.lower() + if not room in db['rooms'].keys(): + return await ctx.send('This room does not exist!') + + if not is_user_admin(ctx.author.id): + return await ctx.send('Only admins can disband rooms!') + + embed = nextcord.Embed( + title=f':warning: Disband `{room}`?', + description='Once the room is disbanded, it\'s gone forever!', + color=colors.warning + ) + view = ui.MessageComponents() + view.add_row( + ui.ActionRow( + nextcord.ui.Button( + style=nextcord.ButtonStyle.red, + label='Disband', + custom_id='disband' + ), + nextcord.ui.Button( + style=nextcord.ButtonStyle.gray, + label='Cancel', + custom_id='cancel' + ) + ) + ) + msg = await ctx.send(embed=embed, view=view) + view.clear_items() + view.row_count = 0 + view.add_row( + ui.ActionRow( + nextcord.ui.Button( + style=nextcord.ButtonStyle.red, + label='Disband', + custom_id='disband', + disabled=True + ), + nextcord.ui.Button( + style=nextcord.ButtonStyle.gray, + label='Cancel', + custom_id='cancel', + disabled=True + ) + ) + ) + + def check(interaction): + return interaction.message.id == msg.id and interaction.user.id == ctx.author.id + + try: + interaction = await bot.wait_for('interaction',check=check,timeout=60) + except: + return await msg.edit(view=view) + + if interaction.data['custom_id'] == 'cancel': + return await interaction.response.edit_message(view=view) + + db['rooms'].pop(room) + embed.title = f':white_check_mark: Disbanded `{room}`' + embed.description = 'The room was disbanded successfully.' + embed.colour = colors.success + await interaction.response.edit_message(embed=embed,view=None) + # noinspection PyTypeChecker + await bot.loop.run_in_executor(None, lambda: db.save_data()) + @bot.command(hidden=True,description="Adds a given rule to a given room.") async def addrule(ctx,room,*,rule): if not is_user_admin(ctx.author.id): return await ctx.send('Only admins can modify rules!') room = room.lower() - if not room in list(db['rules'].keys()): + if not room in list(db['rooms'].keys()): return await ctx.send('This room does not exist!') - if len(db['rules'][room]) >= 25: + if len(db['rooms'][room]['meta']['rules']) >= 25: return await ctx.send('You can only have up to 25 rules in a room!') - db['rules'][room].append(rule) + db['rules'][room]['rules'].append(rule) db.save_data() await ctx.send('Added rule!') @@ -645,9 +941,9 @@ async def delrule(ctx,room,*,rule): raise ValueError() except: return await ctx.send('Rule must be a number higher than 0.') - if not room in list(db['rules'].keys()): + if not room in list(db['rooms'].keys()): return await ctx.send('This room does not exist!') - db['rules'][room].pop(rule-1) + db['rooms'][room]['meta']['rules'].pop(rule-1) db.save_data() await ctx.send('Removed rule!') @@ -666,11 +962,8 @@ async def rules(ctx, *, room=''): index = 0 text = '' - if room in list(db['rules'].keys()): - rules = db['rules'][room] - if len(rules) == 0: - return await ctx.send('The room creator hasn\'t added rules yet. For now, follow `main` room rules.') - else: + rules = db['rooms'][room]['meta']['rules'] + if len(rules) == 0: return await ctx.send('The room creator hasn\'t added rules yet. For now, follow `main` room rules.') for rule in rules: if text == '': @@ -686,17 +979,17 @@ async def rules(ctx, *, room=''): hidden=True, description='Restricts/unrestricts room. Only admins will be able to collect to this room when restricted.' ) -async def roomrestrict(ctx,*,room): +async def restrict(ctx,*,room): if not is_user_admin(ctx.author.id): return await ctx.send('Only admins can modify rooms!') room = room.lower() if not room in list(db['rooms'].keys()): return await ctx.send('This room does not exist!') - if room in db['restricted']: - db['restricted'].remove(room) + if db['rooms'][room]['meta']['restricted']: + db['rooms'][room]['meta']['restricted'] = False await ctx.send(f'Unrestricted `{room}`!') else: - db['restricted'].append(room) + db['rooms'][room]['meta']['restricted'] = True await ctx.send(f'Restricted `{room}`!') db.save_data() @@ -704,20 +997,40 @@ async def roomrestrict(ctx,*,room): hidden=True, description='Locks/unlocks a room. Only moderators and admins will be able to chat in this room when locked.' ) -async def roomlock(ctx,*,room): +async def lock(ctx,*,room): if not is_user_admin(ctx.author.id): return await ctx.send('Only admins can modify rooms!') room = room.lower() if not room in list(db['rooms'].keys()): return await ctx.send('This room does not exist!') - if room in db['locked']: - db['locked'].remove(room) + if db['rooms'][room]['meta']['locked']: + db['rooms'][room]['meta']['locked'] = False await ctx.send(f'Unlocked `{room}`!') else: - db['locked'].append(room) + db['rooms'][room]['meta']['locked'] = True await ctx.send(f'Locked `{room}`!') db.save_data() +@bot.command(name='display-name', hidden=True, description='Sets room display name.') +async def display_name(ctx, room, *, name=''): + if not is_user_admin(ctx.author.id): + return await ctx.send('Only admins can modify rooms!') + room = room.lower() + if not room in list(db['rooms'].keys()): + return await ctx.send('This room does not exist!') + + if len(name) == 0: + if not db['rooms'][room]['meta']['display_name']: + return await ctx.send('There is no display name to reset for this room.') + db['rooms'][room]['meta']['display_name'] = None + db.save_data() + return await ctx.send('Display name removed.') + elif len(name) > 32: + return await ctx.send('Display name is too long. Please keep it within 32 characters.') + db['rooms'][room]['meta']['display_name'] = name + db.save_data() + await ctx.send(f'Updated display name to `{name}`!') + @bot.command(description='Measures bot latency.') async def ping(ctx): t = time.time() @@ -796,6 +1109,9 @@ async def rooms(ctx): if index >= len(roomlist): break name = roomlist[index] + display_name = ( + db['rooms'][name]['meta']['display_name'] or name + ) emoji = ( '\U0001F527' if is_room_restricted(roomlist[index],db) else '\U0001F512' if is_room_locked(roomlist[index],db) else @@ -808,12 +1124,15 @@ async def rooms(ctx): ) embed.add_field( - name=f'{emoji} `{name}`', + name=f'{emoji} '+( + f'{display_name} (`{name}`)' if db['rooms'][name]['meta']['display_name'] else + f'`{display_name}`' + ), value=description, inline=False ) selection.add_option( - label=name, + label=display_name, emoji=emoji, description=description, value=name @@ -896,6 +1215,7 @@ def search_filter(query, query_cmd): max_values=1, min_values=1, custom_id='selection', placeholder='Room...' ) + # noinspection PyTypeChecker roomlist = await bot.loop.run_in_executor(None, lambda: sorted( roomlist, key=lambda x: x.lower() @@ -906,6 +1226,9 @@ def search_filter(query, query_cmd): if index >= len(roomlist): break room = roomlist[index] + display_name = ( + db['rooms'][room]['meta']['display_name'] or room + ) emoji = ( '\U0001F527' if is_room_restricted(roomlist[index], db) else '\U0001F512' if is_room_locked(roomlist[index], db) else @@ -917,12 +1240,15 @@ def search_filter(query, query_cmd): 'Public room' ) embed.add_field( - name=f'{emoji} `{room}`', + name=f'{emoji} '+( + f'{display_name} (`{room}`)' if db['rooms'][room]['meta']['display_name'] else + f'`{display_name}`' + ), value=roomdesc, inline=False ) selection.add_option( - label=room, + label=display_name, description=roomdesc if len(roomdesc) <= 100 else roomdesc[:-(len(roomdesc) - 97)] + '...', value=room, emoji=emoji @@ -1011,6 +1337,9 @@ def search_filter(query, query_cmd): if was_searching else f'{bot.user.global_name or bot.user.name} rooms / {roomname}' ) + display_name = ( + db['rooms'][roomname]['meta']['display_name'] or roomname + ) description = ( db['descriptions'][roomname] if roomname in db['descriptions'].keys() else 'This room has no description.' @@ -1020,7 +1349,10 @@ def search_filter(query, query_cmd): '\U0001F512' if is_room_locked(roomname, db) else '\U0001F310' ) - embed.description = f'# **{emoji} `{roomname}`**\n{description}' + if db['rooms'][roomname]['meta']['display_name']: + embed.description = f'# **{emoji} {display_name}**\n`{roomname}`\n\n{description}' + else: + embed.description = f'# **{emoji} `{display_name}`**\n{description}' components.add_rows( ui.ActionRow( nextcord.ui.Button( @@ -1045,10 +1377,7 @@ def search_filter(query, query_cmd): ) index = 0 text = '' - if roomname in list(db['rules'].keys()): - rules = db['rules'][roomname] - else: - rules = [] + rules = db['rooms'][roomname]['rules'] for rule in rules: if text == '': text = f'1. {rule}' @@ -1136,7 +1465,7 @@ def check(interaction): @bot.command(aliases=['guilds'],description='Lists all servers connected to a given room.') async def servers(ctx,*,room='main'): try: - data = db['rooms'][room] + data = db['rooms'][room]['discord'] except: return await ctx.send(f'This isn\'t a valid room. Run `{bot.command_prefix}rooms` for a list of all rooms.') text = '' @@ -1163,7 +1492,7 @@ async def bind(ctx, *, room=''): room = 'main' await ctx.send('**No room was given, defaulting to main**') try: - data = db['rooms'][room] + data = db['rooms'][room]['discord'] except: return await ctx.send(f'This isn\'t a valid room. Run `{bot.command_prefix}rooms` for a list of rooms.') embed = nextcord.Embed(title='Ensuring channel is not connected...', description='This may take a while.') @@ -1190,10 +1519,10 @@ async def bind(ctx, *, room=''): f'Your server is already linked to this room.\n**Accidentally deleted the webhook?** `{bot.command_prefix}unlink` it then `{bot.command_prefix}link` it back.') index = 0 text = '' - if len(db['rules'][room]) == 0: + if len(db['rooms'][room]['meta']['rules']) == 0: text = f'No rules exist yet for this room! For now, follow the main room\'s rules.\nYou can always view rules if any get added using `{bot.command_prefix}rules {room}`.' else: - for rule in db['rules'][room]: + for rule in db['rooms'][room]['meta']['rules']: if text == '': text = f'1. {rule}' else: @@ -1240,10 +1569,7 @@ def check(interaction): if resp.data['custom_id'] == 'reject': return webhook = await ctx.channel.create_webhook(name='Unifier Bridge') - data = db['rooms'][room] - guild = [webhook.id] - data.update({f'{ctx.guild.id}': guild}) - db['rooms'][room] = data + db['rooms'][room]['discord'].update({f'{ctx.guild.id}': [webhook.id]}) db.save_data() await ctx.send( '# :white_check_mark: Linked channel to Unifier network!\nYou can now send messages to the Unifier network through this channel. Say hi!') @@ -1262,10 +1588,9 @@ async def unbind(ctx, *, room=''): if not ctx.author.guild_permissions.manage_channels and not is_user_admin(ctx.author.id): return await ctx.send('You don\'t have the necessary permissions.') room = room.lower() - try: - data = db['rooms'][room] - except: - return await ctx.send('This isn\'t a valid room. Try `main`, `pr`, `prcomments`, or `liveries` instead.') + if not room in db['rooms'].keys(): + return await ctx.send('This isn\'t a valid room!') + data = db['rooms'][room]['discord'] try: try: hooks = await ctx.guild.webhooks() @@ -1279,8 +1604,7 @@ async def unbind(ctx, *, room=''): if webhook.id in hook_ids: await webhook.delete() break - data.pop(f'{ctx.guild.id}') - db['rooms'][room] = data + db['rooms'][room]['discord'].pop(f'{ctx.guild.id}') db.save_data() await ctx.send( '# :white_check_mark: Unlinked channel from Unifier network!\nThis channel is no longer linked, nothing from now will be bridged.') @@ -1288,8 +1612,8 @@ async def unbind(ctx, *, room=''): await ctx.send('Something went wrong - check my permissions.') raise -@bot.command(aliases=['ban'],description='Blocks a user or server from bridging messages to your server.') -async def restrict(ctx, *, target): +@bot.command(description='Blocks a user or server from bridging messages to your server.') +async def block(ctx, *, target): if not (ctx.author.guild_permissions.administrator or ctx.author.guild_permissions.kick_members or ctx.author.guild_permissions.ban_members): return await ctx.send('You cannot restrict members/servers.') @@ -1315,8 +1639,8 @@ async def restrict(ctx, *, target): db.save_data() await ctx.send('User/server can no longer forward messages to this channel!') -@bot.command(hidden=True,description='Blocks a user or server from bridging messages through Unifier.') -async def globalban(ctx, target, duration, *, reason='no reason given'): +@bot.command(aliases=['globalban'],hidden=True,description='Blocks a user or server from bridging messages through Unifier.') +async def ban(ctx, target, duration, *, reason='no reason given'): if not ctx.author.id in db['moderators']: return forever = (duration.lower() == 'inf' or duration.lower() == 'infinite' or @@ -1530,7 +1854,7 @@ async def on_message(message): for room in list(db['rooms'].keys()): try: for hook in hooks: - if hook.id == db['rooms'][room][f'{message.guild.id}'][0]: + if hook.id == db['rooms'][room]['discord'][f'{message.guild.id}'][0]: roomname = room break except: @@ -1541,7 +1865,7 @@ async def on_message(message): if not roomname: return - if ('nextcord.gg/' in message.content or 'nextcord.com/invite/' in message.content or + if ('discord.gg/' in message.content or 'discord.com/invite/' in message.content or 'discordapp.com/invite/' in message.content): try: await message.delete() @@ -1599,7 +1923,7 @@ async def on_message(message): if not trimmed: donotshow = True - for guild_id in list(db['rooms'][roomname].keys()): + for guild_id in list(db['rooms'][roomname]['discord'].keys()): if int(guild_id)==message.guild.id: continue guild = bot.get_guild(int(guild_id)) @@ -1621,7 +1945,7 @@ async def on_message(message): except: files.append(await attachment.to_file(use_cached=True, spoiler=False)) - webhook: nextcord.Webhook = await bot.fetch_webhook(db['rooms'][roomname][f'{guild.id}'][0]) + webhook: nextcord.Webhook = await bot.fetch_webhook(db['rooms'][roomname]['discord'][f'{guild.id}'][0]) components = None if reply_msg: @@ -1758,17 +2082,20 @@ async def on_message_delete(message): roomname = msg.room - for guild_id in list(db['rooms'][roomname].keys()): + for guild_id in list(db['rooms'][roomname]['discord'].keys()): if int(guild_id)==message.guild.id: continue guild = bot.get_guild(int(guild_id)) try: msg_id = int(await msg.fetch_id(str(guild.id))) - webhook = await bot.fetch_webhook(db['rooms'][roomname][f'{guild.id}'][0]) + webhook = await bot.fetch_webhook(db['rooms'][roomname]['discord'][f'{guild.id}'][0]) except: continue await webhook.delete_message(msg_id) -bot.run(os.environ.get('TOKEN')) +try: + bot.run(tokenstore.retrieve('TOKEN')) +except KeyboardInterrupt: + sys.exit(0) diff --git a/requirements.txt b/requirements.txt index 6dcb91c..134153f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ nextcord -python-dotenv \ No newline at end of file +python-dotenv +tomli +tomli-w +pycryptodome \ No newline at end of file diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..9cfa918 --- /dev/null +++ b/run.bat @@ -0,0 +1,9 @@ +@echo off +py -3 -V >nul 2>&1 + +if NOT ERRORLEVEL 0 ( + echo on + echo Could not find a Python 3 installation. +) else ( + py -3 boot/bootloader.py %* +) diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..8ba0660 --- /dev/null +++ b/run.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +FILEPATH="$(which python3)" + +if [[ -z $FILEPATH ]]; then + echo "Could not find a Python 3 installation." + exit 1 +fi + +python3 ./boot/bootloader.py "$@" diff --git a/utils/secrets.py b/utils/secrets.py new file mode 100644 index 0000000..10ec680 --- /dev/null +++ b/utils/secrets.py @@ -0,0 +1,258 @@ +""" +Unifier - A sophisticated Discord bot uniting servers and platforms +Copyright (C) 2024 Green, ItsAsheer + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +import os +import json +import base64 +import traceback +import string +from dotenv import load_dotenv +from Crypto.Random import random +from Crypto.Protocol.KDF import PBKDF2 +from Crypto.Cipher import AES +from Crypto import Random as CryptoRandom +from Crypto.Util.Padding import pad, unpad + +# import ujson if installed +try: + import ujson as json # pylint: disable=import-error +except: + pass + +class Encryptor: + def __init__(self): + pass + + def encrypt(self, encoded, password, salt): + """Encrypts a given bytes object.""" + __key = PBKDF2(password, salt, dkLen=32) + + iv = CryptoRandom.get_random_bytes(16) + __cipher = AES.new(__key, AES.MODE_CBC, iv=iv) + result = __cipher.encrypt(pad(encoded, AES.block_size)) + del __key + del __cipher + return result, base64.b64encode(iv).decode('ascii') + + def decrypt(self, encrypted, password, salt, iv_string): + """Decrypts a given encrypted bytes object.""" + iv = base64.b64decode(iv_string) + __key = PBKDF2(password, salt, dkLen=32) + __cipher = AES.new(__key, AES.MODE_CBC, iv=iv) + result = unpad(__cipher.decrypt(encrypted), AES.block_size) + del __key + del __cipher + return result + +class TokenStore: + def __init__(self, encrypted, password=None, salt=None, debug=False, content_override=None): + self.__is_encrypted = encrypted + self.__encryptor = Encryptor() + self.__password = password + self.__salt = salt + + if encrypted: + if not password: + raise ValueError('encryption password must be provided') + if not salt: + raise ValueError('encryption salt must be provided') + + # file is in json format + try: + with open('.encryptedenv', 'r') as file: + self.__data = json.load(file) + with open('.ivs', 'r') as file: + self.__ivs = json.load(file) + except: + self.__data = {} + self.__ivs = {} + else: + # file is in dotenv format, load using load_dotenv + # we will not encapsulate dotenv data for the sake of backwards compatibility + if content_override: + # content override is a feature only to be used by bootloader + self.__data = content_override + else: + load_dotenv() + self.__data = os.environ + + self.__debug = debug + + @property + def encrypted(self): + return self.__is_encrypted + + @property + def ivs(self): + # initialization vectors are public, so they can be safely displayed in plaintext + return self.__ivs + + @property + def debug(self): + return self.__debug + + @property + def tokens(self): + if not self.__is_encrypted: + raise ValueError('cannot retrieve keys when tokens are unencrypted') + + tokens = list(self.__data.keys()) + tokens.remove('test') + return tokens + + def to_encrypted(self, password, salt): + dotenv = open('.env', 'r') + lines = dotenv.readlines() + dotenv.close() + + keys = [] + for line in lines: + key = line.split('=', 1)[0] + while key.endswith(' '): + key = key[:-1] + keys.append(key) + + encrypted_env = {'test': None} + ivs = {'test': None} + + test_value, test_iv = self.__encryptor.encrypt(str.encode( + ''.join([random.choice(string.ascii_letters + string.digits) for _ in range(16)]) + ), password, salt) + + encrypted_env['test'] = base64.b64encode(test_value).decode('ascii') + ivs['test'] = test_iv + + # we can get values from dotenv, since that's been done if TokenStore is not encrypted + + for key in keys: + __token = os.environ.get(key) + if not __token: + continue + + encrypted_value, iv = self.__encryptor.encrypt(str.encode(__token), password, salt) + encrypted_env.update({key: base64.b64encode(encrypted_value).decode('ascii')}) + ivs.update({key: iv}) + del os.environ[key] + + with open('.encryptedenv', 'w+') as file: + # noinspection PyTypeChecker + json.dump(encrypted_env, file) + + with open('.ivs', 'w+') as file: + # noinspection PyTypeChecker + json.dump(ivs, file) + + self.__data = encrypted_env + self.__ivs = ivs + self.__password = password + self.__salt = salt + self.__is_encrypted = True + + def test_decrypt(self, password=None): + if not self.__is_encrypted: + return True + + try: + self.__encryptor.decrypt(base64.b64decode(self.__data['test']), password or self.__password, self.__salt, self.__ivs['test']) + except: + if self.__debug: + traceback.print_exc() + + return False + return True + + def retrieve(self, identifier): + data = str.encode(self.__data[identifier]) + iv = self.__ivs[identifier] + decrypted = self.__encryptor.decrypt(base64.b64decode(data), self.__password, self.__salt, iv) + return decrypted.decode('utf-8') + + def add_token(self, identifier, token): + if identifier in self.__data.keys(): + raise KeyError('token already exists') + + encrypted, iv = self.__encryptor.encrypt(str.encode(token), self.__password, self.__salt) + self.__data.update({identifier: base64.b64encode(encrypted).decode('ascii')}) + self.__ivs.update({identifier: iv}) + self.save('.encryptedenv', '.ivs') + return len(self.__data) + + def replace_token(self, identifier, token, password): + # password prompt to prevent unauthorized token deletion + if not self.test_decrypt(password=password): + raise ValueError('invalid password') + + if not identifier in self.tokens: + raise KeyError('token does not exist') + + if identifier == 'test': + raise ValueError('cannot replace token, this is needed for password verification') + + encrypted, iv = self.__encryptor.encrypt(str.encode(token), self.__password, self.__salt) + self.__data.update({identifier: base64.b64encode(encrypted).decode('ascii')}) + self.__ivs.update({identifier: iv}) + self.save('.encryptedenv', '.ivs') + + def delete_token(self, identifier, password): + # password prompt to prevent unauthorized token deletion + if not self.test_decrypt(password=password): + raise ValueError('invalid password') + + if not identifier in self.tokens: + raise KeyError('token does not exist') + + if identifier == 'test': + raise ValueError('cannot delete token, this is needed for password verification') + + del self.__data[identifier] + del self.__ivs[identifier] + self.save('.encryptedenv', '.ivs') + return len(self.__data) + + def reencrypt(self, current_password, password, salt): + if not self.test_decrypt(password=current_password): + raise ValueError('invalid password') + + for key in self.__data.keys(): + token = self.retrieve(key) + encrypted, iv = self.__encryptor.encrypt(str.encode(token), password, salt) + self.__data[key] = base64.b64encode(encrypted).decode('ascii') + self.__ivs[key] = iv + + self.__password = password + self.__salt = salt + self.save('.encryptedenv', '.ivs') + + def save(self, filename, iv_filename): + if not self.__is_encrypted: + raise ValueError('cannot save unencrypted data') + + test_value, test_iv = self.__encryptor.encrypt(str.encode( + ''.join([random.choice(string.ascii_letters + string.digits) for _ in range(16)]) + ), self.__password, self.__salt) + + self.__data['test'] = base64.b64encode(test_value).decode('ascii') + self.__ivs['test'] = test_iv + + with open(filename, 'w+') as file: + # noinspection PyTypeChecker + json.dump(self.__data, file) + + with open(iv_filename, 'w+') as file: + # noinspection PyTypeChecker + json.dump(self.__ivs, file)