add thread rotate, update namechange

This commit is contained in:
Brandon Silva 2022-10-20 13:11:03 -04:00
parent a4c6cad1dc
commit 8270153fae
7 changed files with 319 additions and 111 deletions

View file

@ -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):

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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"
]
}

View file

@ -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 <t:{int(date.timestamp())}>"),
delete_after=60,
)
await ctx.send(info(f"Please review the settings for thread rotation on channel {channel.mention}"))

View file

@ -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<years>\d+?)\s?(years?|y))?",
r"((?P<months>\d+?)\s?(months?|mt))?",
r"((?P<weeks>\d+?)\s?(weeks?|w))?",
r"((?P<days>\d+?)\s?(days?|d))?",
r"((?P<hours>\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