diff --git a/.gitignore b/.gitignore index 1b19a8d..4342f27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env .venv -**/fregbot-birthday.log -.idea/** \ No newline at end of file +**/*.log +.idea/** +uv.lock \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 85d7233..3a0797e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,12 @@ dependencies = [ "discord.py", "pydantic", ] +[project.optional-dependencies] +spielverabredungen = [ + "click", +] [tool.setuptools_scm] [project.scripts] -fregbot-birthday = "birthday:main" \ No newline at end of file +fregbot-birthday = "birthday:main" diff --git a/src/spielverabredungen.py b/src/spielverabredungen.py new file mode 100644 index 0000000..a24158a --- /dev/null +++ b/src/spielverabredungen.py @@ -0,0 +1,123 @@ +import asyncio +import datetime +import locale +import logging +from datetime import timedelta +from typing import Iterable + +import click +import discord +from discord import Client, ForumChannel +from pydantic import BaseModel, PositiveInt + + +class SpielverabredungenConfig(BaseModel): + guild_id: int + channel_id: int + start: datetime.datetime + end: datetime.datetime + go_back_threads_days: PositiveInt + + @property + def dates(self) -> Iterable[datetime.date]: + start, end = self.start.date(), self.end.date() + assert start <= end, f'Start date {self.start} must be smaller than end date {self.end}' + curr_date = start + while curr_date <= end: + yield curr_date + curr_date += timedelta(days=1) + + +class SpielverabredungenClient(Client): + config_object: SpielverabredungenConfig = None + + def __get_channel(self) -> ForumChannel: + guild = self.get_guild(self.config_object.guild_id) + assert guild, f'Guild with ID {self.config_object.guild_id} not found' + channel = discord.utils.get(guild.channels, id=self.config_object.channel_id) + assert channel, f'Channel with ID {self.config_object.channel_id} not found in guild {self.config_object.guild_id}' + assert isinstance(channel, + ForumChannel), f'Channel with ID {self.config_object.channel_id} is not a forum channel' + return channel + + def __check_duplicate_channels(self, channel: ForumChannel, dates: Iterable[datetime.date], + thread_lookback_days: int) -> Iterable[datetime.date]: + skipped_dates = 0 + potential_threadnames = {self.__get_thread_name(date): date for date in dates} + max_lookback_date = datetime.date.today() - datetime.timedelta(days=thread_lookback_days) + for thread in channel.threads: + if thread.locked: + continue + if thread.created_at.date() < max_lookback_date: + break + if thread.name in potential_threadnames: + del potential_threadnames[thread.name] + skipped_dates += 1 + logging.info('Skipping %d days from posting, as the threads were already found', skipped_dates) + yield from potential_threadnames.values() + + @staticmethod + def __get_thread_name(date: datetime.date) -> str: + return date.strftime('%Y-%m-%d %A') + + async def __create_thread(self, channel: ForumChannel, date: datetime.date) -> bool: + name = self.__get_thread_name(date) + thread = await channel.create_thread(name=name, + content="Tragt hier eure Spielverabredungen ein für oben genanntes Datum.", + reason='Spielverabredungen - Erstellt von Bot') + return thread is not None + + async def on_ready(self): + logging.info(f'Logging in as {self.user}') + try: + channel = self.__get_channel() + logging.info('Channel obtained') + dates = self.__check_duplicate_channels(channel, self.config_object.dates, + self.config_object.go_back_threads_days) + for index, date in enumerate(dates, start=1): + if index % 10 == 0: + logging.info('Attempting Thread creation number %d for %s', index, date) + if not await self.__create_thread(channel, date): + logging.error('Could not create thread for %s', date) + await asyncio.sleep(1) + except Exception as e: + logging.error(f'Error: {e}') + finally: + await self.close() + + +@click.command() +@click.option('--guild', '-g', type=int, help='ID of the guild (server) to post in', default=812043029967536188, + show_default=True) +@click.option('--channel', '-c', type=int, help='ID of the channel to post threads in', default=1389312271397818509, + show_default=True) +@click.option('--start', '-s', type=click.DateTime(), help='Date to start the threads for. Defaults to today', + default=datetime.datetime.today(), show_default=True) +@click.option('--end', '-e', type=click.DateTime(), help='Date to end the threads for. Defaults to today + 90 days', + default=datetime.datetime.today() + datetime.timedelta(days=90)) +@click.option('--thread-lookback', type=int, default=180, help='How many days to look back for duplicate threads', + show_default=True) +@click.option('--token', envvar='SPIELVERABREDUNGEN_TOKEN', help='Token to authenticate the bot', required=True) +@click.option('--debug', is_flag=True, help='Write debug messages to log') +def main(guild: int, channel: int, start: datetime.datetime, end: datetime.datetime, token: str, + thread_lookback: int, debug: bool) -> None: + intents = discord.Intents.default() + intents.messages = True + + log_handler = logging.FileHandler(filename='spielabredungen.log', encoding='utf-8', mode='w') + if debug: + log_handler.setLevel(logging.DEBUG) + else: + log_handler.setLevel(logging.INFO) + + locale.setlocale(locale.LC_ALL, 'de_DE.UTF-8') + logging.debug('Setting locale to "de_DE.UTF-8"') + + client = SpielverabredungenClient(intents=intents) + client.config_object = SpielverabredungenConfig(guild_id=guild, channel_id=channel, start=start, end=end, + go_back_threads_days=thread_lookback) + client.run(token, log_handler=log_handler, root_logger=True) + + +if __name__ == '__main__': + main()