2022-10-20 17:06:05 +13:00
import asyncio
import discord
import random
2022-10-26 19:58:21 +13:00
import matplotlib . pyplot as plt
import pandas as pd
2022-10-20 17:06:05 +13:00
from typing import Optional , Literal
2022-10-26 19:58:21 +13:00
from io import BytesIO
2022-10-20 17:06:05 +13:00
from redbot . core import Config , checks , commands
from redbot . core . utils . chat_formatting import *
from redbot . core . utils . predicates import MessagePredicate
2022-10-21 06:11:03 +13:00
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
2022-10-20 17:06:05 +13:00
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 " : { } ,
2022-10-21 06:11:03 +13:00
" topic_threads " : { } ,
2022-10-20 17:06:05 +13:00
" ping_roles " : [ ] ,
" rotation_interval " : 10080 ,
" rotate_on " : None ,
" last_topic " : None ,
}
self . config . register_channel ( * * default_channel )
2022-10-21 06:11:03 +13:00
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 ( ) )
2022-11-09 08:13:29 +13:00
rotate_on = datetime . now ( )
2022-10-21 06:11:03 +13:00
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 \n Hello, 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 )
2022-10-20 17:06:05 +13:00
@commands.group ( name = " rotate " )
@commands.guild_only ( )
@checks.admin ( )
async def thread_rotate ( self , ctx ) :
"""
Manage thread rotations per channel
"""
pass
2022-10-26 19:58:21 +13:00
@thread_rotate.command ( name = " simulation " )
async def thread_rotate_simulation ( self , ctx , channel : discord . TextChannel ) :
"""
Run a simulation using the settings for the channel to see how often topics are chosen .
"""
topics = await self . config . channel ( channel ) . topics ( )
if not topics :
await ctx . send ( error ( " That channel has not been setup for thread rotation! " ) , delete_after = 30 )
return
topic_names = [ k + f " : { v } " for k , v in topics . items ( ) ]
dist = random . choices ( topic_names , weights = list ( topics . values ( ) ) , k = 10000 * len ( topics ) )
# make graph and send it
fontsize = 30
fig = plt . figure ( figsize = ( 50 , 20 + 10 * ( len ( topics ) % 10 ) ) )
# define graph and table save paths
save_path = BytesIO ( )
pd . Series ( dist ) . value_counts ( sort = False ) . plot ( kind = " barh " )
# make graph look nice
plt . title (
f " Simulation for { channel } with { len ( topics ) } unique topics and { 10000 * len ( topics ) } rotations " ,
fontsize = fontsize ,
)
plt . xlabel ( " # of times chosen " , fontsize = fontsize )
plt . ylabel ( " Topics and Weights " , fontsize = fontsize )
plt . xticks ( fontsize = fontsize )
plt . yticks ( fontsize = fontsize )
plt . grid ( True )
fig . tight_layout ( )
fig . savefig ( save_path , dpi = fig . dpi )
plt . close ( )
save_path . seek ( 0 )
files = [ discord . File ( save_path , filename = " graph.png " ) ]
await ctx . send ( files = files )
save_path . close ( )
2022-10-21 06:11:03 +13:00
@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 " )
2022-10-23 16:10:06 +13:00
async def thread_rotate_topics ( self , ctx , channel : discord . TextChannel ) :
2022-10-21 06:11:03 +13:00
"""
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
2022-10-23 16:10:06 +13:00
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 "
2022-10-26 19:58:21 +13:00
for page in pagify ( topic_msg ) :
await ctx . send ( box ( page ) , delete_after = 300 )
2022-10-21 06:11:03 +13:00
2022-10-23 16:10:06 +13:00
await ctx . send (
info (
" Please list the thread topics and their selection weights. \n The weight is how likely the topic will be choosen. \n A weight of `1` means it will not be choosen more or less than other topics. \n A 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. \n A weight greater than 1 means it will be that times more likely to be choosen. \n \n For 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 over others. \n \n Please use this format for listing the weights: \n "
) ,
delete_after = 300 ,
)
msg = await ctx . send (
box ( " topic name: weight_value \n topic 2 name: weight_value \n topic 3 name: weight_value " )
+ " \n \n You can send as many messages as needed, when you are done, type `done`. "
)
topic_msg = " "
while msg . content . lower ( ) != " done " :
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
topic_msg + = msg . content + " \n "
2022-10-21 06:11:03 +13:00
2022-10-23 16:10:06 +13:00
topics = topic_msg . strip ( ) . split ( " \n " ) [ : - 1 ] # remove done from end
2022-10-21 06:11:03 +13:00
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 ] )
2022-10-23 16:10:06 +13:00
if float ( topic [ - 1 ] ) < 0 :
raise ValueError ( )
2022-10-21 06:11:03 +13:00
except :
await ctx . send (
error (
2022-10-26 19:58:21 +13:00
" 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. Topic {topic} caused this error. "
2022-10-21 06:11:03 +13:00
) ,
delete_after = 60 ,
)
return
await self . config . channel ( channel ) . topics . set ( parsed_topics )
2022-10-26 19:58:21 +13:00
await ctx . send ( info ( " Topics changed successfully! " ) , delete_after = 60 )
2022-10-21 06:11:03 +13:00
2022-10-23 16:10:06 +13:00
@thread_rotate.command ( name = " clear " )
async def thread_rotate_clear ( self , ctx , channel : discord . TextChannel ) :
"""
Clear a channel ' s thread rotation settings
"""
await ctx . send (
warning ( f " Are you sure you want to delete all settings for { channel . mention } ? This cannot be reversed. " ) ,
delete_after = 31 ,
)
pred = MessagePredicate . yes_or_no ( ctx )
try :
await self . bot . wait_for ( " message " , check = pred , timeout = 30 )
except asyncio . TimeoutError :
await ctx . send ( error ( " Took too long, cancelling clear! " ) , delete_after = 30 )
return
if not pred . result :
await ctx . send ( info ( " Cancelling clear. " ) , delete_after = 30 )
return
await self . config . channel ( channel ) . topics . clear ( )
await self . config . channel ( channel ) . ping_roles . clear ( )
await self . config . channel ( channel ) . rotation_interval . clear ( )
await self . config . channel ( channel ) . rotate_on . clear ( )
await self . config . channel ( channel ) . last_topic . clear ( )
await self . config . channel ( channel ) . topic_threads . clear ( )
await ctx . send ( info ( f " Settings for { channel . mention } cleared. " ) , delete_after = 30 )
2022-10-20 17:06:05 +13:00
@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
2022-10-21 06:11:03 +13:00
now = datetime . now ( )
2022-10-20 17:06:05 +13:00
await ctx . send (
info (
" Welcome to the thread rotation setup wizard! \n \n First, please specifiy the rotation interval. Rotation intervals can be formatted as follows: \n \t 5 minutes \n \t 1 minute 30 seconds \n \t 1 hour \n \t 2 days \n \t 30 days \n \t 5h30m \n \t (etc) "
2022-10-21 06:11:03 +13:00
) ,
delete_after = 300 ,
2022-10-20 17:06:05 +13:00
)
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 \n Now, please specify the date and time to start rotation. You can say `now` to start rotation as soon as setup is complete. \n \n Valid date formats are: \n \t February 14 at 6pm EDT \n \t 2019-04-13 06:43:00 PST \n \t 01/20/18 at 21:00:43 \n \t (etc) "
2022-10-21 06:11:03 +13:00
) ,
delete_after = 300 ,
2022-10-20 17:06:05 +13:00
)
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
2022-10-21 06:11:03 +13:00
if msg . content . strip ( ) . lower ( ) == " now " :
date = datetime . now ( )
else :
date = parse_time ( msg . content . strip ( ) )
2022-10-20 17:06:05 +13:00
if date is None :
await ctx . send ( error ( " Invalid date, please run setup again! " ) , delete_after = 60 )
return
2022-10-21 06:11:03 +13:00
if date < now :
await ctx . send (
error ( " Invalid date, the date must be in the future! Please run the setup again. " ) , delete_after = 60
)
2022-10-20 17:06:05 +13:00
await ctx . send (
info (
2022-10-21 06:11:03 +13:00
" Great, next step is to list all roles that should be pinged and added to each thread when it rotates. \n \n List each role **seperated by a comma `,`**. \n You 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 ,
2022-10-20 17:06:05 +13:00
)
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
2022-10-21 06:11:03 +13:00
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
2022-10-20 17:06:05 +13:00
2022-10-21 06:11:03 +13:00
role_objs . append ( role )
else :
role_objs = [ ]
2022-10-20 17:06:05 +13:00
await ctx . send (
info (
2022-10-23 16:10:06 +13:00
" Final step is to list the thread topics and their selection weights. \n The weight is how likely the topic will be choosen. \n A weight of `1` means it will not be choosen more or less than other topics. \n A 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. \n A weight greater than 1 means it will be that times more likely to be choosen. \n \n For 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 over others. \n \n Please use this format for listing the weights: \n "
2022-10-21 06:11:03 +13:00
) ,
delete_after = 300 ,
2022-10-20 17:06:05 +13:00
)
2022-10-23 16:10:06 +13:00
msg = await ctx . send (
box ( " topic name: weight_value \n topic 2 name: weight_value \n topic 3 name: weight_value " )
+ " \n \n You can send as many messages as needed, when you are done, type `done`. "
)
2022-10-20 17:06:05 +13:00
2022-10-23 16:10:06 +13:00
topic_msg = " "
while msg . content . lower ( ) != " done " :
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
topic_msg + = msg . content + " \n "
2022-10-20 17:06:05 +13:00
2022-10-23 16:10:06 +13:00
topics = topic_msg . strip ( ) . split ( " \n " ) [ : - 1 ] # remove done from end
2022-10-20 17:06:05 +13:00
parsed_topics = { }
for topic in topics :
topic = topic . split ( " : " )
2022-10-21 06:11:03 +13:00
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 (
2022-10-26 19:58:21 +13:00
" 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. Topic {topic} caused this error. "
2022-10-21 06:11:03 +13:00
) ,
delete_after = 60 ,
)
return
topic_msg = " Topics: \n "
for topic , weight in parsed_topics . items ( ) :
topic_msg + = f " { topic } : { weight } \n "
await ctx . send (
2022-10-23 16:10:06 +13:00
info ( f " Please review the settings for thread rotation on channel { channel . mention } : " ) ,
delete_after = 300 ,
2022-10-21 06:11:03 +13:00
)
await ctx . send (
box (
f " Rotation interval: { humanize_timedelta ( seconds = interval . total_seconds ( ) ) } \n \n Rotation Start: { date } \n \n Ping roles: { humanize_list ( [ r . name for r in role_objs ] ) } "
) ,
delete_after = 300 ,
)
2022-10-26 19:58:21 +13:00
for page in pagify ( topic_msg ) :
await ctx . send (
box ( page ) ,
delete_after = 300 ,
)
2022-10-21 06:11:03 +13:00
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 ,
)