diff --git a/fifo/__init__.py b/fifo/__init__.py new file mode 100644 index 0000000..dedd355 --- /dev/null +++ b/fifo/__init__.py @@ -0,0 +1,7 @@ +from .fifo import FIFO + + +async def setup(bot): + cog = FIFO(bot) + await cog.load_tasks() + bot.add_cog(cog) diff --git a/fifo/fifo.py b/fifo/fifo.py new file mode 100644 index 0000000..ae484d4 --- /dev/null +++ b/fifo/fifo.py @@ -0,0 +1,250 @@ +from typing import Dict, Union + +from apscheduler.executors.asyncio import AsyncIOExecutor +from apscheduler.jobstores.memory import MemoryJobStore +from apscheduler.triggers.base import BaseTrigger +from apscheduler.triggers.combining import AndTrigger, OrTrigger +from apscheduler.triggers.date import DateTrigger +from apscheduler.triggers.interval import IntervalTrigger +from dateutil import parser +from redbot.core import Config, checks, commands +from redbot.core.bot import Red +from apscheduler.schedulers.asyncio import AsyncIOScheduler +import discord +import asyncio +import datetime + +from redbot.core.commands import DictConverter, TimedeltaConverter, parse_timedelta +from redbot.core.utils import AsyncIter + + +def get_trigger(data): + if data["type"] == "interval": + parsed_time = parse_timedelta(data["timedelta_str"]) + return IntervalTrigger(days=parsed_time.days, seconds=parsed_time.seconds) + + if data["type"] == "date": + return DateTrigger(parser.parse(data["strtime"])) + + if data["type"] == "cron": + return None # TODO: Cron parsing + + +def parse_triggers(data: Union[Dict, None]): + if data is None or not data.get("triggers", False): # No triggers + return None + + if len(data["triggers"]) > 1: # Multiple triggers + return OrTrigger(get_trigger(t_data) for t_data in data["triggers"]) + + return get_trigger(data[0]) + + +class Task: + + default_task_data = {"triggers": [], "command_str": ""} + + default_trigger = { + "type": "", + "timedelta_str": "", + } + + def __init__(self, name: str, guild_id, config: Config): + self.name = name + self.guild_id = guild_id + self.config = config + + self.data = None + + async def load_from_data(self, data: Dict): + self.data = data.copy() + + async def load_from_config(self): + self.data = await self.config.guild_from_id(self.guild_id).tasks.get_raw( + self.name, default=None + ) + return self.data + + async def get_trigger(self) -> Union[BaseTrigger, None]: + if self.data is None: + await self.load_from_config() + + return parse_triggers(self.data) + + # async def set_job_id(self, job_id): + # if self.data is None: + # await self.load_from_config() + # + # self.data["job_id"] = job_id + + async def save_data(self): + await self.config.guild_from_id(self.guild_id).tasks.set_raw(self.name, value=self.data) + + async def execute(self): + pass # TODO: something something invoke command + + async def add_trigger(self, param, parsed_time): + pass + + +class FIFO(commands.Cog): + """ + Simple Scheduling Cog + + Named after the simplest scheduling algorithm: First In First Out + """ + + def __init__(self, bot: Red): + super().__init__() + self.bot = bot + self.config = Config.get_conf(self, identifier=70737079, force_registration=True) + + default_global = {"jobs_index": {}, "jobs": []} + default_guild = {"tasks": {}} + + self.config.register_global(**default_global) + self.config.register_guild(**default_guild) + + jobstores = {"default": MemoryJobStore()} + + job_defaults = {"coalesce": False, "max_instances": 1} + + # executors = {"default": AsyncIOExecutor()} + + # Default executor is already AsyncIOExecutor + self.scheduler = AsyncIOScheduler( + jobstores=jobstores, job_defaults=job_defaults + ) + + async def red_delete_data_for_user(self, **kwargs): + """Nothing to delete""" + return + + async def _parse_command(self, command_to_parse: str): + return False # TODO: parse commands somehow + + @checks.is_owner() # Will be reduced when I figure out permissions later + @commands.group() + async def fifo(self, ctx: commands.Context): + """ + Base command for handling scheduling of tasks + """ + if ctx.invoked_subcommand is None: + pass + + @fifo.command(name="list") + async def fifo_list(self, ctx: commands.Context, all_guilds: bool = False): + """ + Lists all current tasks and their triggers. + + Do `[p]fifo list True` to see tasks from all guilds + """ + if all_guilds: + pass + else: + pass # TODO: parse and display tasks + + @fifo.command(name="add") + async def fifo_add(self, ctx: commands.Context, task_name: str, *, command_to_execute: str): + """ + Add a new task to this guild's task list + """ + pass + + @fifo.command(name="delete") + async def fifo_delete(self, ctx: commands.Context, task_name: str, *, command_to_execute: str): + """ + Deletes a task from this guild's task list + """ + pass + + @fifo.group(name="trigger") + async def fifo_trigger(self, ctx: commands.Context): + """ + Add a new trigger for a task from the current guild. + """ + if ctx.invoked_subcommand is None: + pass + + @fifo_trigger.command(name="interval") + async def fifo_trigger_interval( + self, ctx: commands.Context, task_name: str, interval_str: TimedeltaConverter + ): + """ + Add an interval trigger to the specified task + """ + + task = Task(task_name, ctx.guild.id, self.config) + await task.load_from_config() + + if task.data is None: + await ctx.maybe_send_embed( + f"Task by the name of {task_name} is not found in this guild" + ) + return + + result = await task.add_trigger("interval", interval_str) + if not result: + await ctx.maybe_send_embed( + "Failed to add an interval trigger to this task, see console for logs" + ) + return + await ctx.tick() + + @fifo_trigger.command(name="date") + async def fifo_trigger_date( + self, ctx: commands.Context, task_name: str, datetime_str: TimedeltaConverter + ): + """ + Add a "run once" datetime trigger to the specified task + """ + + task = Task(task_name, ctx.guild.id, self.config) + await task.load_from_config() + + if task.data is None: + await ctx.maybe_send_embed( + f"Task by the name of {task_name} is not found in this guild" + ) + return + + result = await task.add_trigger("date", datetime_str) + if not result: + await ctx.maybe_send_embed( + "Failed to add a date trigger to this task, see console for logs" + ) + return + await ctx.tick() + + @fifo_trigger.command(name="cron") + async def fifo_trigger_cron( + self, ctx: commands.Context, task_name: str, cron_settings: DictConverter + ): + """ + Add a "time of day" trigger to the specified task + """ + await ctx.maybe_send_embed("Not yet implemented") + + async def load_tasks(self): + """ + Run once on cog load. + """ + all_guilds = await self.config.all_guilds() + async for guild_id, guild_data in AsyncIter(all_guilds["tasks"].items(), steps=100): + for task_name, task_data in guild_data["tasks"].items(): + task = Task(task_name, guild_id, self.config) + await task.load_from_data(task_data) + + job = self.scheduler.add_job( + task.execute, id=task_name + "_" + guild_id, trigger=await task.get_trigger(), + ) + + self.scheduler.start() + + # async def parent_loop(self): + # await asyncio.sleep(60) + # asyncio.create_task(self.process_tasks(datetime.datetime.utcnow())) + # + # async def process_tasks(self, now: datetime.datetime): + # for task in self.tasks: + # pass diff --git a/fifo/info.json b/fifo/info.json new file mode 100644 index 0000000..4a9cd1c --- /dev/null +++ b/fifo/info.json @@ -0,0 +1,18 @@ +{ + "author": [ + "Bobloy" + ], + "min_bot_version": "3.3.0", + "description": "Schedule commands to be run by certain at certain times or intervals", + "hidden": false, + "install_msg": "Thank you for installing FIFO.\nGet started with `[p]load fifo`, then `[p]help FIFO`", + "short": "Schedule commands to be run by certain at certain times or intervals\"", + "end_user_data_statement": "This cog does not store any End User Data", + "tags": [ + "bobloy", + "utilities", + "tools", + "tool", + "roles" + ] +} \ No newline at end of file diff --git a/fifo/jobstores/redconfig.py b/fifo/jobstores/redconfig.py new file mode 100644 index 0000000..0707b6e --- /dev/null +++ b/fifo/jobstores/redconfig.py @@ -0,0 +1,35 @@ +import asyncio + +from apscheduler.jobstores.base import BaseJobStore +from redbot.core import Config + + +class RedConfigJobStore(BaseJobStore): + def __init__(self, config: Config, loop): + super().__init__() + self.config = config + self.loop: asyncio.BaseEventLoop = loop + + def lookup_job(self, job_id): + task = self.loop.create_task(self.config.jobs_index.get_raw(job_id)) + + def get_due_jobs(self, now): + pass + + def get_next_run_time(self): + pass + + def get_all_jobs(self): + pass + + def add_job(self, job): + pass + + def update_job(self, job): + pass + + def remove_job(self, job_id): + pass + + def remove_all_jobs(self): + pass