├── .gitignore ├── LICENSE ├── README.md ├── bot.py ├── cogs ├── admin.py ├── help.py └── status.py ├── images ├── full.png ├── offline.png └── online.png ├── requirements.txt ├── run.bat ├── run.py ├── run.sh ├── updater.bat ├── updater.py └── updater.sh /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | config.yml 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Fyssion 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minecraft Server Status Bot 2 | 3 | ![Online Status Example for mc.hypixel.net](images/online.png) 4 | ![Full Status Example](images/full.png) 5 | ![Offline Status Example](images/offline.png) 6 | 7 | A simple Discord bot that displays the status and player count of a Minecraft server in the sidebar. 8 | 9 | ## Archival Note 10 | 11 | mc-status-bot is no longer being maintained. It will not recieve any bug fixes or new updates. 12 | 13 | ## Features 14 | 15 | - "At a glance" status display; no need to boot up Minecraft to see who's online! 16 | - Support for both Java and Bedrock servers. 17 | - Real-time Minecraft server status. 18 | - Customizable prefix. 19 | - Easy-to-follow setup instructions. 20 | - Custom maintenance mode detection. 21 | 22 | For more info, including installation, configuration, and usage, visit the [documentation.](https://fyssioncodes.com/mc-status-bot) 23 | 24 | ## Installation and Setup 25 | 26 | For detailed installation instructions, check out the [installation guide.](https://fyssioncodes.com/mc-status-bot/) 27 | 28 | ### Configuration 29 | 30 | `mc-status-bot` comes with a built-in configuration helper. To change configuration options, simply run the updater. 31 | For a more in-depth explanation on configuration, including configuration options, 32 | see the [configuration page.](https://www.fyssioncodes.com/mc-status-bot/configuration.html) 33 | 34 | ### Commands 35 | 36 | While the bot's main purpose is to display a Minecraft server's status in the Discord member list, 37 | it also comes with a few useful commands. For example, the `;server` command shows a message containing detailed 38 | info about the current Minecraft server. 39 | You can view the full command list [here.](https://www.fyssioncodes.com/mc-status-bot/commands.html) 40 | 41 | ## Attributes 42 | 43 | - [Just-Some-Bots/MusicBot](https://github.com/Just-Some-Bots/MusicBot) for run and updater scripts. Copyright (c) Just-Some-Bots 44 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | import yaml 5 | import logging 6 | import sys 7 | import traceback 8 | 9 | discord.VoiceClient.warn_nacl = False # don't need this warning 10 | 11 | formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S") 12 | handler = logging.StreamHandler() 13 | handler.setFormatter(formatter) 14 | 15 | logger = logging.getLogger("discord") 16 | logger.setLevel(logging.INFO) 17 | logger.addHandler(handler) 18 | 19 | log = logging.getLogger("bot") 20 | log.setLevel(logging.INFO) 21 | log.addHandler(handler) 22 | 23 | 24 | class InvalidConfigValue(Exception): 25 | pass 26 | 27 | 28 | class InvalidRefreshRate(InvalidConfigValue): 29 | def __init__(self, refresh_rate): 30 | super().__init__(f"Refresh rate must be 30 or higher. You have it set to {refresh_rate}.") 31 | 32 | 33 | class InvalidServerType(InvalidConfigValue): 34 | def __init__(self, server_type): 35 | super().__init__(f"Server type must be either Java or Bedrock. You have it set to {server_type}.") 36 | 37 | 38 | initial_extensions = [ 39 | "cogs.status", 40 | "cogs.admin", 41 | "cogs.help", 42 | ] 43 | 44 | 45 | def get_prefix(bot, message): 46 | prefixes = [bot.config["prefix"]] 47 | return commands.when_mentioned_or(*prefixes)(bot, message) 48 | 49 | 50 | description = """ 51 | A simple Discord bot that displays the status and player count of a Minecraft server in the sidebar. 52 | """ 53 | 54 | 55 | class ServerStatus(commands.Bot): 56 | def __init__(self): 57 | intents = discord.Intents(messages=True, guilds=True, reactions=True) 58 | 59 | super().__init__( 60 | command_prefix=get_prefix, 61 | description=description, 62 | case_insensitive=True, 63 | activity=discord.Game("Starting up..."), 64 | help_command=commands.MinimalHelpCommand(), 65 | intents=intents, 66 | ) 67 | 68 | self.log = log 69 | 70 | log.info("Starting bot...") 71 | 72 | log.info("Loading config file...") 73 | self.config = self.load_config("config.yml") 74 | 75 | # Config checks 76 | if self.config["server-type"].lower() not in ["java", "bedrock"]: 77 | raise InvalidServerType(self.config["server-type"]) 78 | 79 | if self.config["refresh-rate"] < 30: 80 | raise InvalidRefreshRate(self.config["refresh-rate"]) 81 | 82 | log.info("Loading extensions...") 83 | for extension in initial_extensions: 84 | self.load_extension(extension) 85 | 86 | log.info("Setting initial status before logging in...") 87 | status_cog = self.get_cog("Status") 88 | status, text = self.loop.run_until_complete(status_cog.get_status()) 89 | game = discord.Game(text) 90 | status_cog.activity = game 91 | 92 | self._connection._status = status 93 | self.activity = game 94 | 95 | self.init_ok = None 96 | self.restart_signal = None 97 | 98 | try: 99 | self.load_extension("jishaku") 100 | self.get_command("jishaku").hidden = True 101 | 102 | except Exception: 103 | log.info("jishaku is not installed, continuing...") 104 | 105 | def load_config(self, filename): 106 | with open(filename, "r") as f: 107 | return yaml.safe_load(f) 108 | 109 | async def on_command(self, ctx): 110 | destination = None 111 | 112 | if ctx.guild is None: 113 | destination = "Private Message" 114 | else: 115 | destination = f"#{ctx.channel} ({ctx.guild})" 116 | 117 | log.info(f"{ctx.author} in {destination}: {ctx.message.content}") 118 | 119 | async def send_unexpected_error(self, ctx, error): 120 | em = discord.Embed( 121 | title=":warning: Unexpected Error", 122 | color=discord.Color.gold(), 123 | ) 124 | 125 | description = ( 126 | "An unexpected error has occured:" 127 | f"```py\n{error}```\n" 128 | ) 129 | 130 | em.description = description 131 | await ctx.send(embed=em) 132 | 133 | async def on_command_error(self, ctx, error): 134 | red_tick = "\N{CROSS MARK}" 135 | 136 | if hasattr(ctx, "handled"): 137 | return 138 | 139 | if isinstance(error, commands.NoPrivateMessage): 140 | message = await ctx.send( 141 | f"{red_tick} This command can't be used in DMs." 142 | ) 143 | 144 | elif isinstance(error, commands.ArgumentParsingError): 145 | message = await ctx.send(f"{red_tick} {error}") 146 | 147 | elif isinstance(error, commands.CommandOnCooldown): 148 | message = await ctx.send( 149 | f"{red_tick} You are on cooldown. Try again in {int(error.retry_after)} seconds." 150 | ) 151 | 152 | elif isinstance(error, commands.errors.BotMissingPermissions): 153 | perms = "" 154 | 155 | for perm in error.missing_perms: 156 | formatted = ( 157 | str(perm).replace("_", " ").replace("guild", "server").capitalize() 158 | ) 159 | perms += f"\n- `{formatted}`" 160 | 161 | message = await ctx.send( 162 | f"{red_tick} I am missing some required permission(s):{perms}" 163 | ) 164 | 165 | elif isinstance(error, commands.errors.BadArgument): 166 | message = await ctx.send(f"{red_tick} {error}") 167 | 168 | elif isinstance(error, commands.errors.MissingRequiredArgument): 169 | message = await ctx.send( 170 | f"{red_tick} Missing a required argument: `{error.param.name}`" 171 | ) 172 | 173 | elif ( 174 | isinstance(error, commands.CommandInvokeError) 175 | and str(ctx.command) == "help" 176 | ): 177 | pass 178 | 179 | elif isinstance(error, commands.CommandInvokeError): 180 | original = error.original 181 | # if True: # for debugging 182 | if not isinstance(original, discord.HTTPException): 183 | print( 184 | "Ignoring exception in command {}:".format(ctx.command), 185 | file=sys.stderr, 186 | ) 187 | traceback.print_exception( 188 | type(error), error, error.__traceback__, file=sys.stderr 189 | ) 190 | 191 | await self.send_unexpected_error(ctx, error) 192 | return 193 | 194 | async def on_ready(self): 195 | log.info(f"Logged in as {self.user.name} - {self.user.id}") 196 | self.init_ok = True 197 | 198 | def run(self): 199 | log.info("Logging into Discord...") 200 | super().run(self.config["bot-token"]) 201 | 202 | 203 | if __name__ == "__main__": 204 | bot = ServerStatus() 205 | bot.run() 206 | -------------------------------------------------------------------------------- /cogs/admin.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | 5 | class Admin(commands.Cog): 6 | """Bot admin commands. 7 | 8 | Only the owner of the bot can use these commands. 9 | """ 10 | 11 | def __init__(self, bot): 12 | self.bot = bot 13 | 14 | async def cog_check(self, ctx): 15 | return await commands.is_owner().predicate(ctx) 16 | 17 | @commands.command(name="logout", aliases=["shutdown"]) 18 | async def logout(self, ctx): 19 | """Logout and shutdown the bot. 20 | You must be the owner of the bot to use this command. 21 | """ 22 | await ctx.send("Logging out...") 23 | await self.bot.close() 24 | 25 | 26 | def setup(bot): 27 | bot.add_cog(Admin(bot)) 28 | -------------------------------------------------------------------------------- /cogs/help.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | 5 | class HelpCommand(commands.MinimalHelpCommand): 6 | """Modified MinimalHelpCommand that doesn't display categories""" 7 | 8 | def __init__(self, **kwargs): 9 | kwargs.setdefault("sort_commands", False) 10 | super().__init__(**kwargs) 11 | 12 | def get_opening_note(self): 13 | command_name = self.invoked_with 14 | return "Use `{0}{1} [command]` for more info on a command.".format(self.clean_prefix, command_name) 15 | 16 | def add_bot_commands_formatting(self, commands): 17 | for command in commands: 18 | self.add_subcommand_formatting(command) 19 | 20 | def add_subcommand_formatting(self, command): 21 | fmt = "`{0}{1}` \N{EN DASH} {2}" if command.short_doc else "`{0}{1}`" 22 | self.paginator.add_line(fmt.format(self.clean_prefix, command.qualified_name, command.short_doc)) 23 | 24 | def add_command_formatting(self, command): 25 | if command.description: 26 | self.paginator.add_line(command.description, empty=True) 27 | 28 | signature = self.get_command_signature(command) 29 | if command.aliases: 30 | self.paginator.add_line(f"`{signature.strip()}`") 31 | self.add_aliases_formatting(command.aliases) 32 | else: 33 | self.paginator.add_line(signature, empty=True) 34 | 35 | if command.help: 36 | try: 37 | self.paginator.add_line(command.help, empty=True) 38 | except RuntimeError: 39 | for line in command.help.splitlines(): 40 | self.paginator.add_line(line) 41 | self.paginator.add_line() 42 | 43 | async def send_bot_help(self, mapping): 44 | ctx = self.context 45 | bot = ctx.bot 46 | 47 | if bot.description: 48 | self.paginator.add_line(bot.description, empty=True) 49 | 50 | note = self.get_opening_note() 51 | if note: 52 | self.paginator.add_line(note, empty=True) 53 | 54 | self.paginator.add_line("**Commands**") 55 | 56 | filtered = await self.filter_commands(bot.commands, sort=True) 57 | 58 | commands = sorted(filtered, key=lambda c: c.name) if self.sort_commands else list(filtered) 59 | self.add_bot_commands_formatting(commands) 60 | 61 | note = self.get_ending_note() 62 | if note: 63 | self.paginator.add_line() 64 | self.paginator.add_line(note) 65 | 66 | await self.send_pages() 67 | 68 | async def command_callback(self, ctx, *, command=None): 69 | """Removes cog help from the help command. 70 | 71 | This is essentially modified discord.py code. 72 | """ 73 | await self.prepare_help_command(ctx, command) 74 | bot = ctx.bot 75 | 76 | if command is None: 77 | mapping = self.get_bot_mapping() 78 | return await self.send_bot_help(mapping) 79 | 80 | maybe_coro = discord.utils.maybe_coroutine 81 | 82 | # If it's not a cog then it's a command. 83 | # Since we want to have detailed errors when someone 84 | # passes an invalid subcommand, we need to walk through 85 | # the command group chain ourselves. 86 | keys = command.split(" ") 87 | cmd = bot.all_commands.get(keys[0]) 88 | if cmd is None: 89 | string = await maybe_coro(self.command_not_found, self.remove_mentions(keys[0])) 90 | return await self.send_error_message(string) 91 | 92 | for key in keys[1:]: 93 | try: 94 | found = cmd.all_commands.get(key) 95 | except AttributeError: 96 | string = await maybe_coro(self.subcommand_not_found, cmd, self.remove_mentions(key)) 97 | return await self.send_error_message(string) 98 | else: 99 | if found is None: 100 | string = await maybe_coro(self.subcommand_not_found, cmd, self.remove_mentions(key)) 101 | return await self.send_error_message(string) 102 | cmd = found 103 | 104 | if isinstance(cmd, commands.Group): 105 | return await self.send_group_help(cmd) 106 | else: 107 | return await self.send_command_help(cmd) 108 | 109 | 110 | def setup(bot): 111 | bot._original_help_command = bot.help_command 112 | bot.help_command = HelpCommand() 113 | 114 | 115 | def teardown(bot): 116 | bot.help_command = bot._original_help_command 117 | bot._original_help_command = None 118 | -------------------------------------------------------------------------------- /cogs/status.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands, tasks 3 | 4 | from mcstatus import MinecraftServer, MinecraftBedrockServer 5 | import asyncio 6 | import functools 7 | import logging 8 | import yaml 9 | import traceback 10 | import datetime 11 | import io 12 | import re 13 | import base64 14 | 15 | from bot import InvalidServerType 16 | 17 | 18 | log = logging.getLogger("bot") 19 | 20 | 21 | class ServerNotFound(commands.CommandError): 22 | def __init__(self, ip): 23 | self.ip = ip 24 | 25 | super().__init__(f"Could not find server with an IP of {ip}.") 26 | 27 | 28 | class Status(commands.Cog): 29 | """The main functionality of the bot. 30 | 31 | This includes the status updater and all Minecraft Server 32 | related commands. 33 | """ 34 | def __init__(self, bot): 35 | self.bot = bot 36 | 37 | self.activity = None 38 | self.status = None 39 | 40 | self.last_set = None 41 | 42 | self.ip = ip = self.bot.config["server-ip"] 43 | 44 | server_type = bot.config["server-type"].lower() 45 | if server_type == "java": 46 | Server = self.ServerType = MinecraftServer 47 | elif server_type == "bedrock": 48 | Server = self.ServerType = MinecraftBedrockServer 49 | else: 50 | raise InvalidServerType(bot.config["server-type"]) 51 | 52 | log.info(f"Looking up Minecraft server IP: {ip}") 53 | self.server = Server.lookup(ip) 54 | 55 | if not self.server: 56 | log.critical(f"Could not find server with an IP of {ip}.") 57 | raise ServerNotFound(ip) 58 | 59 | log.info(f"Found server with an IP of {ip}") 60 | 61 | self.status_updater_task.change_interval(seconds=bot.config["refresh-rate"]) 62 | self.status_updater_task.start() 63 | 64 | def cog_unload(self): 65 | self.status_updater_task.cancel() 66 | 67 | @commands.command( 68 | aliases=["list", "who", "online"], 69 | ) 70 | async def players(self, ctx): 71 | """Get player list for the current server. 72 | 73 | At the moment, this is is only available for Java servers.""" 74 | if self.ServerType is MinecraftBedrockServer: 75 | return await ctx.send("Sorry, this functionality is only available for Java servers.") 76 | 77 | partial = functools.partial(self.server.query) 78 | try: 79 | async with ctx.typing(): 80 | query = await self.bot.loop.run_in_executor(None, partial) 81 | 82 | except Exception as exc: 83 | traceback.print_exception(type(exc), exc, exc.__traceback__) 84 | return await ctx.send( 85 | "An error occured while attempting to query the server.\n" 86 | "Server may be offline or does not have query set up.\n" 87 | "Activate query with `enable-query` in `server.properties`.\n" 88 | f"Error: ```py\n{exc}\n```" 89 | ) 90 | 91 | players = "\n".join(query.players.names) 92 | em = discord.Embed( 93 | title="Current Players Online:", 94 | description=players, 95 | color=discord.Color.green(), 96 | ) 97 | 98 | em.set_footer(text=f"Server IP: `{self.ip}`") 99 | await ctx.send(embed=em) 100 | 101 | def resolve_favicon(self, status): 102 | if status.favicon: 103 | string = ",".join(status.favicon.split(",")[1:]) 104 | bytes = io.BytesIO(base64.b64decode(string)) 105 | bytes.seek(0) 106 | 107 | return discord.File(bytes, "favicon.png") 108 | 109 | return None 110 | 111 | @commands.group( 112 | invoke_without_command=True, 113 | aliases=["ip"], 114 | ) 115 | async def server(self, ctx): 116 | """Get info about the current server.""" 117 | partial = functools.partial(self.server.status) 118 | try: 119 | async with ctx.typing(): 120 | status = await self.bot.loop.run_in_executor(None, partial) 121 | 122 | except Exception: 123 | status = None 124 | color = discord.Color.red() 125 | status_text = "Offline" 126 | 127 | else: 128 | if self.ServerType is MinecraftServer: 129 | players_online = status.players.online 130 | players_max = status.players.max 131 | elif self.ServerType is MinecraftBedrockServer: 132 | players_online = status.players_online 133 | players_max = status.players_max 134 | 135 | players = f"{players_online}/{players_max}" 136 | 137 | if players_online == players_max: 138 | color = discord.Color.orange() 139 | status_text = f"Full - {players}" 140 | else: 141 | color = discord.Color.green() 142 | status_text = f"Online - {players}" 143 | 144 | if status: 145 | motd = self._parse_motd(status) 146 | if len(motd) > 1024: 147 | motd = motd[:1024] + "..." 148 | 149 | else: 150 | motd = "" 151 | 152 | em = discord.Embed(title="Minecraft Server Info", description=motd, color=color) 153 | 154 | server_type = self.bot.config["server-type"] 155 | em.add_field(name="Type", value=f"{server_type.lower().capitalize()}") 156 | em.add_field(name="IP", value=f"`{self.ip}`") 157 | em.add_field(name="Status", value=status_text) 158 | 159 | file = None 160 | 161 | if status: 162 | if self.ServerType is MinecraftServer: 163 | version = status.version.name 164 | 165 | favicon = self.resolve_favicon(status) 166 | if favicon: 167 | em.set_thumbnail(url="attachment://favicon.png") 168 | file = favicon 169 | 170 | elif self.ServerType is MinecraftBedrockServer: 171 | version = f"{status.version.brand}: {status.version.protocol}" 172 | 173 | if status.gamemode: 174 | try: 175 | gamemode = ["Survival", "Creative", "Adventure", "Spectator"][int(status.gamemode)] 176 | except (ValueError, TypeError): 177 | gamemode = status.gamemode 178 | em.add_field(name="Gamemode", value=gamemode) 179 | 180 | em.add_field(name="Version", value=version) 181 | em.add_field(name="Latency", value=f"{status.latency:.2f}ms") 182 | 183 | await ctx.send(embed=em, file=file) 184 | 185 | @server.command(name="set") 186 | @commands.is_owner() 187 | async def server_set(self, ctx, ip): 188 | """Set the IP for the server via command. 189 | 190 | This will automatically update the config file. 191 | You must be thw owner of the bot to use this command. 192 | """ 193 | partial = functools.partial(self.ServerType.lookup, ip) 194 | 195 | async with ctx.typing(): 196 | server = await self.bot.loop.run_in_executor(None, partial) 197 | 198 | if not server: 199 | return await ctx.send("Could not find that server") 200 | 201 | self.server = server 202 | self.ip = ip 203 | self.bot.config["server-ip"] = ip 204 | with open("config.yml", "w") as config: 205 | yaml.dump(self.bot.config, config, indent=4, sort_keys=True) 206 | 207 | await self.update_status() 208 | 209 | await ctx.send(f"Set server to `{ip}`.") 210 | 211 | @commands.command() 212 | @commands.cooldown(1, 30, commands.BucketType.user) 213 | async def update(self, ctx): 214 | """Manually update the status if it broke.""" 215 | await self.update_status(force=True) 216 | await ctx.send("Updated status") 217 | 218 | async def set_status(self, status, text, *, force=False): 219 | game = discord.Game(text) 220 | 221 | # We only want to send a request if the status is different 222 | # or if the status is not set. 223 | # The below returns if either of those requirements are not met. 224 | now = datetime.datetime.utcnow() 225 | if ( 226 | not force 227 | and self.last_set 228 | and self.last_set + datetime.timedelta(minutes=30) < now 229 | and self.activity == game 230 | and self.status == status 231 | ): 232 | return 233 | 234 | await self.bot.change_presence(status=status, activity=game) 235 | self.status = status 236 | self.activity = game 237 | 238 | log.info(f"Set status to {status}: {text}") 239 | 240 | def _parse_motd(self, server): 241 | if self.ServerType is MinecraftServer: 242 | motd = server.description 243 | elif self.ServerType is MinecraftBedrockServer: 244 | motd = server.motd 245 | 246 | if isinstance(motd, dict): 247 | description = motd.get("text", "") 248 | extras = motd.get("extra") 249 | if extras: 250 | for extra in extras: 251 | description += extra.get("text", "") 252 | 253 | else: 254 | description = str(motd) 255 | 256 | description = re.sub(r"§.", "", description) 257 | 258 | return description 259 | 260 | async def get_status(self): 261 | partial = functools.partial(self.server.status) 262 | try: 263 | server = await self.bot.loop.run_in_executor(None, partial) 264 | 265 | except Exception: 266 | return discord.Status.dnd, "Server is offline" 267 | 268 | if self.ServerType is MinecraftServer: 269 | players_online = server.players.online 270 | players_max = server.players.max 271 | elif self.ServerType is MinecraftBedrockServer: 272 | players_online = server.players_online 273 | players_max = server.players_max 274 | 275 | if players_online == players_max: 276 | status = discord.Status.idle 277 | else: 278 | status = discord.Status.online 279 | 280 | maintenance_text = self.bot.config["maintenance-mode-detection"] 281 | if maintenance_text: 282 | # somehow some people have this not as a string 283 | if not isinstance(maintenance_text, str): 284 | logging.warning( 285 | "maintenance-mode-detection has been set, but is not a vaild type. " 286 | f"It must be a string, but is a {type(maintenance_text)} instead." 287 | ) 288 | return None 289 | 290 | # I guess the status can be a dict? 291 | description = self._parse_motd(server) 292 | 293 | if maintenance_text.lower() in description.lower(): 294 | return discord.Status.dnd, "Server is in maintenence mode" 295 | 296 | return status, f"{players_online}/{players_max} online" 297 | 298 | async def update_status(self, *, force=False): 299 | status, text = await self.get_status() 300 | 301 | await self.set_status(status, text, force=force) 302 | 303 | @tasks.loop(seconds=60) 304 | async def status_updater_task(self): 305 | await self.update_status() 306 | 307 | @status_updater_task.before_loop 308 | async def before_printer(self): 309 | await self.bot.wait_until_ready() 310 | log.info("Waiting 10 seconds before next status set") 311 | await asyncio.sleep(10) 312 | 313 | @commands.Cog.listener() 314 | async def on_guild_join(self, guild): 315 | if len(self.guilds) == 1: 316 | log.info("Joined first guild, setting status") 317 | await self.update_status() 318 | 319 | 320 | def setup(bot): 321 | bot.add_cog(Status(bot)) 322 | -------------------------------------------------------------------------------- /images/full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyssion/mc-status-bot/bd739bfb21fbeccbfdb0327c1ef557c448a2a70e/images/full.png -------------------------------------------------------------------------------- /images/offline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyssion/mc-status-bot/bd739bfb21fbeccbfdb0327c1ef557c448a2a70e/images/offline.png -------------------------------------------------------------------------------- /images/online.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fyssion/mc-status-bot/bd739bfb21fbeccbfdb0327c1ef557c448a2a70e/images/online.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | discord.py>=1.5.0 2 | mcstatus>=5.1.0 3 | pyyaml 4 | -------------------------------------------------------------------------------- /run.bat: -------------------------------------------------------------------------------- 1 | @ECHO off 2 | 3 | REM This software was sourced from Just-Some-Bots/MusicBot 4 | REM https://github.com/Just-Some-Bots 5 | 6 | REM The MIT License 7 | 8 | REM Copyright c 2015-2019 Just-Some-Bots - https://github.com/Just-Some-Bots 9 | 10 | REM Permission is hereby granted, free of charge, to any person obtaining a copy 11 | REM of this software and associated documentation files - the "Software" - , to deal 12 | REM in the Software without restriction, including without limitation the rights 13 | REM to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | REM copies of the Software, and to permit persons to whom the Software is 15 | REM furnished to do so, subject to the following conditions: 16 | 17 | REM The above copyright notice and this permission notice shall be included in 18 | REM all copies or substantial portions of the Software. 19 | 20 | REM THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | REM IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | REM FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | REM AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | REM LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | REM OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | REM THE SOFTWARE. 27 | 28 | CHCP 65001 > NUL 29 | CD /d "%~dp0" 30 | 31 | SETLOCAL ENABLEEXTENSIONS 32 | SET KEY_NAME="HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" 33 | SET VALUE_NAME=HideFileExt 34 | 35 | FOR /F "usebackq tokens=1-3" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( 36 | SET ValueName=%%A 37 | SET ValueType=%%B 38 | SET ValueValue=%%C 39 | ) 40 | 41 | IF x%ValueValue:0x0=%==x%ValueValue% ( 42 | ECHO Unhiding file extensions... 43 | START CMD /c REG ADD HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced /v HideFileExt /t REG_DWORD /d 0 /f 44 | ) 45 | ENDLOCAL 46 | 47 | 48 | IF EXIST %SYSTEMROOT%\py.exe ( 49 | CMD /k %SYSTEMROOT%\py.exe -3 run.py 50 | EXIT 51 | ) 52 | 53 | python --version > NUL 2>&1 54 | IF %ERRORLEVEL% NEQ 0 GOTO nopython 55 | 56 | CMD /k python run.py 57 | GOTO end 58 | 59 | :nopython 60 | ECHO ERROR: Python has either not been installed or not added to your PATH. 61 | 62 | :end 63 | PAUSE 64 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | This software was sourced from Just-Some-Bots/MusicBot 5 | https://github.com/Just-Some-Bots 6 | 7 | The MIT License 8 | 9 | Copyright (c) 2015-2019 Just-Some-Bots (https://github.com/Just-Some-Bots) 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in 19 | all copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 27 | THE SOFTWARE. 28 | """ 29 | 30 | from __future__ import print_function 31 | 32 | import os 33 | import sys 34 | import time 35 | import logging 36 | import tempfile 37 | import traceback 38 | import subprocess 39 | 40 | from shutil import disk_usage, rmtree 41 | from base64 import b64decode 42 | 43 | try: 44 | import pathlib 45 | import importlib.util 46 | except ImportError: 47 | pass 48 | 49 | 50 | class GIT(object): 51 | @classmethod 52 | def works(cls): 53 | try: 54 | return bool(subprocess.check_output("git --version", shell=True)) 55 | except Exception: 56 | return False 57 | 58 | 59 | class PIP(object): 60 | @classmethod 61 | def run(cls, command, check_output=False): 62 | if not cls.works(): 63 | raise RuntimeError("Could not import pip.") 64 | 65 | try: 66 | return PIP.run_python_m(*command.split(), check_output=check_output) 67 | except subprocess.CalledProcessError as e: 68 | return e.returncode 69 | except Exception: 70 | traceback.print_exc() 71 | print("Error using -m method") 72 | 73 | @classmethod 74 | def run_python_m(cls, *args, **kwargs): 75 | check_output = kwargs.pop("check_output", False) 76 | check = subprocess.check_output if check_output else subprocess.check_call 77 | return check([sys.executable, "-m", "pip"] + list(args)) 78 | 79 | @classmethod 80 | def run_pip_main(cls, *args, **kwargs): 81 | import pip 82 | 83 | args = list(args) 84 | check_output = kwargs.pop("check_output", False) 85 | 86 | if check_output: 87 | from io import StringIO 88 | 89 | out = StringIO() 90 | sys.stdout = out 91 | 92 | try: 93 | pip.main(args) 94 | except Exception: 95 | traceback.print_exc() 96 | finally: 97 | sys.stdout = sys.__stdout__ 98 | 99 | out.seek(0) 100 | pipdata = out.read() 101 | out.close() 102 | 103 | print(pipdata) 104 | return pipdata 105 | else: 106 | return pip.main(args) 107 | 108 | @classmethod 109 | def run_install(cls, cmd, quiet=False, check_output=False): 110 | return cls.run("install %s%s" % ("-q " if quiet else "", cmd), check_output) 111 | 112 | @classmethod 113 | def run_show(cls, cmd, check_output=False): 114 | return cls.run("show %s" % cmd, check_output) 115 | 116 | @classmethod 117 | def works(cls): 118 | try: 119 | import pip 120 | 121 | return True 122 | except ImportError: 123 | return False 124 | 125 | # noinspection PyTypeChecker 126 | @classmethod 127 | def get_module_version(cls, mod): 128 | try: 129 | out = cls.run_show(mod, check_output=True) 130 | 131 | if isinstance(out, bytes): 132 | out = out.decode() 133 | 134 | datas = out.replace("\r\n", "\n").split("\n") 135 | expectedversion = datas[3] 136 | 137 | if expectedversion.startswith("Version: "): 138 | return expectedversion.split()[1] 139 | else: 140 | return [x.split()[1] for x in datas if x.startswith("Version: ")][0] 141 | except Exception: 142 | pass 143 | 144 | @classmethod 145 | def get_requirements(cls, file="requirements.txt"): 146 | from pip.req import parse_requirements 147 | 148 | return list(parse_requirements(file)) 149 | 150 | 151 | # Setup initial loggers 152 | 153 | log = logging.getLogger("launcher") 154 | log.setLevel(logging.DEBUG) 155 | 156 | sh = logging.StreamHandler(stream=sys.stdout) 157 | sh.setFormatter(logging.Formatter(fmt="[%(levelname)s] %(name)s: %(message)s")) 158 | 159 | log.addHandler(sh) 160 | 161 | 162 | def bugger_off(msg="Press enter to continue . . .", code=1): 163 | input(msg) 164 | sys.exit(code) 165 | 166 | 167 | # TODO: all of this 168 | def sanity_checks(optional=True): 169 | log.info("Starting sanity checks") 170 | # Required 171 | 172 | # Make sure we're on Python 3.5+ 173 | req_ensure_py3() 174 | 175 | # Fix windows encoding 176 | req_ensure_encoding() 177 | 178 | # Make sure we're in a writeable env 179 | req_ensure_env() 180 | 181 | # Make our folders if needed 182 | req_ensure_folders() 183 | 184 | # For rewrite only 185 | req_check_deps() 186 | 187 | log.info("Required checks passed.") 188 | 189 | # Optional 190 | if not optional: 191 | return 192 | 193 | # Check disk usage 194 | opt_check_disk_space() 195 | 196 | log.info("Optional checks passed.") 197 | 198 | 199 | def req_ensure_py3(): 200 | log.info("Checking for Python 3.6+") 201 | 202 | if sys.version_info < (3, 6): 203 | log.warning( 204 | "Python 3.6+ is required. This version is %s", sys.version.split()[0] 205 | ) 206 | log.warning("Attempting to locate Python 3.6...") 207 | 208 | pycom = None 209 | 210 | if sys.platform.startswith("win"): 211 | log.info('Trying "py -3.6"') 212 | try: 213 | subprocess.check_output('py -3.6 -c "exit()"', shell=True) 214 | pycom = "py -3.6" 215 | except Exception: 216 | 217 | log.info('Trying "python3"') 218 | try: 219 | subprocess.check_output('python3 -c "exit()"', shell=True) 220 | pycom = "python3" 221 | except Exception: 222 | pass 223 | 224 | if pycom: 225 | log.info("Python 3 found. Launching bot...") 226 | pyexec(pycom, "run.py") 227 | 228 | # I hope ^ works 229 | os.system("start cmd /k %s run.py" % pycom) 230 | sys.exit(0) 231 | 232 | else: 233 | log.info('Trying "python3.6"') 234 | try: 235 | pycom = ( 236 | subprocess.check_output('python3.6 -c "exit()"'.split()) 237 | .strip() 238 | .decode() 239 | ) 240 | except Exception: 241 | pass 242 | 243 | if pycom: 244 | log.info( 245 | "\nPython 3 found. Re-launching bot using: %s run.py\n", pycom 246 | ) 247 | pyexec(pycom, "run.py") 248 | 249 | log.critical( 250 | "Could not find Python 3.6 or higher. Please run the bot using Python 3.6" 251 | ) 252 | bugger_off() 253 | 254 | 255 | def req_check_deps(): 256 | try: 257 | import discord 258 | 259 | if discord.version_info.major < 1: 260 | log.critical( 261 | "This version of mc-status-bot requires a newer version of discord.py (1.0+). Your version is {0}. Try running updater.py.".format( 262 | discord.__version__ 263 | ) 264 | ) 265 | bugger_off() 266 | except ImportError: 267 | # if we can't import discord.py, an error will be thrown later down the line anyway 268 | pass 269 | 270 | 271 | def req_ensure_encoding(): 272 | log.info("Checking console encoding") 273 | 274 | if ( 275 | sys.platform.startswith("win") 276 | or sys.stdout.encoding.replace("-", "").lower() != "utf8" 277 | ): 278 | log.info("Setting console encoding to UTF-8") 279 | 280 | import io 281 | 282 | sys.stdout = io.TextIOWrapper( 283 | sys.stdout.detach(), encoding="utf8", line_buffering=True 284 | ) 285 | # only slightly evil 286 | sys.__stdout__ = sh.stream = sys.stdout 287 | 288 | if os.environ.get("PYCHARM_HOSTED", None) not in (None, "0"): 289 | log.info("Enabling colors in pycharm pseudoconsole") 290 | sys.stdout.isatty = lambda: True 291 | 292 | 293 | def req_ensure_env(): 294 | log.info("Ensuring we're in the right environment") 295 | 296 | if os.environ.get("APP_ENV") != "docker" and not os.path.isdir( 297 | b64decode("LmdpdA==").decode("utf-8") 298 | ): 299 | log.critical( 300 | b64decode( 301 | "Qm90IHdhc24ndCBpbnN0YWxsZWQgdXNpbmcgR2l0LiBSZWluc3RhbGwgdXNpbmcgaHR0cHM6Ly9naXRodWIuY29tL0Z5c3Npb24vbWMtc3RhdHVzLWJvdCNpbnN0YWxsYXRpb24=" 302 | ).decode("utf-8") 303 | ) 304 | bugger_off() 305 | 306 | try: 307 | assert os.path.isfile("config.yml"), "config.yml file not found, run the updater to initiate setup" 308 | assert os.path.isfile( 309 | "bot.py" 310 | ), "Could not find bot.py" 311 | 312 | assert importlib.util.find_spec("bot"), "bot module is not importable" 313 | except AssertionError as e: 314 | log.critical("Failed environment check, %s", e) 315 | bugger_off() 316 | 317 | try: 318 | os.mkdir("statusbot-test-folder") 319 | except Exception: 320 | log.critical("Current working directory does not seem to be writable") 321 | log.critical("Please move the bot to a folder that is writable") 322 | bugger_off() 323 | finally: 324 | rmtree("statusbot-test-folder", True) 325 | 326 | if sys.platform.startswith("win"): 327 | log.info("Adding local bins/ folder to path") 328 | os.environ["PATH"] += ";" + os.path.abspath("bin/") 329 | sys.path.append(os.path.abspath("bin/")) # might as well 330 | 331 | 332 | def req_ensure_folders(): 333 | pathlib.Path("logs").mkdir(exist_ok=True) 334 | pathlib.Path("data").mkdir(exist_ok=True) 335 | 336 | 337 | def opt_check_disk_space(warnlimit_mb=200): 338 | if disk_usage(".").free < warnlimit_mb * 1024 * 2: 339 | log.warning( 340 | "Less than %sMB of free space remains on this device" % warnlimit_mb 341 | ) 342 | 343 | 344 | ################################################# 345 | 346 | 347 | def pyexec(pycom, *args, pycom2=None): 348 | pycom2 = pycom2 or pycom 349 | os.execlp(pycom, pycom2, *args) 350 | 351 | 352 | def main(): 353 | # TODO: *actual* argparsing 354 | 355 | if "--no-checks" not in sys.argv: 356 | sanity_checks() 357 | 358 | import asyncio 359 | 360 | if sys.platform == "win32": 361 | loop = asyncio.ProactorEventLoop() # needed for subprocesses 362 | asyncio.set_event_loop(loop) 363 | 364 | tried_requirementstxt = False 365 | tryagain = True 366 | 367 | loops = 0 368 | max_wait_time = 60 369 | 370 | while tryagain: 371 | # Maybe I need to try to import stuff first, then actually import stuff 372 | # It'd save me a lot of pain with all that awful exception type checking 373 | 374 | bot = None 375 | try: 376 | from bot import ServerStatus 377 | 378 | bot = ServerStatus() 379 | 380 | sh.terminator = "" 381 | sh.terminator = "\n" 382 | 383 | bot.run() 384 | 385 | except SyntaxError: 386 | log.exception("Syntax error (this is a bug, not your fault)") 387 | break 388 | 389 | except ImportError: 390 | # TODO: if error module is in pip or dpy requirements... 391 | 392 | if not tried_requirementstxt: 393 | tried_requirementstxt = True 394 | 395 | log.exception("Error starting bot") 396 | log.info("Attempting to install dependencies...") 397 | 398 | err = PIP.run_install("--upgrade -r requirements.txt") 399 | 400 | if ( 401 | err 402 | ): # TODO: add the specific error check back as not to always tell users to sudo it 403 | print() 404 | log.critical( 405 | "You may need to %s to install dependencies." 406 | % ["use sudo", "run as admin"][sys.platform.startswith("win")] 407 | ) 408 | break 409 | else: 410 | print() 411 | log.info("Ok lets hope it worked") 412 | print() 413 | else: 414 | log.exception("Unknown ImportError, exiting.") 415 | break 416 | 417 | except Exception as e: 418 | import discord 419 | if isinstance(e, discord.LoginFailure): 420 | log.exception("There was an error logging into Discord. Please ensure that your token is correct.") 421 | break 422 | 423 | else: 424 | log.exception("Error starting bot") 425 | 426 | finally: 427 | if not bot or not bot.init_ok: 428 | if any(sys.exc_info()): 429 | # How to log this without redundant messages... 430 | traceback.print_exc() 431 | break 432 | 433 | asyncio.set_event_loop(asyncio.new_event_loop()) 434 | loops += 1 435 | 436 | if not bot or not bot.restart_signal: 437 | break 438 | 439 | sleeptime = min(loops * 2, max_wait_time) 440 | if sleeptime: 441 | log.info("Restarting in {} seconds...".format(loops * 2)) 442 | time.sleep(sleeptime) 443 | 444 | print() 445 | log.info("All done.") 446 | 447 | 448 | if __name__ == "__main__": 449 | main() 450 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This software was sourced from Just-Some-Bots/MusicBot 4 | # https://github.com/Just-Some-Bots 5 | 6 | # The MIT License 7 | 8 | # Copyright (c) 2015-2019 Just-Some-Bots (https://github.com/Just-Some-Bots) 9 | 10 | # Permission is hereby granted, free of charge, to any person obtaining a copy 11 | # of this software and associated documentation files (the "Software"), to deal 12 | # in the Software without restriction, including without limitation the rights 13 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | # copies of the Software, and to permit persons to whom the Software is 15 | # furnished to do so, subject to the following conditions: 16 | 17 | # The above copyright notice and this permission notice shall be included in 18 | # all copies or substantial portions of the Software. 19 | 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | # THE SOFTWARE. 27 | 28 | # Ensure we're in the MusicBot directory 29 | cd "$(dirname "$BASH_SOURCE")" 30 | 31 | # Set variables for python versions. Could probably be done cleaner, but this works. 32 | declare -A python=( ["0"]=`python -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[0]))' || { echo "no py"; }` ["1"]=`python -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[1]))' || { echo "no py"; }` ["2"]=`python -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[2]))' || { echo "no py"; }` ) 33 | declare -A python3=( ["0"]=`python3 -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[1]))' || { echo "no py3"; }` ["1"]=`python3 -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[2]))' || { echo "no py3"; }` ) 34 | PYTHON35_VERSION=`python3.5 -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[2]))' || { echo "no py35"; }` 35 | PYTHON36_VERSION=`python3.6 -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[1]))' || { echo "no py36"; }` 36 | PYTHON37_VERSION=`python3.7 -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[1]))' || { echo "no py37"; }` 37 | 38 | 39 | if [ "${python[0]}" -eq "3" ]; then # Python = 3 40 | if [ "${python[1]}" -ge "6" ]; then # Python >= 3.6 41 | python run.py 42 | exit 43 | fi 44 | fi 45 | 46 | 47 | if [ "${python3[0]}" -ge "6" ]; then # Python3 >= 3.6 48 | python3 run.py 49 | exit 50 | fi 51 | 52 | if [ "$PYTHON35_VERSION" -ge "3" ]; then # Python3.5 > 3.5.3 53 | python3.5 run.py 54 | exit 55 | fi 56 | 57 | if [ "$PYTHON36_VERSION" -eq "6" ]; then # Python3.6 = 3.6 58 | python3.6 run.py 59 | exit 60 | fi 61 | 62 | if [ "$PYTHON37_VERSION" -eq "7" ]; then # Python3.7 = 3.7 63 | python3.7 run.py 64 | exit 65 | fi 66 | 67 | echo "You are running an unsupported Python version." 68 | echo "Please use a version of Python at or above 3.6.0." 69 | -------------------------------------------------------------------------------- /updater.bat: -------------------------------------------------------------------------------- 1 | @ECHO off 2 | 3 | REM This software was sourced from Just-Some-Bots/MusicBot 4 | REM https://github.com/Just-Some-Bots 5 | 6 | REM The MIT License 7 | 8 | REM Copyright c 2015-2019 Just-Some-Bots - https://github.com/Just-Some-Bots 9 | 10 | REM Permission is hereby granted, free of charge, to any person obtaining a copy 11 | REM of this software and associated documentation files - the "Software" - , to deal 12 | REM in the Software without restriction, including without limitation the rights 13 | REM to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | REM copies of the Software, and to permit persons to whom the Software is 15 | REM furnished to do so, subject to the following conditions: 16 | 17 | REM The above copyright notice and this permission notice shall be included in 18 | REM all copies or substantial portions of the Software. 19 | 20 | REM THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | REM IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | REM FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | REM AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | REM LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | REM OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | REM THE SOFTWARE. 27 | 28 | CHCP 65001 > NUL 29 | 30 | REM --> Check for permissions 31 | IF "%PROCESSOR_ARCHITECTURE%" EQU "amd64" ( 32 | >nul 2>&1 "%SYSTEMROOT%\SysWOW64\cacls.exe" "%SYSTEMROOT%\SysWOW64\config\system" 33 | ) ELSE ( 34 | >nul 2>&1 "%SYSTEMROOT%\system32\cacls.exe" "%SYSTEMROOT%\system32\config\system" 35 | ) 36 | 37 | REM --> If error flag set, we do not have admin. 38 | if '%errorlevel%' NEQ '0' ( 39 | goto UACPrompt 40 | ) else ( goto gotAdmin ) 41 | 42 | :UACPrompt 43 | echo Set UAC = CreateObject^("Shell.Application"^) > "%temp%\getadmin.vbs" 44 | set params = %*:"="" 45 | echo UAC.ShellExecute "cmd.exe", "/c ""%~s0"" %params%", "", "runas", 1 >> "%temp%\getadmin.vbs" 46 | 47 | "%temp%\getadmin.vbs" 48 | del "%temp%\getadmin.vbs" 49 | exit /B 50 | 51 | :gotAdmin 52 | pushd "%CD%" 53 | CD /D "%~dp0" 54 | 55 | CD /d "%~dp0" 56 | 57 | IF EXIST %SYSTEMROOT%\py.exe ( 58 | CMD /k %SYSTEMROOT%\py.exe -3 updater.py 59 | EXIT 60 | ) 61 | 62 | python --version > NUL 2>&1 63 | IF %ERRORLEVEL% NEQ 0 GOTO nopython 64 | 65 | CMD /k python updater.py 66 | GOTO end 67 | 68 | :nopython 69 | ECHO ERROR: Python has either not been installed or not added to your PATH. 70 | 71 | :end 72 | PAUSE 73 | -------------------------------------------------------------------------------- /updater.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | This software was sourced from Just-Some-Bots/MusicBot 5 | https://github.com/Just-Some-Bots 6 | 7 | The MIT License 8 | 9 | Copyright (c) 2015-2019 Just-Some-Bots (https://github.com/Just-Some-Bots) 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in 19 | all copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 27 | THE SOFTWARE. 28 | """ 29 | 30 | import os 31 | import subprocess 32 | import sys 33 | 34 | 35 | def y_n(q): 36 | while True: 37 | ri = input("{} (y/n): ".format(q)) 38 | if ri.lower() in ["yes", "y"]: 39 | return True 40 | elif ri.lower() in ["no", "n"]: 41 | return False 42 | 43 | 44 | def update_deps(): 45 | print("Attempting to update dependencies...") 46 | 47 | try: 48 | subprocess.check_call( 49 | '"{}" -m pip install --no-warn-script-location --user -U -r requirements.txt'.format( 50 | sys.executable 51 | ), 52 | shell=True, 53 | ) 54 | except subprocess.CalledProcessError: 55 | raise OSError( 56 | "Could not update dependencies. You will need to run '\"{0}\" -m pip install -U -r requirements.txt' yourself.".format( 57 | sys.executable 58 | ) 59 | ) 60 | 61 | 62 | def get_info(question, *, default=None, optional=True): 63 | default_text = f" [{default}]" if default else " []" 64 | default_text = default_text if optional else "" 65 | 66 | while True: 67 | result = input(f"{question}{default_text}: ") 68 | if not result and optional: 69 | return default 70 | 71 | if not optional and not result: 72 | continue 73 | 74 | else: 75 | break 76 | return result 77 | 78 | 79 | class ConfigOption: 80 | def __init__(self, name, help, *, default=None, optional=True): 81 | self.name = name 82 | self.help = help 83 | 84 | if default and not optional: 85 | raise ValueError("this option is optional yet a default has been set") 86 | 87 | self.default = default 88 | self.optional = optional 89 | 90 | def prompt(self): 91 | result = get_info(self.help, default=self.default, optional=self.optional) 92 | return result 93 | 94 | 95 | class BotToken(ConfigOption): 96 | def __init__(self): 97 | super().__init__("bot-token", "Enter the token for the bot", optional=False) 98 | 99 | def prompt(self): 100 | description = self.help + ( 101 | ".\nYou can get the bot's token from the bot application. " 102 | "To learn how to create a bot application, visit https://discordpy.readthedocs.io/en/latest/discord.html" 103 | ) 104 | result = get_info(description, default=self.default, optional=self.optional) 105 | return result 106 | 107 | 108 | class Prefix(ConfigOption): 109 | def __init__(self): 110 | super().__init__("prefix", "Enter the prefix for the bot", default=";") 111 | 112 | 113 | class ServerType(ConfigOption): 114 | def __init__(self): 115 | super().__init__( 116 | "server-type", 117 | "Enter the type of Minecraft server (Java or Bedrock)", 118 | default="java", 119 | ) 120 | 121 | def prompt(self): 122 | while True: 123 | result = get_info(self.help, default=self.default, optional=self.optional) 124 | 125 | result = result.lower() 126 | 127 | if result in ["java", "bedrock"]: 128 | break 129 | 130 | print("Please enter either Java or Bedrock.") 131 | 132 | return result 133 | 134 | 135 | class ServerIP(ConfigOption): 136 | def __init__(self): 137 | super().__init__( 138 | "server-ip", 139 | "Enter the Minecraft server ip to display status for", 140 | optional=False, 141 | ) 142 | 143 | 144 | class RefreshRate(ConfigOption): 145 | def __init__(self): 146 | super().__init__( 147 | "refresh-rate", 148 | "Enter the amount of seconds to wait in between status refreshes", 149 | default=60, 150 | ) 151 | 152 | def prompt(self): 153 | while True: 154 | result = get_info(self.help, default=self.default, optional=self.optional) 155 | 156 | try: 157 | result = int(result) 158 | 159 | except ValueError: 160 | continue 161 | 162 | if result >= 30: 163 | break 164 | 165 | print("Seconds must be 30 or higher. This is due to Discord's ratelimit on changing statuses.") 166 | 167 | return result 168 | 169 | 170 | class MaintenceModeDetection(ConfigOption): 171 | def __init__(self): 172 | super().__init__( 173 | "maintenance-mode-detection", 174 | "Enter the text to look for in the MOTD", 175 | default=None, 176 | ) 177 | 178 | def prompt(self): 179 | enable = y_n( 180 | "Would you like to enable maintenance mode detection? " 181 | "This will allow you to specify a substring to search for in the Minecraft server's MOTD. " 182 | "If the substring is found, the bot's status is set to maintenance mode " 183 | "(DND presence with a maintenance mode message)." 184 | ) 185 | 186 | if not enable: 187 | return None 188 | 189 | result = get_info(self.help, optional=False) 190 | return result 191 | 192 | 193 | OPTIONS = (BotToken(), Prefix(), ServerType(), ServerIP(), RefreshRate(), MaintenceModeDetection()) 194 | 195 | 196 | def get_option(key): 197 | for option in OPTIONS: 198 | if option.name == key: 199 | return option 200 | return None 201 | 202 | 203 | def ensure_config_keys(config): 204 | import yaml 205 | 206 | all_keys = [o.name for o in OPTIONS] 207 | 208 | missing_opts = set(all_keys) - set(config.keys()) 209 | 210 | if missing_opts: 211 | joined = ", ".join(missing_opts) 212 | print(f"There are missing options in your config file: {joined}") 213 | set_to_default = y_n( 214 | "Automatically set these options to default? You can come back and change these later." 215 | ) 216 | 217 | if set_to_default: 218 | for opt in missing_opts: 219 | option = get_option(opt) 220 | config[opt] = option.default 221 | 222 | else: 223 | for opt in missing_opts: 224 | option = get_option(opt) 225 | result = option.prompt() 226 | config[opt] = result 227 | 228 | with open("config.yml", "w") as f: 229 | yaml.dump(config, f) 230 | 231 | print("Successfully updated config") 232 | 233 | else: 234 | print("Config is up-to-date") 235 | 236 | 237 | def run_config_adjustments(all_keys, formatted): 238 | import yaml 239 | 240 | with open("config.yml", "r") as f: 241 | current_config = yaml.safe_load(f) 242 | 243 | ensure_config_keys(current_config) 244 | 245 | change = y_n("Change info in config file?") 246 | 247 | view_more = "View more about each option here: https://github.com/Fyssion/mc-status-bot#setup-details" 248 | 249 | if not change: 250 | return 251 | 252 | while True: 253 | while True: 254 | to_change = get_info( 255 | f"Options: {formatted}\n{view_more}\nEnter option to change", 256 | optional=False, 257 | ) 258 | to_change = to_change.lower() 259 | 260 | if to_change in all_keys: 261 | break 262 | 263 | option = get_option(to_change) 264 | change_to = option.prompt() 265 | current_config[to_change] = change_to 266 | 267 | again = y_n("Change another option?") 268 | if not again: 269 | break 270 | 271 | with open("config.yml", "w") as f: 272 | yaml.dump(current_config, f) 273 | 274 | print("Successfully updated your config") 275 | 276 | 277 | def run_setup(): 278 | loops = 0 279 | 280 | while loops < 2: 281 | try: 282 | import yaml 283 | 284 | break 285 | 286 | except ImportError: 287 | print("Oh no! PyYAML is not installed. Trying to fix...") 288 | loops += 1 289 | update_deps() 290 | 291 | if loops >= 2: 292 | raise OSError("Could not install PyYAML. Try installing it manually.") 293 | 294 | all_keys = [o.name for o in OPTIONS] 295 | formatted = ", ".join(all_keys) 296 | 297 | config_exists = os.path.isfile("config.yml") 298 | 299 | if config_exists: 300 | run_config_adjustments(all_keys, formatted) 301 | 302 | else: 303 | print("Config file not found, initiating setup...") 304 | 305 | config = {} 306 | 307 | for option in OPTIONS: 308 | result = option.prompt() 309 | config[option.name] = result 310 | 311 | with open("config.yml", "w") as f: 312 | yaml.dump(config, f) 313 | 314 | print("Successfully created and setup config") 315 | 316 | 317 | def main(): 318 | print("Starting...") 319 | 320 | # Make sure that we're in a Git repository 321 | if not os.path.isdir(".git"): 322 | raise EnvironmentError("This isn't a Git repository.") 323 | 324 | # Make sure that we can actually use Git on the command line 325 | # because some people install Git Bash without allowing access to Windows CMD 326 | try: 327 | subprocess.check_call("git --version", shell=True, stdout=subprocess.DEVNULL) 328 | except subprocess.CalledProcessError: 329 | raise EnvironmentError( 330 | "Couldn't use Git on the CLI. You will need to run 'git pull' yourself." 331 | ) 332 | 333 | print("Passed Git checks...") 334 | 335 | # Check that the current working directory is clean 336 | sp = subprocess.check_output( 337 | "git status --porcelain", shell=True, universal_newlines=True 338 | ) 339 | if sp: 340 | ohno = y_n( 341 | "You have modified files that are tracked by Git (e.g the bot's source files).\n" 342 | "Should we try resetting the repo? You will lose local modifications." 343 | ) 344 | if ohno: 345 | try: 346 | subprocess.check_call("git reset --hard", shell=True) 347 | except subprocess.CalledProcessError: 348 | raise OSError("Could not reset the directory to a clean state.") 349 | else: 350 | wowee = y_n( 351 | "OK, skipping bot update. Do you still want to update dependencies?" 352 | ) 353 | if wowee: 354 | update_deps() 355 | 356 | run_setup() 357 | print("Done. You may now run the bot.") 358 | return 359 | 360 | print("Checking if we need to update the bot...") 361 | 362 | try: 363 | subprocess.check_call("git pull", shell=True) 364 | except subprocess.CalledProcessError: 365 | raise OSError( 366 | "Could not update the bot. You will need to run 'git pull' yourself." 367 | ) 368 | 369 | update_deps() 370 | run_setup() 371 | 372 | print("Done. You may now run the bot.") 373 | 374 | 375 | if __name__ == "__main__": 376 | main() 377 | -------------------------------------------------------------------------------- /updater.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This software was sourced from Just-Some-Bots/MusicBot 4 | # https://github.com/Just-Some-Bots 5 | 6 | # The MIT License 7 | 8 | # Copyright (c) 2015-2019 Just-Some-Bots (https://github.com/Just-Some-Bots) 9 | 10 | # Permission is hereby granted, free of charge, to any person obtaining a copy 11 | # of this software and associated documentation files (the "Software"), to deal 12 | # in the Software without restriction, including without limitation the rights 13 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | # copies of the Software, and to permit persons to whom the Software is 15 | # furnished to do so, subject to the following conditions: 16 | 17 | # The above copyright notice and this permission notice shall be included in 18 | # all copies or substantial portions of the Software. 19 | 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | # THE SOFTWARE. 27 | 28 | # Ensure we're in the MusicBot directory 29 | cd "$(dirname "$BASH_SOURCE")" 30 | 31 | # Set variables for python versions. Could probably be done cleaner, but this works. 32 | declare -A python=( ["0"]=`python -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[0]))' || { echo "no py"; }` ["1"]=`python -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[1]))' || { echo "no py"; }` ["2"]=`python -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[2]))' || { echo "no py"; }` ) 33 | declare -A python3=( ["0"]=`python3 -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[1]))' || { echo "no py3"; }` ["1"]=`python3 -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[2]))' || { echo "no py3"; }` ) 34 | PYTHON35_VERSION=`python3.5 -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[2]))' || { echo "no py35"; }` 35 | PYTHON36_VERSION=`python3.6 -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[1]))' || { echo "no py36"; }` 36 | PYTHON37_VERSION=`python3.7 -c 'import sys; version=sys.version_info[:3]; print("{0}".format(version[1]))' || { echo "no py37"; }` 37 | 38 | 39 | if [ "${python[0]}" -eq "3" ]; then # Python = 3 40 | if [ "${python[1]}" -ge "6" ]; then # Python >= 3.6 41 | python updater.py 42 | exit 43 | fi 44 | fi 45 | 46 | 47 | if [ "${python3[0]}" -ge "6" ]; then # Python3 >= 3.6 48 | python3 updater.py 49 | exit 50 | fi 51 | 52 | if [ "$PYTHON35_VERSION" -ge "3" ]; then # Python3.5 > 3.5.3 53 | python3.5 updater.py 54 | exit 55 | fi 56 | 57 | if [ "$PYTHON36_VERSION" -eq "6" ]; then # Python3.6 = 3.6 58 | python3.6 updater.py 59 | exit 60 | fi 61 | 62 | if [ "$PYTHON37_VERSION" -eq "7" ]; then # Python3.7 = 3.7 63 | python3.7 updater.py 64 | exit 65 | fi 66 | 67 | echo "You are running an unsupported Python version." 68 | echo "Please use a version of Python at or above 3.6.0." 69 | --------------------------------------------------------------------------------