diff --git a/namechange/namechange.py b/namechange/namechange.py index d41a46a..29af1e4 100644 --- a/namechange/namechange.py +++ b/namechange/namechange.py @@ -127,9 +127,9 @@ class NameChange(commands.Cog): except discord.HTTPException: return False - @commands.command() - async def test(self, ctx): - await self.update_namechanges() + # @commands.command() + # async def test(self, ctx): + # await self.update_namechanges() @commands.group(name="name", invoke_without_command=True) @commands.guild_only() @@ -348,6 +348,7 @@ class NameChange(commands.Cog): await ctx.send(page) @namechange_user.group(name="optout") + @checks.admin() async def namechange_user_optout(self, ctx): """ Opt specific users out from changing their name @@ -401,10 +402,26 @@ class NameChange(commands.Cog): currency_name = await bank.get_currency_name(ctx.guild) await ctx.send( - info(f"It costs {current_cost} {currency_name} **per minute** to change someone's name."), - delete_after=30, + info(f"It costs {current_cost} {currency_name} **per minute** to change someone's name."), delete_after=30, ) + @namechange.command(name="optin") + async def namechange_optin(self, ctx): + """ + Opt in or opt out of allowing others to change your name + + If you are already opted in, running the command again will opt you out. + """ + member = ctx.author + async with self.config.guild(ctx.guild).allowed_users() as allowed_users: + if member.id not in allowed_users: + allowed_users.append(member.id) + await ctx.tick() + else: + allowed_users.remove(member.id) + await ctx.tick() + await ctx.send(info("You have opted out of allowing others to change your name."), delete_after=30) + @namechange.command(name="remove") @checks.admin() async def namechange_remove(self, ctx, *, member: discord.Member): diff --git a/punish/discord_thread_feature.py b/punish/discord_thread_feature.py index 852b8d3..852a1c2 100644 --- a/punish/discord_thread_feature.py +++ b/punish/discord_thread_feature.py @@ -37,10 +37,8 @@ async def create_thread( 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 + if archive > 10080: + archive = 10080 if thread_type == THREAD_TYPES.PRIVATE_THREAD and "PRIVATE_THREADS" not in guild.features: raise AttributeError("Your guild requires Level 2 Boost to use private threads.") @@ -54,11 +52,7 @@ async def create_thread( } reason = "Punish Thread Creation" - r = Route( - "POST", - "/channels/{channel_id}/threads", - channel_id=channel.id, - ) + r = Route("POST", "/channels/{channel_id}/threads", channel_id=channel.id,) return (await bot.http.request(r, json=fields, reason=reason))["id"] @@ -73,11 +67,6 @@ async def add_user_thread(bot, channel: int, member: discord.Member): """ reason = "Punish Add Member" - r = Route( - "POST", - "/channels/{channel_id}/thread-members/{user_id}", - channel_id=channel, - user_id=member.id, - ) + 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) diff --git a/threadmanager/discord_thread_feature.py b/threadmanager/discord_thread_feature.py index 4957dc5..ac975ac 100644 --- a/threadmanager/discord_thread_feature.py +++ b/threadmanager/discord_thread_feature.py @@ -22,19 +22,14 @@ async def create_thread(bot, channel: discord.TextChannel, message: discord.Mess 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 + if archive > 10080: + archive = 10080 fields = {"name": name, "auto_archive_duration": archive} reason = "Thread Manager" r = Route( - "POST", - "/channels/{channel_id}/messages/{message_id}/threads", - channel_id=channel.id, - message_id=message.id, + "POST", "/channels/{channel_id}/messages/{message_id}/threads", channel_id=channel.id, message_id=message.id, ) return (await bot.http.request(r, json=fields, reason=reason))["id"] @@ -50,12 +45,7 @@ async def add_user_thread(bot, channel: int, member: discord.Member): """ reason = "Thread Manager" - r = Route( - "POST", - "/channels/{channel_id}/thread-members/{user_id}", - channel_id=channel, - user_id=member.id, - ) + 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) @@ -73,11 +63,7 @@ async def get_active_threads(bot, guild: discord.Guild): reason = "Thread Manager" - r = Route( - "GET", - "/guilds/{guild_id}/threads/active", - guild_id=guild.id, - ) + r = Route("GET", "/guilds/{guild_id}/threads/active", guild_id=guild.id,) res = await bot.http.request(r, reason=reason) diff --git a/threadrotate/discord_thread_feature.py b/threadrotate/discord_thread_feature.py index facb0f8..5abe91c 100644 --- a/threadrotate/discord_thread_feature.py +++ b/threadrotate/discord_thread_feature.py @@ -4,11 +4,7 @@ from discord.http import Route async def create_thread( - bot, - channel: discord.TextChannel, - name: str, - archive: int = 1440, - message: discord.Message = None, + bot, channel: discord.TextChannel, name: str, archive: int = 1440, message: discord.Message = None, ): """ Creates a new thread in the channel from the message @@ -28,10 +24,8 @@ async def create_thread( 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 + if archive > 10080: + archive = 10080 reason = "Thread Rotation" fields = {"name": name, "auto_archive_duration": archive} @@ -44,20 +38,14 @@ async def create_thread( message_id=message.id, ) else: - r = Route( - "POST", - "/channels/{channel_id}/threads", - channel_id=channel.id, - ) + fields["type"] = 11 + 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 = [], + bot, thread_id: int, content: str, mention_roles: list = [], ): """ Send a message in a thread, allowing pings for roles @@ -71,13 +59,9 @@ async def send_thread_message( Returns: int: ID of the new message """ - fields = {"content": content, "allowed_mentions": {"parse": ["roles"], "roles": mention_roles}} + fields = {"content": content, "allowed_mentions": {"roles": mention_roles}} - r = Route( - "POST", - "/channels/{channel_id}/nessages", - channel_id=thread_id, - ) + r = Route("POST", "/channels/{channel_id}/messages", channel_id=thread_id,) return (await bot.http.request(r, json=fields))["id"] @@ -92,12 +76,7 @@ async def add_user_thread(bot, channel: int, member: discord.Member): """ reason = "Thread Manager" - r = Route( - "POST", - "/channels/{channel_id}/thread-members/{user_id}", - channel_id=channel, - user_id=member.id, - ) + 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) @@ -115,11 +94,7 @@ async def get_active_threads(bot, guild: discord.Guild): reason = "Thread Manager" - r = Route( - "GET", - "/guilds/{guild_id}/threads/active", - guild_id=guild.id, - ) + r = Route("GET", "/guilds/{guild_id}/threads/active", guild_id=guild.id,) res = await bot.http.request(r, reason=reason) diff --git a/threadrotate/info.json b/threadrotate/info.json index e2df4c1..dac6bd3 100644 --- a/threadrotate/info.json +++ b/threadrotate/info.json @@ -1,8 +1,11 @@ { - "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"] -} + "author": [ + "brandons209" + ], + "install_msg": "Thank you for installing my cog!", + "name": "Thread Rotator", + "description": "Allows for automatic thread rotation given a list of topics. Useful for roleplaying, general chats, topic discussions, etc.", + "tags": [ + "Threads" + ] +} \ No newline at end of file diff --git a/threadrotate/thread_rotate.py b/threadrotate/thread_rotate.py index a136fc7..4d39a67 100644 --- a/threadrotate/thread_rotate.py +++ b/threadrotate/thread_rotate.py @@ -1,15 +1,17 @@ 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 * +from .discord_thread_feature import send_thread_message, create_thread +from .time_utils import parse_time, parse_timedelta + +from datetime import datetime +from datetime import timedelta class ThreadRotate(commands.Cog): @@ -23,6 +25,7 @@ class ThreadRotate(commands.Cog): default_channel = { "topics": {}, + "topic_threads": {}, "ping_roles": [], "rotation_interval": 10080, "rotate_on": None, @@ -30,6 +33,78 @@ class ThreadRotate(commands.Cog): } self.config.register_channel(**default_channel) + self.task = asyncio.create_task(self.thread_rotation_task()) + + def cog_unload(self): + if self.task is not None: + self.task.cancel() + return super().cog_unload()() + + async def thread_rotation_task(self): + await self.bot.wait_until_ready() + + while True: + for guild in self.bot.guilds: + for channel in guild.text_channels: + topics = await self.config.channel(channel).topics() + if not topics: + continue + rotate_on = datetime.fromtimestamp(await self.config.channel(channel).rotate_on()) + if datetime.now() > rotate_on: + await self.rotate_thread(channel) + await asyncio.sleep(1) + + await asyncio.sleep(60) + + async def rotate_thread(self, channel: discord.TextChannel): + topics = await self.config.channel(channel).topics() + topic_threads = await self.config.channel(channel).topic_threads() + ping_roles = await self.config.channel(channel).ping_roles() + rotation = timedelta(seconds=await self.config.channel(channel).rotation_interval()) + rotate_on = datetime.fromtimestamp(await self.config.channel(channel).rotate_on()) + last_topic = await self.config.channel(channel).last_topic() + + # choose new topic + # don't want to choose the last topic, so set it's weight to 0 so it is not choosen + if last_topic is not None: + topics[last_topic] = 0 + + new_topic = random.choices(list(topics.keys()), weights=list(topics.values()), k=1)[0] + + # rotate the thread, create new thread, ping roles, etc + # ping roles + roles = [channel.guild.get_role(r) for r in ping_roles] + roles = [r for r in roles if r is not None] + + role_msg = " ".join([r.mention for r in roles]) + role_msg += f"\n\nHello, a new topic has been set for {channel.mention}: `{new_topic}`" + + # if a thread already exists for the topic, try to send a message to it to unarchive it + new_thread_id = None + if new_topic in topic_threads: + try: + await send_thread_message(self.bot, topic_threads[new_topic], role_msg, mention_roles=ping_roles) + new_thread_id = topic_threads[new_topic] + except discord.HTTPException: # may occur if bot cant unarchive manually archived threads or thread is deleted + try: + new_thread_id = await create_thread(self.bot, channel, new_topic, archive=10080) + except discord.HTTPException: + return + await send_thread_message(self.bot, new_thread_id, role_msg, mention_roles=ping_roles) + else: + try: + new_thread_id = await create_thread(self.bot, channel, new_topic, archive=10080) + except discord.HTTPException: + return + await send_thread_message(self.bot, new_thread_id, role_msg, mention_roles=ping_roles) + + # update next rotation + async with self.config.channel(channel).topic_threads() as topic_threads: + topic_threads[new_topic] = new_thread_id + + await self.config.channel(channel).rotate_on.set(int((rotate_on + rotation).timestamp())) + await self.config.channel(channel).last_topic.set(new_topic) + @commands.group(name="rotate") @commands.guild_only() @checks.admin() @@ -39,16 +114,111 @@ class ThreadRotate(commands.Cog): """ pass + @thread_rotate.command(name="manual") + async def thread_rotate_manual(self, ctx, channel: discord.TextChannel): + """ + Manually rotate a thread topic + """ + current = await self.config.channel(channel).topics() + if not current: + await ctx.send(error("That channel has not been setup for thread rotation!"), delete_after=30) + return + + await self.rotate_thread(channel) + await ctx.tick() + + @thread_rotate.command(name="interval") + async def thread_rotate_interval(self, ctx, channel: discord.TextChannel, interval: str): + """ + Modify the rotation interval for a thread rotation + + The channel must of already been setup for thread rotation + + This will apply on the next thread rotation for the channel! + """ + current = await self.config.channel(channel).topics() + if not current: + await ctx.send(error("That channel has not been setup for thread rotation!"), delete_after=30) + return + + interval = parse_timedelta(interval.strip()) + if interval is None: + await ctx.send(error("Invalid time interval, please try again!"), delete_after=60) + return + + await self.config.channel(channel).rotation_interval.set(interval.total_seconds()) + await ctx.tick() + + @thread_rotate.command(name="roles") + async def thread_rotate_roles(self, ctx, channel: discord.TextChannel, *roles: discord.Role): + """ + Modify the ping roles for a thread rotation + + The channel must of already been setup for thread rotation + """ + current = await self.config.channel(channel).topics() + if not current: + await ctx.send(error("That channel has not been setup for thread rotation!"), delete_after=30) + return + + await self.config.channel(channel).ping_roles.set([r.id for r in roles]) + await ctx.tick() + + @thread_rotate.command(name="topics") + async def thread_rotate_topics(self, ctx, channel: discord.TextChannel, *, topics: str = None): + """ + Modify topics for thread rotation. + + The channel must of already been setup for thread rotation + """ + current = await self.config.channel(channel).topics() + if not current: + await ctx.send(error("That channel has not been setup for thread rotation!"), delete_after=30) + return + + if topics is None: + await ctx.send(info(f"{channel.mention}'s topics:")) + topic_msg = "Topics:\n" + for topic, weight in current.items(): + topic_msg += f"{topic}: {weight}\n" + await ctx.send(box(topic_msg), delete_after=300) + + return + + topics = topics.split("\n") + parsed_topics = {} + for topic in topics: + topic = topic.split(":") + try: + if len(topic) > 2: + parsed_topics[":".join(topic[0:-1])] = float(topic[-1]) + else: + parsed_topics[topic[0]] = float(topic[-1]) + except: + await ctx.send( + error( + "Please make sure to use the correct format, every topic and weight should be split by a `:` and the weight should be a single decimal value." + ), + delete_after=60, + ) + return + + await self.config.channel(channel).topics.set(parsed_topics) + await ctx.tick() + @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 + now = datetime.now() + 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)" - ) + ), + delete_after=300, ) pred = MessagePredicate.same_context(ctx) @@ -66,7 +236,8 @@ class ThreadRotate(commands.Cog): 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)" - ) + ), + delete_after=300, ) pred = MessagePredicate.same_context(ctx) @@ -76,15 +247,25 @@ class ThreadRotate(commands.Cog): await ctx.send(error("Took too long, cancelling setup!"), delete_after=30) return - date = parse_time(msg.content.strip()) + if msg.content.strip().lower() == "now": + date = datetime.now() + else: + date = parse_time(msg.content.strip()) + if date is None: await ctx.send(error("Invalid date, please run setup again!"), delete_after=60) return + if date < now: + await ctx.send( + error("Invalid date, the date must be in the future! Please run the setup again."), delete_after=60 + ) + 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." - ) + "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. If you do not want to ping any roles type `next` or `no`." + ), + delete_after=300, ) pred = MessagePredicate.same_context(ctx) @@ -94,23 +275,27 @@ class ThreadRotate(commands.Cog): 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 + if msg.content.strip().lower() != "no" and msg.content.strip().lower() != "next": + 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."), delete_after=60) + return - role_objs.append(channel) + role_objs.append(role) + else: + role_objs = [] 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" - ) + ), + delete_after=300, ) await ctx.send(box("topic name: weight_value\ntopic 2 name: weight_value\ntopic 3 name: weight_value")) @@ -123,9 +308,64 @@ class ThreadRotate(commands.Cog): topics = msg.content.split("\n") parsed_topics = {} - # TODO error checking for topic in topics: topic = topic.split(":") - parsed_topics[topic[0]] = float(topic[1]) + try: + if len(topic) > 2: + parsed_topics[":".join(topic[0:-1])] = float(topic[-1]) + else: + parsed_topics[topic[0]] = float(topic[-1]) + + if float(topic[-1]) < 0: + raise ValueError() + except: + await ctx.send( + error( + "Please make sure to use the correct format, every topic and weight should be split by a `:` and the weight should be a single decimal value greater than or equal to 0." + ), + delete_after=60, + ) + return + + topic_msg = "Topics:\n" + for topic, weight in parsed_topics.items(): + topic_msg += f"{topic}: {weight}\n" + + await ctx.send( + info(f"Please review the settings for thread rotation on channel {channel.mention}:"), delete_after=300, + ) + await ctx.send( + box( + f"Rotation interval: {humanize_timedelta(seconds=interval.total_seconds())}\n\nRotation Start: {date}\n\nPing roles: {humanize_list([r.name for r in role_objs])}" + ), + delete_after=300, + ) + await ctx.send( + box(topic_msg), delete_after=300, + ) + + await ctx.send("Type yes to confirm the thread rotation, type no to cancel thread rotation setup.") + + pred = MessagePredicate.yes_or_no(ctx) + + try: + msg = await self.bot.wait_for("message", check=pred, timeout=240) + except asyncio.TimeoutError: + await ctx.send(error("Took too long, cancelling setup!"), delete_after=30) + return + + if not pred.result: + await ctx.send(info("Cancelled setup."), delete_after=60) + return + + # setup the channel + await self.config.channel(channel).topics.set(parsed_topics) + await self.config.channel(channel).ping_roles.set([r.id for r in role_objs]) + await self.config.channel(channel).rotation_interval.set(interval.total_seconds()) + await self.config.channel(channel).rotate_on.set(int(date.timestamp())) + + await ctx.send( + info(f"Thread rotation setup! The first rotation will start at "), + delete_after=60, + ) - 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 index a4fe990..1761cb0 100644 --- a/threadrotate/time_utils.py +++ b/threadrotate/time_utils.py @@ -1,15 +1,13 @@ import pytz import re -from datetime import datetime as dt +from datetime import datetime, timedelta 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?))?", @@ -24,7 +22,7 @@ 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) + tzdate = pytz.timezone(zone).localize(datetime.utcnow(), is_dst=None) except pytz.NonExistentTimeError: pass else: @@ -38,7 +36,7 @@ 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) + ret = ret.astimezone(str(datetime.now().astimezone().tzinfo)) return ret @@ -46,10 +44,10 @@ def parse_time_naive(datetimestring: str): return parser.parse(datetimestring) -def parse_timedelta(argument: str) -> Optional[relativedelta]: +def parse_timedelta(argument: str) -> Optional[timedelta]: 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 timedelta(**params) return None