From fd962a9e767ab8a7280e709e0881f889ba799d72 Mon Sep 17 00:00:00 2001 From: corbz Date: Mon, 11 Dec 2023 01:19:22 +0000 Subject: [PATCH] init --- README.md | 8 +++- src/bot.py | 44 ++++++++++++++++++ src/extensions/test.py | 41 ++++++++++++++++ src/logs.py | 103 +++++++++++++++++++++++++++++++++++++++++ src/main.py | 46 ++++++++++++++++++ 5 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 src/bot.py create mode 100644 src/extensions/test.py create mode 100644 src/logs.py create mode 100644 src/main.py diff --git a/README.md b/README.md index ce910c5..1102892 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ # NewsBot -Bot delivering news articles to discord servers. \ No newline at end of file +Bot delivering news articles to discord servers. + +Plans + +- Multiple news providers +- Choose how much of each provider should be delivered +- Check for duplicate articles between providers, and only deliver preferred provider article diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..ca50d46 --- /dev/null +++ b/src/bot.py @@ -0,0 +1,44 @@ +""" +The discord bot for the application. +""" + +import logging +from pathlib import Path + +from discord import Intents +from discord.ext import commands + +log = logging.getLogger(__name__) + + +class DiscordBot(commands.Bot): + + def __init__(self, BASE_DIR: Path): + super().__init__(command_prefix="-", intents=Intents.all()) + self.BASE_DIR = BASE_DIR + + 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() + + async def load_extensions(self): + """ + Load any extensions found in the extensions dictionary. + """ + + for path in (self.BASE_DIR / "src/extensions").iterdir(): + if path.suffix == ".py": + await self.load_extension(f"extensions.{path.stem}") \ No newline at end of file diff --git a/src/extensions/test.py b/src/extensions/test.py new file mode 100644 index 0000000..94d5c21 --- /dev/null +++ b/src/extensions/test.py @@ -0,0 +1,41 @@ +""" +Extension for the `test` cog. +Loading this file via `commands.Bot.load_extension` will add the `test` cog to the bot. +""" + +import logging + +from discord import app_commands, Interaction +from discord.ext import commands, tasks + +log = logging.getLogger(__name__) + + +class Test(commands.Cog): + """ + News cog. + Delivers embeds of news articles to discord channels. + """ + + def __init__(self, bot): + super().__init__() + self.bot = bot + + @commands.Cog.listener() + async def on_ready(self): + log.info(f"{self.__class__.__name__} cog is ready") + + @app_commands.command(name="test-command") + async def test_command(self, inter: Interaction): + await inter.response.send_message("test") + + +async def setup(bot): + """ + Setup function for this extension. + Adds the `ErrorCog` cog to the bot. + """ + + cog = Test(bot) + await bot.add_cog(cog) + log.info(f"Added {cog.__class__.__name__} cog") diff --git a/src/logs.py b/src/logs.py new file mode 100644 index 0000000..f09bf92 --- /dev/null +++ b/src/logs.py @@ -0,0 +1,103 @@ +""" +Handle async logging for the project. +""" + +import sys +import queue +import logging +from logging.handlers import QueueHandler, QueueListener +from datetime import datetime, timedelta +from itertools import count +from typing import TextIO +from pathlib import Path +from os import getenv + +LOG_FILENAME_FORMAT_PREFIX = getenv("LOG_FILENAME_FORMAT_PREFIX") +MAX_LOGFILE_AGE_DAYS = getenv("MAX_LOGFILE_AGE_DAYS") + +log = logging.getLogger(__name__) + + +class LogSetup: + + def __init__(self, BASE_DIR: Path): + self.BASE_DIR = BASE_DIR + self.LOGS_DIR = BASE_DIR / "logs/" + + def _open_file(self) -> TextIO: + """ + Returns a file object for the current log file. + """ + + # Create the logs directory if it doesnt exist + self.LOGS_DIR.mkdir(exist_ok=True) + + # Create a generator to generate a unique filename + timestamp = datetime.now().strftime(LOG_FILENAME_FORMAT_PREFIX) + filenames = (f'{timestamp}.log' if i == 0 else f'{timestamp}_({i}).log' for i in count()) + + # Find a filename that doesn't already exist and return it + for filename in filenames: + try: + return (self.LOGS_DIR / filename).open("x", encoding="utf-8") + except FileExistsError: + continue + + def _delete_old_logs(self): + """ + Search through the logs directory and delete any expired log files. + """ + + for path in self.LOGS_DIR.glob('*.txt'): + prefix = path.stem.split('_')[0] + try: + log_date = datetime.strptime(prefix, LOG_FILENAME_FORMAT_PREFIX) + except ValueError: + log.warning(f'{path.parent} contains a problematic filename: {path.name}') + continue + + age = datetime.now() - log_date + if age >= timedelta(days=MAX_LOGFILE_AGE_DAYS): + log.info(f'Removing expired log file: {path.name}') + path.unlink() + + @staticmethod + def update_log_levels(logger_names:tuple[str], level:int): + """ + Quick way to update the log level of multiple loggers at once. + """ + for name in logger_names: + logger=logging.getLogger(name) + logger.setLevel(level) + + def setup_logs(self, log_level:int=logging.DEBUG) -> str: + """ + Setup a logging queue handler and queue listener. + Also creates a new log file for the current session and deletes old log files. + """ + + # Create a queue to pass log records to the listener + log_queue = queue.Queue() + queue_handler = QueueHandler(log_queue) + + # Configure the root logger to use the queue + logging.basicConfig( + level=log_level, + handlers=(queue_handler,), + format='[%(asctime)s] [%(levelname)-8s] [%(name)-18s]: %(message)s' + ) + + # Create a new log file + file = self._open_file() + + file_handler = logging.StreamHandler(file) # Stream logs to the log file + sys_handler = logging.StreamHandler(sys.stdout) # Stream logs to the console + + # Create a listener to handle the queue + queue_listener = QueueListener(log_queue, file_handler, sys_handler) + queue_listener.start() + + # Clear up old log files + self._delete_old_logs() + + return file.name \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..900294e --- /dev/null +++ b/src/main.py @@ -0,0 +1,46 @@ +""" +Entry point for the application. +Run this file to get started. +""" + +import logging +import asyncio +from os import getenv +from pathlib import Path + +from dotenv import load_dotenv +load_dotenv() + +from bot import DiscordBot +from logs import LogSetup + +BASE_DIR = Path(__file__).resolve().parent.parent + +async def main(): + """ + Entry point function for the application. + Run this function to get started. + """ + + # Grab the token before anything else, because if there is no token + # available then the bot cannot be started anyways. + token = getenv("BOT_TOKEN") + + if not token: + raise ValueError("Token is empty") + + # Setup logging settings + logsetup = LogSetup(BASE_DIR) + logsetup.setup_logs() + logsetup.update_log_levels( + ('discord', 'PIL', 'urllib3', 'aiosqlite', 'charset_normalizer'), + level=logging.WARNING + ) + + async with DiscordBot(BASE_DIR) as bot: + await bot.load_extensions() + await bot.start(token) + +if __name__ == "__main__": + + asyncio.run(main()) \ No newline at end of file