diff --git a/threadrotate/__init__.py b/threadrotate/__init__.py new file mode 100644 index 0000000..e735b79 --- /dev/null +++ b/threadrotate/__init__.py @@ -0,0 +1,7 @@ +from .thread_rotate import ThreadRotate + +__red_end_user_data_statement__ = "This doesn't store any user data." + + +def setup(bot): + bot.add_cog(ThreadRotate(bot)) diff --git a/threadrotate/discord_thread_feature.py b/threadrotate/discord_thread_feature.py new file mode 100644 index 0000000..facb0f8 --- /dev/null +++ b/threadrotate/discord_thread_feature.py @@ -0,0 +1,126 @@ +# NOTE: this file contains backports or unintroduced features of next versions of dpy (as for 1.7.3) +import discord +from discord.http import Route + + +async def create_thread( + bot, + channel: discord.TextChannel, + name: str, + archive: int = 1440, + message: discord.Message = None, +): + """ + Creates a new thread in the channel from the message + + Args: + channel (TextChannel): The channel the thread will be apart of + message (Message): The discord message the thread will start with + name (str): The name of the thread + archive (int): The archive duration. Can be 60, 1440, 4320, and 10080. + + Returns: + int: The channel ID of the newly created thread + + Note: + The guild must be boosted for longer thread durations then a day. The archive parameter will automatically be scaled down if the feature is not present. + + Raises HTTPException 400 if thread creation fails + """ + guild = channel.guild + if archive > 4320 and "THREE_DAY_THREAD_ARCHIVE" not in guild.features: + archive = 1440 + elif archive == 10080 and "SEVEN_DAY_THREAD_ARCHIVE" not in guild.features: + archive = 4320 + + reason = "Thread Rotation" + fields = {"name": name, "auto_archive_duration": archive} + + if message is not None: + r = Route( + "POST", + "/channels/{channel_id}/messages/{message_id}/threads", + channel_id=channel.id, + message_id=message.id, + ) + else: + r = Route( + "POST", + "/channels/{channel_id}/threads", + channel_id=channel.id, + ) + + return (await bot.http.request(r, json=fields, reason=reason))["id"] + + +async def send_thread_message( + bot, + thread_id: int, + content: str, + mention_roles: list = [], +): + """ + Send a message in a thread, allowing pings for roles + + Args: + bot (Red): The bot object + thread_id (int): ID of the thread + content (str): The message to send + mention_roles (list, optional): List of role ids to allow mentions. Defaults to []. + + Returns: + int: ID of the new message + """ + fields = {"content": content, "allowed_mentions": {"parse": ["roles"], "roles": mention_roles}} + + r = Route( + "POST", + "/channels/{channel_id}/nessages", + channel_id=thread_id, + ) + + return (await bot.http.request(r, json=fields))["id"] + + +async def add_user_thread(bot, channel: int, member: discord.Member): + """ + Add a user to a thread + + Args: + channel (int): The channel id that represents the thread + member (Member): The member to add to the thread + """ + reason = "Thread Manager" + + r = Route( + "POST", + "/channels/{channel_id}/thread-members/{user_id}", + channel_id=channel, + user_id=member.id, + ) + + return await bot.http.request(r, reason=reason) + + +async def get_active_threads(bot, guild: discord.Guild): + """ + Get all active threads in the guild + + Args: + guild (Guild): The guild to get active threads in + + Returns: + list(int): List of thread IDs of each actuvate thread + """ + + reason = "Thread Manager" + + r = Route( + "GET", + "/guilds/{guild_id}/threads/active", + guild_id=guild.id, + ) + + res = await bot.http.request(r, reason=reason) + + return [t["id"] for t in res["threads"]] diff --git a/threadrotate/info.json b/threadrotate/info.json new file mode 100644 index 0000000..e2df4c1 --- /dev/null +++ b/threadrotate/info.json @@ -0,0 +1,8 @@ +{ + "author" : ["brandons209"], + "install_msg" : "Thank you for installing my cog!", + "name" : "Thread Manager", + "short" : "Manage access to threads for users.", + "description" : "Allows a more finer management of users and threads.", + "tags" : ["Threads"] +} diff --git a/threadrotate/thread_rotate.py b/threadrotate/thread_rotate.py new file mode 100644 index 0000000..a136fc7 --- /dev/null +++ b/threadrotate/thread_rotate.py @@ -0,0 +1,131 @@ +import asyncio +import discord +import random +from datetime import datetime, timedelta +from typing import Optional, Literal + +from redbot.core import Config, checks, commands +from redbot.core.utils.chat_formatting import * +from redbot.core.utils.predicates import MessagePredicate + +from .discord_thread_feature import * +from .time_utils import * + + +class ThreadRotate(commands.Cog): + """ + Rotate threads for events, roleplay, etc + """ + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=45612361654894681623, force_registration=True) + + default_channel = { + "topics": {}, + "ping_roles": [], + "rotation_interval": 10080, + "rotate_on": None, + "last_topic": None, + } + self.config.register_channel(**default_channel) + + @commands.group(name="rotate") + @commands.guild_only() + @checks.admin() + async def thread_rotate(self, ctx): + """ + Manage thread rotations per channel + """ + pass + + @thread_rotate.command(name="setup") + async def thread_rotate_setup(self, ctx, channel: discord.TextChannel): + """ + Interactively setup a thread rotation for a channel + """ + guild = ctx.guild + await ctx.send( + info( + "Welcome to the thread rotation setup wizard!\n\nFirst, please specifiy the rotation interval. Rotation intervals can be formatted as follows:\n\t5 minutes\n\t1 minute 30 seconds\n\t1 hour\n\t2 days\n\t30 days\n\t5h30m\n\t(etc)" + ) + ) + + pred = MessagePredicate.same_context(ctx) + try: + msg = await self.bot.wait_for("message", check=pred, timeout=121) + except asyncio.TimeoutError: + await ctx.send(error("Took too long, cancelling setup!"), delete_after=30) + return + + interval = parse_timedelta(msg.content.strip()) + if interval is None: + await ctx.send(error("Invalid time interval, please run setup again!"), delete_after=60) + return + + await ctx.send( + info( + "Thank you.\n\nNow, please specify the date and time to start rotation. You can say `now` to start rotation as soon as setup is complete.\n\nValid date formats are:\n\tFebruary 14 at 6pm EDT\n\t2019-04-13 06:43:00 PST\n\t01/20/18 at 21:00:43\n\t(etc)" + ) + ) + + pred = MessagePredicate.same_context(ctx) + try: + msg = await self.bot.wait_for("message", check=pred, timeout=121) + except asyncio.TimeoutError: + await ctx.send(error("Took too long, cancelling setup!"), delete_after=30) + return + + date = parse_time(msg.content.strip()) + if date is None: + await ctx.send(error("Invalid date, please run setup again!"), delete_after=60) + return + + await ctx.send( + info( + "Great, next step is to list all roles that should be pinged and added to each thread when it rotates.\n\nList each role **seperated by a comma `,`**.\nYou can use role IDs, role mentions, or role names." + ) + ) + + pred = MessagePredicate.same_context(ctx) + try: + msg = await self.bot.wait_for("message", check=pred, timeout=241) + except asyncio.TimeoutError: + await ctx.send(error("Took too long, cancelling setup!"), delete_after=30) + return + + roles = [m.strip().strip("<").strip(">").strip("@").strip("&") for m in msg.content.split(",")] + role_objs = [] + for r in roles: + try: + role = guild.get_role(int(r)) + except: + role = discord.utils.find(lambda c: c.name == r, guild.roles) + if role is None: + await ctx.send(error(f"Unknown channel: `{r}`, please run the command again.")) + return + + role_objs.append(channel) + + await ctx.send( + info( + "Final step is to list the thread topics and their selection weights.\nThe weight is how likely the topic will be choosen.\nA weight of `1` means it will not be choosen more or less than other topics.\nA weight between 0 and 1 means it is that weight times less likely to be choosen, with a weight of 0 meaning it will never be choosen.\nA weight greater than 1 means it will be that times more likely to be choosen.\n\nFor example, a weight of 1.5 means that topic is 1.5 more likely to be choose over the others. A weight of 0.5 means that topic is half as likely to be choosen by others.\n\nPlease use this format for listing the weights:\n" + ) + ) + await ctx.send(box("topic name: weight_value\ntopic 2 name: weight_value\ntopic 3 name: weight_value")) + + pred = MessagePredicate.same_context(ctx) + try: + msg = await self.bot.wait_for("message", check=pred, timeout=301) + except asyncio.TimeoutError: + await ctx.send(error("Took too long, cancelling setup!"), delete_after=30) + return + + topics = msg.content.split("\n") + parsed_topics = {} + # TODO error checking + for topic in topics: + topic = topic.split(":") + parsed_topics[topic[0]] = float(topic[1]) + + await ctx.send(info(f"Please review the settings for thread rotation on channel {channel.mention}")) diff --git a/threadrotate/time_utils.py b/threadrotate/time_utils.py new file mode 100644 index 0000000..a4fe990 --- /dev/null +++ b/threadrotate/time_utils.py @@ -0,0 +1,55 @@ +import pytz +import re +from datetime import datetime as dt +from typing import Optional +from dateutil import parser +from dateutil.tz import gettz +from dateutil.relativedelta import relativedelta + +TIME_RE_STRING = r"\s?".join( + [ + r"((?P\d+?)\s?(years?|y))?", + r"((?P\d+?)\s?(months?|mt))?", + r"((?P\d+?)\s?(weeks?|w))?", + r"((?P\d+?)\s?(days?|d))?", + r"((?P\d+?)\s?(hours?|hrs|hr?))?", + r"((?P\d+?)\s?(minutes?|mins?|m(?!o)))?", # prevent matching "months" + r"((?P\d+?)\s?(seconds?|secs?|s))?", + ] +) + +TIME_RE = re.compile(TIME_RE_STRING, re.I) + + +def gen_tzinfos(): + for zone in pytz.common_timezones: + try: + tzdate = pytz.timezone(zone).localize(dt.utcnow(), is_dst=None) + except pytz.NonExistentTimeError: + pass + else: + tzinfo = gettz(zone) + + if tzinfo: + yield tzdate.tzname(), tzinfo + + +def parse_time(datetimestring: str): + tzinfo = dict(gen_tzinfos()) + ret = parser.parse(datetimestring, tzinfos=tzinfo) + if ret.tzinfo is not None: + ret = ret.astimezone(pytz.utc) + return ret + + +def parse_time_naive(datetimestring: str): + return parser.parse(datetimestring) + + +def parse_timedelta(argument: str) -> Optional[relativedelta]: + matches = TIME_RE.match(argument) + if matches: + params = {k: int(v) for k, v in matches.groupdict().items() if v} + if params: + return relativedelta(**params) + return None