diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..89f3323 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Current File", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/bot.py", + "console": "integratedTerminal", + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..fc37806 --- /dev/null +++ b/bot.py @@ -0,0 +1,113 @@ +""" +Spiffo +A Discord Bot for integration with a Project Zomboid game server. +""" + +import json +import atexit +import asyncio +import logging +import logging.config +from os import getenv +from pathlib import Path + +from discord import Intents +from discord.ext import commands +from dotenv import load_dotenv + +load_dotenv(override=True) +log = logging.getLogger(__name__) + +BASE_DIR = Path(__file__).resolve().parent + + +class DiscordBot(commands.Bot): + """ + Represents a Discord bot. + Contains controls to interact with the bot via the Discord API. + """ + rcon_details: dict + + def __init__(self): + super().__init__(command_prefix="-", intents=Intents.all()) + self.rcon_details = { + "host": getenv("SPIFFO__RCON_HOST"), + "port": getenv("SPIFFO__RCON_PORT"), + "passwd": getenv("SPIFFO__RCON_PASSWORD") + } + + async def sync_app_commands(self): + """ + Sync application commands between Discord and the bot. + """ + await self.wait_until_ready() + await self.tree.sync() + log.info("Application commands successfully synced") + + async def on_ready(self): + """ + Execute init operations that require the bot to be ready. + Ideally should not be manually called, this is handled by discord.py + """ + + await self.sync_app_commands() + log.info("Discord Bot is ready") + + async def load_cogs(self): + """ + Load any extensions found in the cogs dictionary. + """ + + for path in (BASE_DIR / "cogs").iterdir(): + if path.suffix == ".py": + await self.load_extension(f"cogs.{path.stem}") + + +def get_bot_token() -> str: + """ + Retrieve the access token of the bot from the environment. + Raises `ValueError` if token is missing or blank. + """ + bot_token = getenv("SPIFFO__BOT_TOKEN") + if not bot_token: + raise ValueError("'SPIFFO__BOT_TOKEN' environment variable is missing or empty.") + + return bot_token + +def setup_logging_config(): + """ + Loads the logging configuration and creates an asynchronous queue handler. + """ + log_config_path = BASE_DIR / "logging.json" + if not log_config_path.exists(): + raise FileNotFoundError(log_config_path) + + with open(log_config_path, "r", encoding="utf-8") as file: + log_config = json.load(file) + + # Ensure the logging directory exists + (BASE_DIR / "logs").mkdir(exist_ok=True) + + # Load the config + logging.config.dictConfig(log_config) # This 'logging.config' path is jank. + + # Register the queue handler for asynchronous logging + queue_handler = logging.getHandlerByName("queue_handler") + if queue_handler is not None: + queue_handler.listener.start() + atexit.register(queue_handler.listener.stop) + +async def main(): + """ + The entrypoint function, initialises the application. + """ + setup_logging_config() + bot_token = get_bot_token() + + async with DiscordBot() as bot: + await bot.load_cogs() + await bot.start(bot_token, reconnect=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/cogs/commands.py b/cogs/commands.py new file mode 100644 index 0000000..c5f6d50 --- /dev/null +++ b/cogs/commands.py @@ -0,0 +1,115 @@ +""" +Contains commands for users to influence the bot via Discord. +""" + +import logging + +from rcon.source import rcon +from discord.ext import commands +from discord import app_commands, Interaction, Permissions + +log = logging.getLogger(__name__) +COMMANDS_BLACKLIST = [ + "additem", + "addvehicle", + "addxp", + "alarm", + "chopper", + "clear", + "createhorde", + "createhorde2", + "godmod", + "gunshot", + "invisible", + "lightning", + "log", + "noclip", + "quit", + "releasesafehouse", + "removezombies", + "replay", + "setaccesslevel", + "startrain", + "startstorm", + "stoprain", + "stopweather", + "teleport", + "teleportto", + "thunder" + +] + + +class CommandCog(commands.Cog): + """ + Contains user commands. + """ + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.Cog.listener() + async def on_ready(self): + log.info("%s cog is ready", self.__class__.__name__) + + rcon_group = app_commands.Group( + name="rcon", + description="Remote console commands", + default_permissions=Permissions.all(), + guild_only=True + ) + + async def send_rcon_response(self, inter: Interaction, command: str) -> str: + # if inter.user.id != 377453890523627522: + # log.warning("Bad user tried to send rcon command: '%s', '%s'", inter.user.name, command) + # await inter.response.send_message("Permissions Error", ephemeral=True) + # return + + if command in COMMANDS_BLACKLIST: + log.warning("Attempt to use banned command: '%s', '%s'", inter.user.name, command) + await inter.response.send_message("Blacklisted command", ephemeral=True) + return + + await inter.response.defer() + response = await rcon(command, **self.bot.rcon_details) + await inter.followup.send(content=response) + + @rcon_group.command(name="command") + async def send_rcon_command(self, inter: Interaction, command: str): + """ + Send a command via remote console. + """ + await self.send_rcon_response(inter, command) + + @rcon_group.command(name="save") + async def send_rcon_save(self, inter: Interaction): + """ + Save the in-game world. + """ + await self.send_rcon_response(inter, "save") + + @rcon_group.command(name="servermsg") + async def send_rcon_servermsg(self, inter: Interaction, message: str): + """ + Broadcast a message to all players. + """ + await self.send_rcon_response(inter, f'servermsg "{message}"') + + @rcon_group.command(name="players") + async def send_rcon_players(self, inter: Interaction): + """ + Get a list of online players. + """ + await self.send_rcon_response(inter, "players") + + @rcon_group.command(name="check-mods-need-update") + async def send_rcon_check_mod_needs_update(self, inter: Interaction): + """ + Get a list of mods that need an update, if any. + """ + await self.send_rcon_response(inter, "checkModsNeedUpdate") + + +async def setup(bot: commands.Bot): + cog = CommandCog(bot) + await bot.add_cog(cog) + log.info("Added %s cog", cog.__class__.__name__) diff --git a/logging.json b/logging.json new file mode 100644 index 0000000..dcc8856 --- /dev/null +++ b/logging.json @@ -0,0 +1,51 @@ +{ + "version": 1, + "disable_existing_loggers": true, + "formatters": { + "simple": { + "format": "%(levelname)s %(message)s" + }, + "detail": { + "format": "[%(asctime)s] [%(levelname)s|%(name)s]: %(message)s" + }, + "complex": { + "format": "[%(levelname)s|%(module)s|L%(lineno)d] %(asctime)s %(message)s", + "datefmt": "%Y-%m-%dT%H:%M:%S%z" + } + }, + "handlers": { + "stdout": { + "class": "logging.StreamHandler", + "level": "DEBUG", + "formatter": "detail", + "stream": "ext://sys.stdout" + }, + "file": { + "class": "logging.handlers.RotatingFileHandler", + "level": "DEBUG", + "formatter": "complex", + "filename": "logs/spiffo.log", + "maxBytes": 1048576, + "backupCount": 3 + }, + "queue_handler": { + "class": "logging.handlers.QueueHandler", + "handlers": [ + "stdout", + "file" + ], + "respect_handler_level": true + } + }, + "loggers": { + "root": { + "level": "DEBUG", + "handlers": [ + "queue_handler" + ] + }, + "discord": { + "level": "INFO" + } + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d550e99 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +aiohappyeyeballs==2.4.4 +aiohttp==3.11.9 +aiosignal==1.3.1 +attrs==24.2.0 +discord.py==2.4.0 +frozenlist==1.5.0 +idna==3.10 +multidict==6.1.0 +propcache==0.2.1 +python-dotenv==1.0.1 +rcon==2.4.9 +yarl==1.18.3