basic bot setup

This commit is contained in:
Corban-Lee Jones 2024-12-05 22:59:34 +00:00
parent a5682eb260
commit afef3ae674
5 changed files with 307 additions and 0 deletions

16
.vscode/launch.json vendored Normal file
View File

@ -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
}
]
}

113
bot.py Normal file
View File

@ -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())

115
cogs/commands.py Normal file
View File

@ -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__)

51
logging.json Normal file
View File

@ -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"
}
}
}

12
requirements.txt Normal file
View File

@ -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