init
This commit is contained in:
parent
a61b227c9f
commit
fd962a9e76
@ -1,3 +1,9 @@
|
|||||||
# NewsBot
|
# NewsBot
|
||||||
|
|
||||||
Bot delivering news articles to discord servers.
|
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
|
||||||
|
44
src/bot.py
Normal file
44
src/bot.py
Normal file
@ -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}")
|
41
src/extensions/test.py
Normal file
41
src/extensions/test.py
Normal file
@ -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")
|
103
src/logs.py
Normal file
103
src/logs.py
Normal file
@ -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
|
46
src/main.py
Normal file
46
src/main.py
Normal file
@ -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())
|
Loading…
x
Reference in New Issue
Block a user