From 5f8951acdc902d059075bbf37e792dc50872eeaa Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Mon, 15 Jul 2024 10:21:39 +0200 Subject: [PATCH 01/22] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6c4a146..d8d0e34 100644 --- a/README.md +++ b/README.md @@ -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 From 0c9418bd66c9da9ac712afbed01d8f9e1d2fc951 Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Mon, 15 Jul 2024 10:22:19 +0200 Subject: [PATCH 02/22] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d8d0e34..9bb4689 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- + Unifier Micro

From 2493487a391ca707c95f6082db831f37b8b7e70d Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Mon, 15 Jul 2024 10:25:31 +0200 Subject: [PATCH 03/22] this logo sucks --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9bb4689..1d34a35 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- + Unifier Micro

From ae6d3f20e9f34b7dc74b4615619fee504cda793b Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Wed, 18 Sep 2024 09:54:43 +0200 Subject: [PATCH 04/22] unifier v3 --- boot/bootloader.py | 167 ++++++++++++++++++++++++++++++++++++++++++ boot/dep_installer.py | 54 ++++++++++++++ boot/installer.py | 160 ++++++++++++++++++++++++++++++++++++++++ boot/internal.json | 8 ++ microfier.py | 149 +++++++++++++++++++++++++++---------- requirements.txt | 4 +- run.bat | 9 +++ run.sh | 10 +++ 8 files changed, 523 insertions(+), 38 deletions(-) create mode 100644 boot/bootloader.py create mode 100644 boot/dep_installer.py create mode 100644 boot/installer.py create mode 100644 boot/internal.json create mode 100644 run.bat create mode 100644 run.sh diff --git a/boot/bootloader.py b/boot/bootloader.py new file mode 100644 index 0000000..518d713 --- /dev/null +++ b/boot/bootloader.py @@ -0,0 +1,167 @@ +import os +import sys +import shutil +import json +import time + +reinstall = '--reinstall' in sys.argv + +install_options = [ + { + 'id': 'stable', + 'name': '\U0001F48E Standard', + 'description': 'Uses the latest stable Nextcord version.', + 'default': True, + 'prefix': '', + 'color': '\x1b[32' + } +] + +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) + + +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: + if os.path.isdir('update') and not reinstall: + # 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: + json.dump( + { + 'product': internal["product"], + 'setup': False, + 'option': 'optimized' + }, + file + ) + else: + # this installation is fresh + 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) + + exit_code = os.system(f'{binary} boot/dep_installer.py {install_option}{options}') + if not exit_code == 0: + sys.exit(exit_code) + + 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) + +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') + +while True: + exit_code = os.system(f'{binary} {boot_file}{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(): + 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..b085464 --- /dev/null +++ b/boot/dep_installer.py @@ -0,0 +1,54 @@ +import json +import os +import sys + +install_option = sys.argv[1] if len(sys.argv) > 1 else None + +install_options = [ + { + 'id': 'stable', + 'name': '\U0001F48E Standard', + 'description': 'Uses the latest stable Nextcord version.', + 'default': True, + 'prefix': '', + 'color': '\x1b[32' + } +] + +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') + +if prefix: + code = os.system(f'{binary} -m pip install --user -U -r requirements_{prefix}.txt') +else: + code = os.system(f'{binary} -m pip install --user -U -r requirements.txt') + +if not code == 0: + print('\x1b[31;1mCould not install dependencies.\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..2b32770 --- /dev/null +++ b/boot/installer.py @@ -0,0 +1,160 @@ +import asyncio +import sys +import getpass +import json +import nextcord +import tomli +import tomli_w +import traceback +from nextcord.ext import commands + +install_option = sys.argv[1] if len(sys.argv) > 1 else None + +install_options = [ + { + 'id': 'stable', + 'name': '\U0001F48E Standard', + 'description': 'Uses the latest stable Nextcord version.', + 'default': True, + 'prefix': '', + 'color': '\x1b[32' + } +] + +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 + +with open('boot/internal.json') as file: + internal = json.load(file) + +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() + +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) + +file = open('.env','w+') +file.write(f'TOKEN={token}') +file.close() + +with open('config.toml', 'rb') as file: + config = tomli.load(file) + +config['roles']['owner'] = user_id +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: + 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..94e3f22 --- /dev/null +++ b/boot/internal.json @@ -0,0 +1,8 @@ +{ + "maintainer": "UnifierHQ", + "product": "unifier-micro", + "product_name": "Unifier Micro", + "base_bootfile": "microfier.py", + "repo": "https://github.com/UnifierHQ/unifier-micro", + "required_py_version": 9 +} \ No newline at end of file diff --git a/microfier.py b/microfier.py index 77cee5f..b78dd10 100644 --- a/microfier.py +++ b/microfier.py @@ -124,7 +124,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 +133,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,6 +146,39 @@ 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() + with open('config.json', 'r') as file: config = json.load(file) @@ -158,6 +191,17 @@ async def fetch_message(message_id): logger = log.buildlogger(package,'core',level) +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() @@ -616,8 +660,7 @@ 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:dict(room_template)}) db.save_data() await ctx.send(f'Created room `{room}`!') @@ -626,11 +669,11 @@ 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 +688,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 +709,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 +726,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 +744,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 +856,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 +871,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 @@ -906,6 +972,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 +986,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 +1083,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 +1095,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 +1123,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}' @@ -1190,10 +1265,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: @@ -1288,8 +1363,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 +1390,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 diff --git a/requirements.txt b/requirements.txt index 6dcb91c..ef611ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ nextcord -python-dotenv \ No newline at end of file +python-dotenv +tomli +tomli-w \ 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 "$@" From 40315f7a591a084c6df46f31daa0f1dad9b470df Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:05:31 +0200 Subject: [PATCH 05/22] use config.toml instead --- microfier.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/microfier.py b/microfier.py index b78dd10..d8423a7 100644 --- a/microfier.py +++ b/microfier.py @@ -28,6 +28,9 @@ import re from utils import log, ui import math +import tomli +import tomli_w +import traceback version = '2.0.1' @@ -112,10 +115,10 @@ def save_data(self): with open(self.file_path, 'w') as file: json.dump(self, file, indent=4) -def is_user_admin(id): +def is_user_admin(user_id): try: global admin_ids - if id in admin_ids: + if user_id in admin_ids: return True else: return False @@ -180,8 +183,54 @@ async def convert_1(): db.save_data() -with open('config.json', 'r') as file: - config = json.load(file) +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) + + 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 + + data = update_toml(config, newdata) + + with open(config_file, 'wb+') as file: + tomli_w.dump(data, file) env_loaded = load_dotenv() From 0eaa8bf626362e0f7370d4663cb6526342c18e26 Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:07:07 +0200 Subject: [PATCH 06/22] config.toml fix --- microfier.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/microfier.py b/microfier.py index d8423a7..fa4ac73 100644 --- a/microfier.py +++ b/microfier.py @@ -227,10 +227,24 @@ def update_toml(old, new): new[key].update({newkey: old[newkey]}) return new - data = update_toml(config, newdata) + config = update_toml(config, newdata) with open(config_file, 'wb+') as file: - tomli_w.dump(data, 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]}) + +data = newdata env_loaded = load_dotenv() @@ -263,6 +277,10 @@ def update_toml(old, new): 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) From 62a117d9caf33d18c84bac9c6594972cb95378da Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:09:35 +0200 Subject: [PATCH 07/22] use config.toml instead of config.json --- config.json | 9 --------- config.toml | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 9 deletions(-) delete mode 100644 config.json create mode 100644 config.toml 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..41e7df9 --- /dev/null +++ b/config.toml @@ -0,0 +1,14 @@ +[system] +debug = false +package = "microfier" + +[roles] +owner = -1 +admin_ids = [] + +[bot] +prefix = "u!" +ping = 0 + +[plugin] +"repo" = "https://github.com/greeeen-dev/unifier-micro" From 6d829932691bdce894c0c6f8ef04e5ef0f2d8f28 Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:11:12 +0200 Subject: [PATCH 08/22] shutdown post-install --- boot/bootloader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/boot/bootloader.py b/boot/bootloader.py index 518d713..42eb0e1 100644 --- a/boot/bootloader.py +++ b/boot/bootloader.py @@ -116,8 +116,8 @@ print('\x1b[31;1mInstaller has crashed or has been aborted.\x1b[0m') sys.exit(exit_code) - # sleep to prevent 429s - time.sleep(5) + print('\x1b[33;1mPlease re-run the run script after configuring the bot to start the bot.\x1b[0m') + sys.exit(0) if not boot_file in os.listdir(): if os.path.isdir('update'): From 1a680c0088af5b9e61a74be564a5ab379cbcbdd9 Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:12:58 +0200 Subject: [PATCH 09/22] run convert --- microfier.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/microfier.py b/microfier.py index fa4ac73..9ed542c 100644 --- a/microfier.py +++ b/microfier.py @@ -341,6 +341,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 From 7940ac8d9b24dde564a1541ba68518d2cc9e4ead Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:13:52 +0200 Subject: [PATCH 10/22] version bump --- microfier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microfier.py b/microfier.py index 9ed542c..e8303a1 100644 --- a/microfier.py +++ b/microfier.py @@ -32,7 +32,7 @@ import tomli_w import traceback -version = '2.0.1' +version = '3.0.0' def timetoint(t,timeoutcap=False): try: From 677a251b3ab2214473a082460095464e4079ce3c Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Fri, 20 Sep 2024 21:42:08 +0200 Subject: [PATCH 11/22] some renaming --- .github/workflows/pylint.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 380133b..78bb56d 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -4,6 +4,7 @@ on: [push] jobs: build-linux: + name: "Linux (Standard)" runs-on: ubuntu-latest strategy: matrix: @@ -19,11 +20,12 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt pip install pylint - - name: Analysing the code with pylint + - name: Pylint analysis run: | pylint --enable-all-extensions --extension-pkg-allow-list=orjson,ujson --disable=R,C,W $(git ls-files '*.py') build-windows: + name: "Windows (Standard)" runs-on: windows-latest strategy: matrix: @@ -39,6 +41,6 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt pip install pylint - - name: Analysing the code with pylint + - name: Pylint analysis run: | pylint --enable-all-extensions --extension-pkg-allow-list=orjson,ujson --disable=R,C,W $(git ls-files '*.py') From 9106c258a8e3a7069fb63a70226c9ccfe7d68927 Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:15:20 +0200 Subject: [PATCH 12/22] bootloader update --- boot/bootloader.py | 97 +++++++++++++++++++++++++++++----------------- boot_config.json | 4 ++ 2 files changed, 65 insertions(+), 36 deletions(-) create mode 100644 boot_config.json diff --git a/boot/bootloader.py b/boot/bootloader.py index 42eb0e1..630b749 100644 --- a/boot/bootloader.py +++ b/boot/bootloader.py @@ -5,6 +5,7 @@ import time reinstall = '--reinstall' in sys.argv +depinstall = '--install-deps' in sys.argv install_options = [ { @@ -48,11 +49,12 @@ else: options = ' ' + ' '.join(options) -if not '.install.json' in os.listdir() or reinstall: - if os.path.isdir('update') and not reinstall: +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"], @@ -63,61 +65,77 @@ ) else: # this installation is fresh - if not reinstall: - print('\x1b[33;1mInstallation not detected, running installer...\x1b[0m') + if 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') + 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') + 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') + 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()) + 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) - if install_option < 0 or install_option >= len(install_options): - raise ValueError() + 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) - 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') + 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) - try: - answer = input().lower() - except: - print(f'\x1b[31;1mAborting.\x1b[0m') - sys.exit(1) + print('\x1b[33;1mInstalling dependencies...\x1b[0m') - if not answer == 'y': - print(f'\x1b[31;1mAborting.\x1b[0m') - sys.exit(1) + 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) - print('\x1b[33;1mPlease re-run the run script after configuring the bot to start the bot.\x1b[0m') - sys.exit(0) + # sleep to prevent 429s + time.sleep(5) if not boot_file in os.listdir(): if os.path.isdir('update'): @@ -138,8 +156,10 @@ os.remove('.restart') print('\x1b[33;1mAn incomplete restart was detected.\x1b[0m') +restart_options = '' + while True: - exit_code = os.system(f'{binary} {boot_file}{options}') + exit_code = os.system(f'{binary} {boot_file}{restart_options}{options}') crash_reboot = False if not exit_code == 0: @@ -153,6 +173,11 @@ 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') 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 From a7f999bcd71e4827f8ffbb3b644c5f9094c92389 Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:27:51 +0200 Subject: [PATCH 13/22] shutdown --- microfier.py | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/microfier.py b/microfier.py index e8303a1..824589b 100644 --- a/microfier.py +++ b/microfier.py @@ -113,8 +113,23 @@ def load_data(self): def save_data(self): with open(self.file_path, 'w') as file: + # noinspection PyTypeChecker json.dump(self, file, indent=4) +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 @@ -291,6 +306,8 @@ def update_toml(old, new): db = AutoSaveDict({}) db.load_data() +colors = Colors + messages = [] ut_total = round(time.time()) @@ -350,6 +367,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 @@ -447,6 +544,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, @@ -668,6 +766,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): @@ -1031,6 +1141,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() From 139b8780a9b6a8e2248f168063440cc14d924fce Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:31:20 +0200 Subject: [PATCH 14/22] remove unused config --- boot/installer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/boot/installer.py b/boot/installer.py index 2b32770..230600b 100644 --- a/boot/installer.py +++ b/boot/installer.py @@ -142,12 +142,12 @@ async def on_ready(): config = tomli.load(file) config['roles']['owner'] = user_id -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"], From 57b04b871d22c4753803aaf2d9df9b6d0bc1d048 Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:33:07 +0200 Subject: [PATCH 15/22] use correct variable --- microfier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microfier.py b/microfier.py index 824589b..835150c 100644 --- a/microfier.py +++ b/microfier.py @@ -259,7 +259,7 @@ def update_toml(old, new): for newkey in config[key]: newdata.update({newkey: config[key][newkey]}) -data = newdata +config = newdata env_loaded = load_dotenv() From 821dc1acdda35e61aa45ed0b9d570582e6e80c24 Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:35:20 +0200 Subject: [PATCH 16/22] make owner an admin --- microfier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microfier.py b/microfier.py index 835150c..2a44fc3 100644 --- a/microfier.py +++ b/microfier.py @@ -133,7 +133,7 @@ class Colors: # format: 0xHEXCODE def is_user_admin(user_id): try: global admin_ids - if user_id in admin_ids: + if user_id in admin_ids or user_id == config['owner']: return True else: return False From 5ba4d5d98c2919be6dd1eb81bb4d51576a09b0c0 Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:37:24 +0200 Subject: [PATCH 17/22] use meta key --- microfier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microfier.py b/microfier.py index 2a44fc3..91f2888 100644 --- a/microfier.py +++ b/microfier.py @@ -839,7 +839,7 @@ 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:dict(room_template)}) + db['rooms'].update({room:{'meta': dict(room_template)}}) db.save_data() await ctx.send(f'Created room `{room}`!') From 7e8896c296691bfea4218d90db4cb12dcc931738 Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:39:17 +0200 Subject: [PATCH 18/22] room disband --- microfier.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/microfier.py b/microfier.py index 91f2888..da47d0a 100644 --- a/microfier.py +++ b/microfier.py @@ -843,6 +843,74 @@ async def make(ctx,*,room): 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): From 9b00857ccd31c6e8838e52774ebe84b95a9ba7e9 Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:48:04 +0200 Subject: [PATCH 19/22] fixes --- microfier.py | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/microfier.py b/microfier.py index da47d0a..264702e 100644 --- a/microfier.py +++ b/microfier.py @@ -97,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() @@ -277,7 +276,7 @@ def update_toml(old, new): 'invites': [], 'platform': 'discord' }, - 'emoji': None, 'description': None, 'display_name': None, 'banned': [] + 'emoji': None, 'description': None, 'display_name': None, 'banned': [], 'discord': {} } if not '.welcome.txt' in os.listdir(): @@ -1459,7 +1458,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 = '' @@ -1486,7 +1485,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.') @@ -1563,10 +1562,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!') @@ -1585,10 +1581,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() @@ -1602,8 +1597,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.') @@ -1853,7 +1847,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: @@ -1864,7 +1858,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() @@ -1922,7 +1916,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)) @@ -1944,7 +1938,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: @@ -2081,14 +2075,14 @@ 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 From b07f7980de59a0fc052ca8ae95540b07be501e4d Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:49:37 +0200 Subject: [PATCH 20/22] use proper key --- microfier.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/microfier.py b/microfier.py index 264702e..dd83811 100644 --- a/microfier.py +++ b/microfier.py @@ -276,7 +276,7 @@ def update_toml(old, new): 'invites': [], 'platform': 'discord' }, - 'emoji': None, 'description': None, 'display_name': None, 'banned': [], 'discord': {} + 'emoji': None, 'description': None, 'display_name': None, 'banned': [] } if not '.welcome.txt' in os.listdir(): @@ -838,7 +838,7 @@ 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:{'meta': dict(room_template)}}) + db['rooms'].update({room:{'meta': dict(room_template), 'discord': {}}}) db.save_data() await ctx.send(f'Created room `{room}`!') From 6ee0673d060f51b6b518da6e54203ca0b07a7b3b Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Sat, 5 Oct 2024 23:31:46 +0200 Subject: [PATCH 21/22] encrypted tokens + new pylint workflow --- .github/workflows/pylint.yml | 48 +---- .github/workflows/pylint_standard.yml | 31 ++++ boot/bootloader.py | 134 +++++++++++-- boot/dep_installer.py | 39 ++-- boot/installer.py | 76 +++++--- boot/internal.json | 15 +- boot/tokenmgr.py | 227 ++++++++++++++++++++++ config.toml | 1 + microfier.py | 14 +- utils/secrets.py | 258 ++++++++++++++++++++++++++ 10 files changed, 752 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/pylint_standard.yml create mode 100644 boot/tokenmgr.py create mode 100644 utils/secrets.py diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 78bb56d..4c62514 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -1,46 +1,16 @@ name: Pylint -on: [push] +on: [push, pull_request] jobs: - build-linux: + linux-standard: name: "Linux (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') + uses: ./.github/workflows/pylint_standard.yml + with: + os: ubuntu-latest - build-windows: + windows-standard: name: "Windows (Standard)" - 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: Pylint analysis - run: | - pylint --enable-all-extensions --extension-pkg-allow-list=orjson,ujson --disable=R,C,W $(git ls-files '*.py') + 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/boot/bootloader.py b/boot/bootloader.py index 630b749..427da35 100644 --- a/boot/bootloader.py +++ b/boot/bootloader.py @@ -1,22 +1,32 @@ +""" +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 - -install_options = [ - { - 'id': 'stable', - 'name': '\U0001F48E Standard', - 'description': 'Uses the latest stable Nextcord version.', - 'default': True, - 'prefix': '', - 'color': '\x1b[32' - } -] +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') @@ -25,6 +35,7 @@ with open('boot/internal.json') as file: internal = json.load(file) +install_options = internal['options'] boot_config = {} try: @@ -65,7 +76,10 @@ ) else: # this installation is fresh - if not depinstall: + 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') @@ -137,6 +151,49 @@ # 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') @@ -158,7 +215,60 @@ 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 diff --git a/boot/dep_installer.py b/boot/dep_installer.py index b085464..8316fe4 100644 --- a/boot/dep_installer.py +++ b/boot/dep_installer.py @@ -1,19 +1,31 @@ +""" +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 -install_options = [ - { - 'id': 'stable', - 'name': '\U0001F48E Standard', - 'description': 'Uses the latest stable Nextcord version.', - 'default': True, - 'prefix': '', - 'color': '\x1b[32' - } -] +with open('boot/internal.json') as file: + internal = json.load(file) + +install_options = internal['options'] prefix = None @@ -41,13 +53,16 @@ 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 -U -r requirements_{prefix}.txt') + 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 -U -r requirements.txt') + 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') diff --git a/boot/installer.py b/boot/installer.py index 230600b..9e04d3a 100644 --- a/boot/installer.py +++ b/boot/installer.py @@ -1,3 +1,21 @@ +""" +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 @@ -7,19 +25,14 @@ 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 -install_options = [ - { - 'id': 'stable', - 'name': '\U0001F48E Standard', - 'description': 'Uses the latest stable Nextcord version.', - 'default': True, - 'prefix': '', - 'color': '\x1b[32' - } -] +with open('boot/internal.json') as file: + internal = json.load(file) + +install_options = internal['options'] if not install_option: for option in install_options: @@ -35,9 +48,6 @@ user_id = 0 server_id = 0 -with open('boot/internal.json') as file: - internal = json.load(file) - 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) @@ -124,6 +134,29 @@ async def on_ready(): 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: @@ -134,27 +167,22 @@ async def on_ready(): print('\x1b[31;49mMake sure all privileged intents are enabled for the bot.\x1b[0m') sys.exit(1) -file = open('.env','w+') -file.write(f'TOKEN={token}') -file.close() +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 - ) + 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 index 94e3f22..2579251 100644 --- a/boot/internal.json +++ b/boot/internal.json @@ -3,6 +3,17 @@ "product": "unifier-micro", "product_name": "Unifier Micro", "base_bootfile": "microfier.py", - "repo": "https://github.com/UnifierHQ/unifier-micro", - "required_py_version": 9 + "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/config.toml b/config.toml index 41e7df9..2b9a7d1 100644 --- a/config.toml +++ b/config.toml @@ -1,6 +1,7 @@ [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 diff --git a/microfier.py b/microfier.py index dd83811..cc44bb5 100644 --- a/microfier.py +++ b/microfier.py @@ -26,7 +26,7 @@ import sys import os import re -from utils import log, ui +from utils import log, ui, secrets import math import tomli import tomli_w @@ -268,6 +268,13 @@ def update_toml(old, new): 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': { @@ -2088,4 +2095,7 @@ async def on_message_delete(message): 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/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) From fe56d6610f5070a084025ab5a81d851972817731 Mon Sep 17 00:00:00 2001 From: green <41323182+greeeen-dev@users.noreply.github.com> Date: Sat, 5 Oct 2024 23:32:54 +0200 Subject: [PATCH 22/22] add pycryptodome --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ef611ae..134153f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ nextcord python-dotenv tomli -tomli-w \ No newline at end of file +tomli-w +pycryptodome \ No newline at end of file