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)