├── LICENSE ├── README.md ├── Threadstorm.py └── cogs ├── create_category.py ├── editthread.py ├── errors.py ├── lock_thread.py ├── makethread.py ├── settings.py ├── updates.py └── utils.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mailstorm-ctrl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Threadstorm [![Discord Bots](https://top.gg/api/widget/status/617376702187700224.svg)](https://top.gg/bot/617376702187700224) 2 | An attempt to bring forum functionality to Discord. Including thread locking, category creation for organization and thread clean-up. 3 | 4 | # Usage example: 5 | ![](https://threadstorm.app/assets/DEMO.gif) 6 | 7 | # Invite this bot to your server: 8 | https://threadstorm.app/invite 9 | ##### Why it needs the permissions it request: 10 | `Manage Channel`: This is needed so it can create channels. When you invite this bot to your server, it will create a category named "threads" which is the default location all threads will be put. This also allows the bot to lock/unlock threads. 11 | 12 | `Manage Messages`: When a thread is created, it will pin the original post (In the channel the bot makes). For custom categories, it will delete commands used to create threads to keep the channel clean. 13 | 14 | `Manage Roles`: Needed to lock/unlock threads. 15 | 16 | `View Audit Log`: The bot will attempt to move channels/threads back into their respective channels if they weren't deleted properly. 17 | 18 | # Support server 19 | Join this server to report issues or get help with the bot. Alternatively, join to talk with other people. 20 | 21 | https://threadstorm.app/support 22 | 23 | ## Known issues: 24 | Sometimes, the bot will not post a 24 hour warning if a thread is about to expire. 25 | -------------------------------------------------------------------------------- /Threadstorm.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import asyncpg 3 | import asyncio 4 | import json 5 | import os 6 | from datetime import datetime 7 | from dotenv import load_dotenv 8 | from discord.ext import commands 9 | 10 | # Known issues: 11 | # Sometimes it wont post the warning for a channel delete Cause: I blame rate limits 12 | # Sometimes it wont delete a channel Cause: Above 13 | 14 | def prefix_callable(bot_client, message): 15 | try: 16 | return bot_client.cache[message.guild.id]['prefix'] 17 | except: 18 | return '.' 19 | 20 | bot = commands.Bot(command_prefix=prefix_callable, case_insensitive=True) 21 | bot.remove_command('help') 22 | 23 | @bot.event 24 | async def on_ready(): 25 | print(f"Logged in as: {bot.user}") 26 | active_threads = 0 27 | for k,v in bot.cache.items(): 28 | active_threads += len(v['active_threads']) 29 | activity = discord.Activity(type=discord.ActivityType.watching, 30 | name=f"{active_threads} threads | {len(bot.guilds)} guilds") 31 | await bot.change_presence(activity=activity) 32 | 33 | @bot.command(hidden=True) 34 | @commands.is_owner() 35 | async def reload_cog(ctx, cog): 36 | bot.reload_extension(f"cogs.{cog}") 37 | await ctx.message.add_reaction('👍') 38 | 39 | 40 | load_dotenv('threadstorm.env') 41 | bot_token = os.getenv('TOKEN') 42 | db = os.getenv('DATABASE') 43 | edb = os.getenv('ERROR_DB') 44 | user = os.getenv('DB_USER') 45 | password = os.getenv('DB_PASS') 46 | 47 | DB_SETTINGS = { 48 | 'host': 'localhost', 49 | 'database': db, 50 | 'user': user, 51 | 'password': password, 52 | } 53 | 54 | cogs = ['cogs.makethread', 55 | 'cogs.editthread', 56 | 'cogs.lock_thread', 57 | 'cogs.create_category', 58 | 'cogs.utils', 59 | 'cogs.settings', 60 | 'cogs.errors', 61 | 'cogs.updates' 62 | ] 63 | 64 | 65 | for cog in cogs: 66 | print(f'loaded cog: {cog}') 67 | bot.load_extension(cog) 68 | 69 | async def write_to_db(guild): 70 | #Write everything in cache to database. By guild only, obviously 71 | async with bot.db.acquire() as con: 72 | await con.execute('INSERT INTO ts_data(guild_id, data) VALUES($1, $2) ON CONFLICT (guild_id) DO UPDATE SET data=$2', guild.id, json.dumps(bot.cache[guild.id], default=str)) 73 | 74 | async def populate_cache(): 75 | """Populate the bots cache. Convert everything just in case""" 76 | bot.cache = {} 77 | async with bot.db.acquire() as con: 78 | results = await con.fetch('SELECT * FROM ts_data;') 79 | for data in results: 80 | guild_data = json.loads(data[1]) 81 | try: 82 | bot.cache[data[0]] = {} 83 | bot.cache[data[0]]['prefix'] = guild_data.get('prefix') 84 | bot.cache[data[0]]['default_thread_channel'] = int(guild_data.get('default_thread_channel')) 85 | bot.cache[data[0]]['settings'] = {} 86 | bot.cache[data[0]]['settings']['role_required'] = bool(guild_data.get('settings').get('role_required')) 87 | bot.cache[data[0]]['settings']['allowed_roles'] = [int(r_id) for r_id in guild_data.get('settings').get('allowed_roles')] 88 | bot.cache[data[0]]['settings']['TTD'] = int(guild_data.get('settings').get('TTD')) 89 | bot.cache[data[0]]['settings']['cleanup'] = bool(guild_data.get('settings').get('cleanup')) 90 | bot.cache[data[0]]['settings']['admin_roles'] = [int(r_id) for r_id in guild_data.get('settings').get('admin_roles')] 91 | bot.cache[data[0]]['settings']['admin_bypass'] = bool(guild_data.get('settings').get('admin_bypass')) 92 | bot.cache[data[0]]['settings']['cooldown'] = {} 93 | bot.cache[data[0]]['settings']['cooldown']['enabled'] = bool(guild_data.get('settings').get('cooldown').get('enabled')) 94 | bot.cache[data[0]]['settings']['cooldown']['rate'] = int(guild_data.get('settings').get('cooldown').get('rate')) 95 | bot.cache[data[0]]['settings']['cooldown']['per'] = int(guild_data.get('settings').get('cooldown').get('rate')) 96 | bot.cache[data[0]]['settings']['cooldown']['bucket'] = guild_data.get('settings').get('cooldown').get('bucket') 97 | bot.cache[data[0]]['active_threads'] = {} 98 | for thread,value in guild_data.get('active_threads').items(): 99 | bot.cache[data[0]]['active_threads'][int(thread)] = {} 100 | try: 101 | bot.cache[data[0]]['active_threads'][int(thread)]['last_message_time'] = datetime.strptime(value.get('last_message_time'),"%Y-%m-%d %H:%M:%S.%f") 102 | except: 103 | bot.cache[data[0]]['active_threads'][int(thread)]['last_message_time'] = datetime.strptime(f"{value.get('last_message_time')}.000111","%Y-%m-%d %H:%M:%S.%f") 104 | bot.cache[data[0]]['active_threads'][int(thread)]['keep'] = bool(value.get('keep')) 105 | bot.cache[data[0]]['active_threads'][int(thread)]['author_id'] = int(value.get('author_id')) 106 | if value.get('original_msg_id') is None: 107 | bot.cache[data[0]]['active_threads'][int(thread)]['original_msg_id'] = 0 108 | else: 109 | bot.cache[data[0]]['active_threads'][int(thread)]['original_msg_id'] = int(value.get('original_msg_id')) 110 | bot.cache[data[0]]['active_threads'][int(thread)]['json'] = bool(value.get('json')) 111 | 112 | bot.cache[data[0]]['custom_categories'] = {} 113 | for channel,value in guild_data.get('custom_categories').items(): 114 | bot.cache[data[0]]['custom_categories'][int(channel)] = int(value) 115 | except Exception as e: 116 | print(e) 117 | continue 118 | 119 | db_loop = asyncio.get_event_loop() 120 | 121 | bot.db = db_loop.run_until_complete(asyncpg.create_pool(**DB_SETTINGS, max_inactive_connection_lifetime=480)) 122 | bot.error_color = 0xcc0000 123 | bot.success_color = 0x00b359 124 | bot.warning_color = 0xffb366 125 | bot.DEFAULT_COLOR = 0x00ace6 126 | bot.write_db = write_to_db 127 | 128 | bot.cooldowns = {} 129 | for guild in bot.guilds: 130 | bot.cooldowns[guild.id] = {} 131 | 132 | loop = asyncio.get_event_loop() 133 | loop.run_until_complete(populate_cache()) 134 | 135 | bot.run(bot_token, reconnect=True) 136 | -------------------------------------------------------------------------------- /cogs/create_category.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import asyncio 3 | from discord.ext import commands 4 | 5 | class TB_Category_Creation(commands.Cog): 6 | 7 | def __init__(self, bot): 8 | self.bot = bot 9 | 10 | @commands.command(name='ccategory', aliases=['tcreate'], description='Create a custom category for the server') 11 | @commands.has_permissions(manage_channels=True) 12 | @commands.bot_has_permissions(manage_channels=True) 13 | @commands.guild_only() 14 | async def create_category(self, ctx, *, category_name): 15 | category = f'{category_name[:96]}...' 16 | thread_category = await ctx.guild.create_category(category, overwrites=ctx.channel.overwrites) 17 | custom_category_hub_channel = await ctx.guild.create_text_channel(f'Create threads here!', category=thread_category) 18 | self.bot.cache[ctx.guild.id]["custom_categories"][thread_category.id] = custom_category_hub_channel.id 19 | 20 | hub_channel_embed = discord.Embed(title="THREAD HUB CHANNEL", description=f'Please create threads for the category `{category_name[:96]}` here.', color=self.bot.DEFAULT_COLOR) 21 | hub_channel_embed.set_author(name=ctx.guild.name, icon_url=ctx.guild.icon_url) 22 | 23 | confirmation = discord.Embed(title="Category created!", description=f'Please use {custom_category_hub_channel.mention} to place threads into this category. I\'ve left a reminder in there too.', color=self.bot.DEFAULT_COLOR) 24 | confirmation.set_author(name=ctx.guild.name, icon_url=ctx.guild.icon_url) 25 | confirmation.set_footer(text='You can rename this channel to whatever you wish. You can also move the category around.') 26 | 27 | pin_this = await custom_category_hub_channel.send(embed=hub_channel_embed) 28 | await pin_this.pin() 29 | await ctx.send(embed=confirmation) 30 | await self.bot.write_db(ctx.guild) 31 | 32 | @commands.command(name='dcategory', aliases=['tdelete_category', 'tdc'], description='Delete a custom category') 33 | @commands.has_permissions(manage_guild=True) 34 | @commands.bot_has_permissions(manage_channels=True) 35 | @commands.guild_only() 36 | async def delete_category(self, ctx): 37 | """This command will wipe out a custom category. Meaning all threads will be deleted. Will need to verify before any action is taken.""" 38 | category = ctx.channel.category 39 | error_msg = "This command cannot be ran here. Sorry. Please run it in a channel that is a part of one of your custom categories." 40 | if category is None: 41 | await ctx.send(error_msg) 42 | return 43 | if category.id not in self.bot.cache[ctx.guild.id]["custom_categories"]: 44 | await ctx.send(error_msg) 45 | return 46 | 47 | confirm = await ctx.send(f"Are you sure you want to do this? This will delete `{ctx.channel.category.name}` and __***ALL***__ channels that belong to `{ctx.channel.category.name}` ***(Even channels I did not make!)***.\nTo confirm this action, please react with the appropriate reaction.") 48 | await confirm.add_reaction('✔️') 49 | def check(reaction, user): 50 | return str(reaction.emoji) == '✔️' and reaction.message == confirm and user == ctx.author and reaction.message.channel.category.id in self.bot.cache[ctx.guild.id]["custom_categories"] 51 | try: 52 | react, user = await self.bot.wait_for('reaction_add', check=check, timeout=60) 53 | except asynio.TimeoutError: 54 | await ctx.send("Timeout reached. This action times out after 60 seconds.") 55 | return 56 | else: 57 | for channel in ctx.channel.category.channels: 58 | await channel.delete(reason='PURGE CATEGORY') 59 | self.bot.cache[ctx.guild.id]['active_threads'].pop(channel.id, None) 60 | self.bot.cache[ctx.guild.id]["custom_categories"].pop(ctx.channel.category.id, None) 61 | await self.bot.write_db(ctx.guild) 62 | 63 | def setup(bot): 64 | bot.add_cog(TB_Category_Creation(bot)) 65 | -------------------------------------------------------------------------------- /cogs/editthread.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import typing 3 | import json 4 | from datetime import datetime,timezone 5 | from discord.ext import tasks, commands 6 | 7 | class TB_Editing(commands.Cog): 8 | 9 | def __init__(self, bot): 10 | self.bot = bot 11 | 12 | async def embed_check(self, embed): 13 | bad_fields = [] 14 | if len(embed) > 6000: 15 | bad_fields.append("Length of embed to long. Total length cannot exceed 6000 characters.") 16 | if len(embed.title) > 256: 17 | bad_fields.append("Title is over 256 characters long.") 18 | if len(embed.description) > 2048: 19 | bad_fields.append("Description is over 2048 characters long.") 20 | if len(embed.fields) > 25: 21 | bad_fields.append("Field count is over 25.") 22 | for field in embed.fields: 23 | if len(field.name) > 256: 24 | bad_fields.append(f"Field name {field.name} exceeds 256 characters.") 25 | if len(field.value) > 1024: 26 | bad_fields.append(f"Field value under {field.name} exceeds 1024 characters.") 27 | 28 | if bad_fields: 29 | embed = discord.Embed(title="Errors in embed", description="It seems like your thread will be to long, or a general failure has occured.\nCorrect these errors then try again.", color=self.bot.warning_color) 30 | embed.add_field(name="Errors:", value='\n'.join(bad_fields), inline=False) 31 | return [embed, False] 32 | 33 | return [embed, True] 34 | 35 | async def parse_json(self, json_file_bytes, ctx, mod_edit): 36 | """Read the json file into a dict. Check for errors and length restrictions. return errors or return the formatted embed""" 37 | error = [] 38 | try: 39 | json_obj = json.loads(json_file_bytes.decode('utf8')) 40 | except Exception as e: 41 | error.append('Unable to convert JSON file. Please make sure it\'s formatted correctly.') 42 | error.append(str(e)) 43 | embed = discord.Embed(title="Bad format", description="See below for a detailed error.", color=self.bot.warning_color) 44 | embed.add_field(name="Errors:", value='\n'.join(error), inline=False) 45 | return [embed, False] 46 | 47 | try: 48 | embeds = [] 49 | for embed in json_obj['embeds']: 50 | embeds.append(discord.Embed.from_dict(embed)) 51 | except Exception as e: 52 | embed = discord.Embed(title="Bad format", description="See below for a detailed error.", color=self.bot.warning_color) 53 | embed.add_field(name="Error:", value=f"Unable to read JSON. Make sure it's in the format Discord expects. You can use [this](https://discohook.org) website for creating embeds. Just beaware the content field wont be posted!\nError: {e}", inline=False) 54 | return [embed, False] 55 | 56 | bad_fields = [] 57 | for embed in embeds: 58 | if len(embed) > 6000: 59 | bad_fields.append("Length of embed to long. Total length cannot exceed 6000 characters.") 60 | if len(embed.title) > 256: 61 | bad_fields.append("Title is over 256 characters long.") 62 | if len(embed.description) > 2048: 63 | bad_fields.append("Description is over 2048 characters long.") 64 | if len(embed.fields) > 25: 65 | bad_fields.append("Field count is over 25.") 66 | for field in embed.fields: 67 | if len(field.name) > 256: 68 | bad_fields.append(f"Field name {field.name} exceeds 256 characters.") 69 | if len(field.value) > 1024: 70 | bad_fields.append(f"Field value under {field.name} exceeds 1024 characters.") 71 | 72 | if bad_fields: 73 | embed = discord.Embed(title="Errors in JSON", description="It seems like your JSON file is to long in some spots, or a general failure has occured.\nCorrect these errors then try again.", color=self.bot.warning_color) 74 | embed.add_field(name="Errors:", value='\n'.join(bad_fields), inline=False) 75 | return [embed, False] 76 | 77 | return [embed, True] 78 | 79 | @commands.command(name='editthread',aliases=['tedit'], description='Editing an existing thread.') 80 | @commands.guild_only() 81 | async def edit(self, ctx, part=None, *, appended_text: typing.Optional[str] = 'Blank'): 82 | """This lets server mods and thread owners to edit their own thread. This only APPENDS text (Except the title). It does not modify!""" 83 | 84 | # Check to see if this is even in the cache 85 | if ctx.channel.id not in self.bot.cache[ctx.guild.id]['active_threads']: 86 | embed = discord.Embed(title="Channel not in-cache or not ran in thread channel", colour=self.bot.warning_color, description="Please run this in the channel you wish to edit. If you are, there seems to be an error and this thread is no longer being recognized.") 87 | embed.set_thumbnail(url=ctx.guild.icon_url) 88 | embed.set_author(name="Threadstorm", url="https://github.com/Mailstorm-ctrl/Threadstorm", icon_url=self.bot.user.avatar_url) 89 | await ctx.send(embed=embed) 90 | return 91 | 92 | valid_args = ['title','description','body','picture','json'] 93 | # See if user supplied valid arguments. 94 | # Display an error if they didn't 95 | if part is None or part not in valid_args: 96 | embed = discord.Embed(title="Missing arguments", colour=self.bot.warning_color, description=f"It seems you are missing some arguments. Valid arguments are: `{', '.join(valid_args)}`") 97 | embed.set_thumbnail(url=ctx.guild.icon_url) 98 | embed.set_author(name="Threadstorm", url="https://github.com/Mailstorm-ctrl/Threadstorm", icon_url=self.bot.user.avatar_url) 99 | await ctx.send(embed=embed) 100 | return 101 | 102 | # See to see the thread was made with a json file and the user didn't supply a json file 103 | if self.bot.cache[ctx.guild.id]['active_threads'][ctx.channel.id]['json'] and not ctx.message.attachments: 104 | embed = discord.Embed(title="JSON-based Thread", colour=self.bot.warning_color, description="A JSON file was used to create this thread. Therefore, another json file is needed in order to edit this thread. Please supply a valid json file.") 105 | embed.set_thumbnail(url=ctx.guild.icon_url) 106 | embed.set_author(name="Threadstorm", url="https://github.com/Mailstorm-ctrl/Threadstorm", icon_url=self.bot.user.avatar_url) 107 | embed.set_footer(text="Reminder! One embed per thread. If you supply a file with multiple embeds, only the last one will be kept.") 108 | await ctx.send(embed=embed) 109 | return 110 | 111 | # Check for permission to edit thread 112 | mod_edit = False 113 | if ctx.author.id != self.bot.cache[ctx.guild.id]['active_threads'][ctx.channel.id]['author_id'] and ctx.author.guild_permissions.manage_channels: 114 | mod_edit = True 115 | if ctx.author.id != self.bot.cache[ctx.guild.id]['active_threads'][ctx.channel.id]['author_id'] and not ctx.author.guild_permissions.manage_channels: 116 | embed = discord.Embed(title="403 -- FORBIDDEN", colour=self.bot.warning_color, description="You do not have the proper permission to edit this thread. You either need to have the manage_message guild permission or be the owner of the thread.") 117 | embed.set_thumbnail(url=ctx.guild.icon_url) 118 | embed.set_author(name="Threadstorm", url="https://github.com/Mailstorm-ctrl/Threadstorm", icon_url=self.bot.user.avatar_url) 119 | await ctx.send(embed=embed) 120 | return 121 | 122 | thread_op_msg = await ctx.channel.fetch_message(self.bot.cache[ctx.guild.id]['active_threads'][ctx.channel.id]['original_msg_id']) 123 | 124 | # Check to see if user supplied a json file and provided the right argument 125 | if self.bot.cache[ctx.guild.id]['active_threads'][ctx.channel.id]['json'] and ctx.message.attachments and part.lower() == 'json': 126 | json_bytes = await ctx.message.attachments[0].read(use_cached=False) 127 | new_embed = await self.parse_json(json_bytes, ctx, mod_edit) 128 | if new_embed[1] == False: 129 | await ctx.send(embed=new_embed[0]) 130 | return 131 | 132 | new_embed[0].set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) 133 | if len(thread_op_msg.embeds[0].footer.text) == 13: 134 | if mod_edit: 135 | new_embed[0].set_footer(text=f"{thread_op_msg.embeds[0].footer.text} MOD > {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 136 | else: 137 | new_embed[0].set_footer(text=f"{thread_op_msg.embeds[0].footer.text} {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 138 | else: 139 | if mod_edit: 140 | new_embed[0].set_footer(text=f"{thread_op_msg.embeds[0].footer.text} | MOD > {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 141 | else: 142 | new_embed[0].set_footer(text=f"{thread_op_msg.embeds[0].footer.text} | {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 143 | 144 | await thread_op_msg.edit(embed=new_embed[0]) 145 | embed = discord.Embed(title="Thread Updated", colour=self.bot.success_color, description="The original thread has been replaced with the file specified.") 146 | embed.set_thumbnail(url=ctx.guild.icon_url) 147 | embed.set_author(name="Threadstorm", url="https://github.com/Mailstorm-ctrl/Threadstorm", icon_url=self.bot.user.avatar_url) 148 | await ctx.send(embed=embed) 149 | return 150 | 151 | thread_embed = thread_op_msg.embeds[0] 152 | 153 | # Section to edit the thread title 154 | # If the invoking user doesn't own the thread, append the MOD edit tag 155 | if part.lower() == 'title': 156 | thread_embed.title = appended_text 157 | if len(thread_embed.footer.text) == 13: 158 | if mod_edit: 159 | thread_embed.set_footer(text=f"{thread_embed.footer.text} MOD > {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 160 | else: 161 | thread_embed.set_footer(text=f"{thread_embed.footer.text} {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 162 | else: 163 | if mod_edit: 164 | thread_embed.set_footer(text=f"{thread_embed.footer.text} | MOD > {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 165 | else: 166 | thread_embed.set_footer(text=f"{thread_embed.footer.text} | {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 167 | result = await self.embed_check(thread_embed) 168 | if result[1]: 169 | await thread_op_msg.edit(embed=thread_embed) 170 | await ctx.channel.edit(name=appended_text) 171 | embed = discord.Embed(title="Thread Updated", colour=self.bot.success_color, description="The thread title has been replaced.") 172 | embed.set_thumbnail(url=ctx.guild.icon_url) 173 | embed.set_author(name="Threadstorm", url="https://github.com/Mailstorm-ctrl/Threadstorm", icon_url=self.bot.user.avatar_url) 174 | await ctx.send(embed=embed) 175 | return 176 | else: 177 | errors = '\n'.join(result[1]) 178 | await ctx.send(f"Bad embed. Errors: {errors}") 179 | return 180 | 181 | # Section to edit the thread body 182 | # If the invoking user doesn't own the thread, append the MOD edit tag 183 | if part.lower() in ['description', 'body']: 184 | thread_embed.description += f"\nEDIT: {appended_text}" 185 | if len(thread_embed.footer.text) == 13: 186 | if mod_edit: 187 | thread_embed.set_footer(text=f"{thread_embed.footer.text} MOD > {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 188 | else: 189 | thread_embed.set_footer(text=f"{thread_embed.footer.text} {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 190 | else: 191 | if mod_edit: 192 | thread_embed.set_footer(text=f"{thread_embed.footer.text} | MOD > {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 193 | else: 194 | thread_embed.set_footer(text=f"{thread_embed.footer.text} | {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 195 | result = await self.embed_check(thread_embed) 196 | if result[1]: 197 | await thread_op_msg.edit(embed=thread_embed) 198 | embed = discord.Embed(title="Thread Updated", colour=self.bot.success_color, description="Your requested addition as been appended.") 199 | embed.set_thumbnail(url=ctx.guild.icon_url) 200 | embed.set_author(name="Threadstorm", url="https://github.com/Mailstorm-ctrl/Threadstorm", icon_url=self.bot.user.avatar_url) 201 | await ctx.send(embed=embed) 202 | return 203 | else: 204 | errors = '\n'.join(result[1]) 205 | await ctx.send(f"Bad embed. Errors: {errors}") 206 | return 207 | 208 | # Section to edit the threads picture 209 | # If the invoking user doesn't own the thread, append the MOD edit tag 210 | if part.lower() == 'picture': 211 | if ctx.message.attachments: 212 | thread_embed.set_image(url=ctx.message.attachments[0].url) 213 | if len(thread_embed.footer.text) == 13: 214 | if mod_edit: 215 | thread_embed.set_footer(text=f"{thread_embed.footer.text} MOD > {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 216 | else: 217 | thread_embed.set_footer(text=f"{thread_embed.footer.text} {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 218 | else: 219 | if mod_edit: 220 | thread_embed.set_footer(text=f"{thread_embed.footer.text} | MOD > {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 221 | else: 222 | thread_embed.set_footer(text=f"{thread_embed.footer.text} | {datetime.utcnow().strftime('%m/%d, %H:%M %p')}") 223 | await thread_op_msg.edit(embed=thread_embed) 224 | embed = discord.Embed(title="Thread Updated", colour=self.bot.success_color, description="Thread picture updated.") 225 | embed.set_thumbnail(url=ctx.guild.icon_url) 226 | embed.set_author(name="Threadstorm", url="https://github.com/Mailstorm-ctrl/Threadstorm", icon_url=self.bot.user.avatar_url) 227 | await ctx.send(embed=embed) 228 | else: 229 | await ctx.send("Please upload the picture you're trying to display with your command.") 230 | return 231 | 232 | if part.lower() == 'json': 233 | await ctx.send("This thread wasn't made with a json file. Therefore, you cannot use this argument. Sorry!") 234 | return 235 | 236 | 237 | @commands.command(name='keep', aliases=['tkeep'], description='Toggle the delete flag') 238 | @commands.has_permissions(manage_channels=True) 239 | @commands.guild_only() 240 | async def keep(self, ctx, thread: typing.Optional[discord.TextChannel] = None): 241 | """Pretty simple. Toggles the value that determines if a thread should be created or not""" 242 | thread_link = None 243 | if thread is not None and thread.id in self.bot.cache[ctx.guild.id]['active_threads']: 244 | self.bot.cache[ctx.guild.id]['active_threads'][thread.id]['keep'] = not self.bot.cache[ctx.guild.id]['active_threads'][thread.id]['keep'] 245 | thread_link = thread.id 246 | else: 247 | if ctx.channel.id in self.bot.cache[ctx.guild.id]['active_threads']: 248 | self.bot.cache[ctx.guild.id]['active_threads'][ctx.channel.id]['keep'] = not self.bot.cache[ctx.guild.id]['active_threads'][ctx.channel.id]['keep'] 249 | thread_link = ctx.channel.id 250 | else: 251 | await ctx.send("Unable to modified channel. Channel ID not found. A database error has occured or this channel is not managed by me.") 252 | return 253 | thread_id = thread_link 254 | thread_link = f"https://discord.com/channels/{ctx.guild.id}/{thread_link}" 255 | embed=discord.Embed(title="Thread settings updated", description=f"You have changed the **keep** flag for [this]({thread_link}) thread.\nThe current value is: `{self.bot.cache[ctx.guild.id]['active_threads'][thread_id]['keep']}`", color=self.bot.success_color) 256 | embed.set_author(name=ctx.bot.user.name, icon_url=ctx.bot.user.avatar_url) 257 | embed.set_thumbnail(url=ctx.guild.icon_url) 258 | embed.set_footer(text="A True value means the thread will not be marked for deletion due to inactivity. A False value means a thread will get marked for deletion if it's inactive to long.") 259 | await ctx.send(embed=embed) 260 | await self.bot.write_db(ctx.guild) 261 | 262 | 263 | def setup(bot): 264 | bot.add_cog(TB_Editing(bot)) 265 | -------------------------------------------------------------------------------- /cogs/errors.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import traceback 3 | import random 4 | import string 5 | from time import gmtime, strftime 6 | from datetime import datetime 7 | from discord.ext import commands 8 | 9 | class TB_errors(commands.Cog): 10 | 11 | def __init__(self, bot): 12 | self.bot = bot 13 | 14 | async def error_db(self, error_code, error_type): 15 | print(error_code) 16 | print(error_type) 17 | #async with self.bot.db.acquire() as con: 18 | # await con.execute('INSERT INTO errors(error_code, error_type, date) VALUES ($1, $2, $3);', error_code, error_type, datetime.utcnow()) 19 | 20 | @commands.Cog.listener() 21 | async def on_command_error(self, ctx, error): 22 | if isinstance(error, commands.CommandNotFound): 23 | return 24 | elif isinstance(error, commands.NoPrivateMessage): 25 | embed=discord.Embed(title="Command not DM-able", description=f"The command: `{ctx.prefix}{ctx.invoked_with}` cannot be ran in a private message.", color=self.bot.error_color) 26 | embed.set_author(name= ctx.bot.user.name, url="https://github.com/Mailstorm-ctrl/Threadstorm", icon_url=ctx.bot.user.avatar_url) 27 | await ctx.send(embed=embed) 28 | return 29 | elif isinstance(error, commands.BadUnionArgument): 30 | embed=discord.Embed(title="Failed to convert an argument!", description=f"{ctx.author.mention}, something hapened with your command.\nThe parameter: {error.param.name} failed to convert.\nThe converter(s): {error.converters} failed.", color=self.bot.error_color) 31 | embed.set_author(name=ctx.bot.user.name, url="https://github.com/Mailstorm-ctrl/Threadstorm", icon_url=ctx.bot.user.avatar_url) 32 | await ctx.send(embed=embed) 33 | return 34 | elif isinstance(error, commands.MissingRequiredArgument): 35 | embed=discord.Embed(title="Missing argument!", description=f"{ctx.author.mention}, it appears you forgot to specify the {error.param.name} parameter. Please do so next time.", color=self.bot.error_color) 36 | embed.set_author(name=ctx.bot.user.name, url="https://github.com/Mailstorm-ctrl/Threadstorm", icon_url=ctx.bot.user.avatar_url) 37 | await ctx.send(f"There's a missing argument here.") 38 | return 39 | elif isinstance(error, commands.CommandOnCooldown): 40 | # Bypass cooldown if allowed 41 | if any(item in [role.id for role in ctx.author.roles] for item in ctx.bot.cache[ctx.guild.id]['settings']["admin_roles"]) and ctx.bot.cache[ctx.guild.id]['settings']['admin_bypass']: 42 | await ctx.reinvoke(restart=True) 43 | return 44 | embed=discord.Embed(title="You're on cooldown!", description=f"{ctx.author.mention}, you are currently on cooldown.\nTry again in: {strftime('%H Hours and %M minutes.', gmtime(error.retry_after))}", color=self.bot.error_color) 45 | embed.set_author(name=ctx.bot.user.name, url="https://github.com/Mailstorm-ctrl/Threadstorm", icon_url=ctx.bot.user.avatar_url) 46 | await ctx.send(embed=embed) 47 | return 48 | elif isinstance(error, commands.MissingPermissions): 49 | embed=discord.Embed(title="Missing permissions!", description=f"{ctx.author.mention}, it appears *you* don't have permission to run this command.\nYou need permission(s):\n{', '.join(error.missing_perms)}", color=self.bot.error_color) 50 | embed.set_author(name=ctx.bot.user.name, url="https://github.com/Mailstorm-ctrl/Threadstorm", icon_url=ctx.bot.user.avatar_url) 51 | await ctx.send(embed=embed) 52 | return 53 | elif isinstance(error, commands.BotMissingPermissions): 54 | embed=discord.Embed(title="Missing permissions!", description=f"{ctx.author.mention}, I'm missing the permissions needed to run this command.\nI need the permission(s):\n{', '.join(error.missing_perms)}", color=self.bot.error_color) 55 | embed.set_author(name=ctx.bot.user.name, url="https://github.com/Mailstorm-ctrl/Threadstorm", icon_url=ctx.bot.user.avatar_url) 56 | await ctx.send(embed=embed) 57 | return 58 | elif isinstance(error, commands.CheckFailure): 59 | embed=discord.Embed(title="Check failure!", description=f"{ctx.author.mention}, the command: `{ctx.prefix}{ctx.invoked_with}` failed to execute. Possible causes:\nMissing required permission\n\nTrying to execute in a channel I don't manage", color=self.bot.error_color) 60 | embed.set_author(name=ctx.bot.user.name, url="https://github.com/Mailstorm-ctrl/Threadstorm", icon_url=ctx.bot.user.avatar_url) 61 | await ctx.send(embed=embed) 62 | return 63 | else: 64 | error_code = ''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(6)) 65 | traceback_str = traceback.format_exception(type(error), error, error.__traceback__) 66 | new_trace = traceback_str[0] 67 | for line in traceback_str[1:]: 68 | new_trace += line.replace('\\n', '\n') 69 | embed=discord.Embed(title=f"Oh no! You (probably didn't) break it!", description=f'Here is the traceback:```python\n{new_trace}```\n\nHere is context info:```Author = {ctx.author.id}\nChannel = {ctx.channel.id}\nGuild = {ctx.guild.id}\nCommand = {ctx.message.content}```', color=self.bot.error_color) 70 | embed.set_author(name=ctx.bot.user.name, icon_url=ctx.bot.user.avatar_url, url="https://github.com/Mailstorm-ctrl/Threadstorm") 71 | embed.set_footer(text=f"Reportable error code: {error_code}") 72 | try: 73 | await ctx.send(embed=embed) 74 | except: 75 | await ctx.send(f"A generic error as occured. However, I'm unable to post a traceback. If you'd like to report this, please include this in your error report:\n{error_code}\n\nHere is context info:```Author = {ctx.author.id}\nChannel = {ctx.channel.id}\nGuild = {ctx.guild.id}\nCommand = {ctx.message.content}```") 76 | await self.error_db(error_code, traceback.format_exc()) 77 | print(error) 78 | return 79 | def setup(bot): 80 | bot.add_cog(TB_errors(bot)) 81 | -------------------------------------------------------------------------------- /cogs/lock_thread.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import typing 3 | from discord.ext import commands 4 | from datetime import datetime 5 | 6 | class TB_Thread_Locking(commands.Cog): 7 | 8 | def __init__(self, bot): 9 | self.bot = bot 10 | 11 | async def if_thread(ctx): 12 | return ctx.channel.id in ctx.bot.cache[ctx.guild.id]['active_threads'] 13 | 14 | @commands.command(name='lock', aliases=['tlock'], description='Lock a thread') 15 | @commands.has_permissions(manage_channels=True, manage_roles=True) 16 | @commands.guild_only() 17 | @commands.check(if_thread) 18 | async def lock_thread(self, ctx, reason: typing.Optional[str] = "No reason provided"): 19 | if isinstance(ctx, discord.TextChannel): 20 | # Strictly used for the channel checker. ctx will never be a channel object except in this instance 21 | channel = ctx 22 | author = self.bot.user 23 | else: 24 | channel = ctx.channel 25 | author = ctx.author 26 | self.bot.cache[ctx.guild.id]['active_threads'][channel.id]['permissions'] = {obj.id:[channel.overwrites.get(obj).pair()[0].value, channel.overwrites.get(obj).pair()[1].value] for obj in channel.overwrites} 27 | if len(self.bot.cache[ctx.guild.id]['active_threads'][channel.id]['permissions']) == 0: 28 | await channel.set_permissions(ctx.guild.me, send_messages=True) 29 | await channel.set_permissions(ctx.guild.default_role, send_messages=False) 30 | else: 31 | ow = ctx.channel.overwrites 32 | 33 | for obj,value in ow.items(): 34 | if isinstance(obj, discord.Member): 35 | if (not obj.permissions_in(channel).manage_messages or not obj.permissions_in(channel).manage_channels) and obj != ctx.guild.me: 36 | value.update(send_messages=False) 37 | else: 38 | if (not obj.permissions.manage_messages or not obj.permissions.manage_channels) and obj != ctx.guild.me: 39 | value.update(send_messages=False) 40 | 41 | await ctx.channel.edit(overwrites=ow) 42 | await channel.set_permissions(ctx.guild.me, send_messages=True) 43 | embed=discord.Embed(title=":lock: THREAD LOCKED :lock:", description=f"This thread has been **__locked__**!\nResponsible Moderator: **{author.name}**\nReason for lock: **{reason}**", color=self.bot.warning_color) 44 | embed.set_author(name=ctx.guild.name, icon_url=ctx.guild.icon_url) 45 | embed.set_footer(text=f"Thread locked on: {datetime.now().strftime('%B %d at %H:%M')}") 46 | await ctx.send(embed=embed) 47 | 48 | @commands.command(name='unlock',aliases=['tunlock'], description='Unlocks a thread') 49 | @commands.has_permissions(manage_messages=True) 50 | @commands.bot_has_permissions(manage_channels=True, manage_roles=True) 51 | @commands.guild_only() 52 | @commands.check(if_thread) 53 | async def unlock_thread(self, ctx): 54 | """The opposite of lock. This will restore the permission structure to what it was when `lock` was invoked.""" 55 | permissions = self.bot.cache[ctx.guild.id]['active_threads'][ctx.channel.id]['permissions'] 56 | if len(permissions) == 0: 57 | overwrite = discord.PermissionsOverwrite() 58 | overwrite.send_messages = None 59 | await ctx.channel.set_permissions(ctx.guild.default_role, overwrite=overwrite) 60 | else: 61 | ow = {} 62 | for obj_id in permissions: 63 | obj = ctx.guild.get_role(int(obj_id)) 64 | if obj is None: 65 | obj = ctx.guild.get_member(obj_id) 66 | allow = discord.Permissions(permissions=permissions.get(obj_id)[0]) 67 | deny = discord.Permissions(permissions=permissions.get(obj_id)[1]) 68 | ow[obj] = discord.PermissionOverwrite().from_pair(allow,deny) 69 | #await ctx.channel.set_permissions(obj, overwrite=discord.PermissionOverwrite().from_pair(allow,deny)) 70 | await ctx.channel.edit(overwrites=ow) 71 | embed=discord.Embed(title=":unlock: THREAD UNLOCKED :unlock:", description=f"This thread is now **__unlocked__**!\nResponsible Moderator: **{ctx.author.name}**", color=self.bot.success_color) 72 | embed.set_author(name=ctx.guild.name, icon_url=ctx.guild.icon_url) 73 | embed.set_footer(text=f"Thread unlocked on: {datetime.now().strftime('%B %d at %H:%M')}") 74 | await ctx.send(embed=embed) 75 | 76 | @commands.command(name='delete', aliases=['tdelete'], description='Delete a singular thread') 77 | @commands.guild_only() 78 | @commands.check(if_thread) 79 | async def delete_thread(self, ctx): 80 | """A command that deletes a single thread. This command must be invoked within a thread and only the owner of the thread or members with the `manage_channels` permission can invoke this command.""" 81 | if ctx.author.id == self.bot.cache[ctx.guild.id]['active_threads'][ctx.channel.id]['author_id']: 82 | await ctx.channel.delete() 83 | self.bot.cache[ctx.guild.id]['active_threads'].pop(ctx.channel.id, None) 84 | elif ctx.author.permissions_in(ctx.channel).manage_channels: 85 | await ctx.channel.delete() 86 | self.bot.cache[ctx.guild.id]['active_threads'].pop(ctx.channel.id, None) 87 | else: 88 | await ctx.send('You do not own this thread or do not have permission to delete channels.') 89 | return 90 | await self.bot.write_db(ctx.guild) 91 | 92 | def setup(bot): 93 | bot.add_cog(TB_Thread_Locking(bot)) 94 | -------------------------------------------------------------------------------- /cogs/makethread.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import asyncio 3 | import typing 4 | import json 5 | from datetime import datetime 6 | from discord.ext import commands 7 | 8 | ## Will probably just make my own system for cooldowns 9 | # class CustomCooldown: 10 | # def __init__(self, rate: int, per: float, alter_rate: int, alter_per: float, bucket: commands.BucketType, ctx, *, elements): 11 | # self.elements = elements 12 | # self.default_mapping = commands.CooldownMapping.from_cooldown(rate, per, bucket) 13 | # self.altered_mapping = commands.CooldownMapping.from_cooldown(alter_rate, alter_per, bucket) 14 | # rate = ctx.bot.cache[ctx.guild.id]["settings"]["cooldown"]["rate"] 15 | # per = ctx.bot.cache[ctx.guild.id]["settings"]["cooldown"]["per"] 16 | # bucket_str = ctx.bot.cache[ctx.guild.id]["settings"]["cooldown"]["bucket"] 17 | # if bucket_str == "user": 18 | # pass 19 | # if bucket_str == "guild": 20 | # pass 21 | # if bucket_str == "channel": 22 | # pass 23 | # if bucket_str == "member": 24 | # pass 25 | # if bucket_str == "category": 26 | # pass 27 | # if bucket_str == "role": 28 | # pass 29 | 30 | 31 | # def __call__(self, ctx: commands.Context): 32 | # key = self.altered_mapping._bucket_key(ctx.message) 33 | # if key in self.elements: 34 | # bucket = self.altered_mapping.get_bucket(ctx.message) 35 | # else: 36 | # bucket = self.default_mapping.get_bucket(ctx.message) 37 | # retry_after = bucket.update_rate_limit() 38 | # if retry_after: 39 | # raise commands.CommandOnCooldown(bucket, retry_after) 40 | # return True 41 | 42 | class TB_Thread_Creation(commands.Cog): 43 | 44 | def __init__(self, bot): 45 | self.bot = bot 46 | 47 | async def has_required_role(ctx): 48 | """Check to see if guild has role requirements, then see if author meets requirements""" 49 | if ctx.bot.cache[ctx.guild.id]['settings']["role_required"]: 50 | return any(item in [role.id for role in ctx.author.roles] for item in ctx.bot.cache[ctx.guild.id]['settings']["allowed_roles"]) 51 | else: 52 | return True 53 | 54 | 55 | async def parse_json(self, json_file_bytes, ctx): 56 | """Read the json file into a dict. Check for errors and length restrictions. return errors or return the formatted embed""" 57 | error = [] 58 | try: 59 | json_obj = json.loads(json_file_bytes.decode('utf8')) 60 | except Exception as e: 61 | error.append('Unable to convert JSON file. Please make sure it\'s formatted correctly.') 62 | error.append(str(e)) 63 | embed = discord.Embed(title="Bad format", description="See below for a detailed error.", color=self.bot.warning_color) 64 | embed.add_field(name="Errors:", value='\n'.join(error), inline=False) 65 | return [embed, False] 66 | 67 | try: 68 | embeds = [] 69 | for embed in json_obj['embeds']: 70 | embeds.append(discord.Embed.from_dict(embed)) 71 | except Exception as e: 72 | embed = discord.Embed(title="Bad format", description="See below for a detailed error.", color=self.bot.warning_color) 73 | embed.add_field(name="Error:", value=f"Unable to read JSON. Make sure it's in the format Discord expects. You can use [this](https://discohook.org) website for creating embeds. Just beaware the content field wont be posted!\nError: {e}", inline=False) 74 | return [embed, False] 75 | 76 | bad_fields = [] 77 | for embed in embeds: 78 | if len(embed) > 6000: 79 | bad_fields.append("Length of embed to long. Total length cannot exceed 6000 characters.") 80 | if len(embed.title) > 256: 81 | bad_fields.append("Title is over 256 characters long.") 82 | if len(embed.description) > 2048: 83 | bad_fields.append("Description is over 2048 characters long.") 84 | if len(embed.fields) > 25: 85 | bad_fields.append("Field count is over 25.") 86 | for field in embed.fields: 87 | if len(field.name) > 256: 88 | bad_fields.append(f"Field name {field.name} exceeds 256 characters.") 89 | if len(field.value) > 1024: 90 | bad_fields.append(f"Field value under {field.name} exceeds 1024 characters.") 91 | # Need to change some properties to keep consistency 92 | embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) 93 | embed.set_footer(text="Edit History:") 94 | 95 | if bad_fields: 96 | embed = discord.Embed(title="Errors in JSON", description="It seems like your JSON file is to long in some spots, or a general failure has occured.\nCorrect these errors then try again.", color=self.bot.warning_color) 97 | embed.add_field(name="Errors:", value='\n'.join(bad_fields), inline=False) 98 | return [embed, False] 99 | 100 | return [embed, True] 101 | 102 | 103 | 104 | async def create_thread(self, data): 105 | embed = discord.Embed(title=data['title'], description=data['description'], color=self.bot.DEFAULT_COLOR) 106 | embed.set_author(name=data['author'].name, icon_url=data['author'].avatar_url) 107 | embed.set_footer(text="Edit History:") 108 | return embed 109 | 110 | 111 | 112 | def manual_creation(self, ctx, step): 113 | if step == 1: 114 | embed=discord.Embed(title="What do you want the title of the thread to be? (The title is this part)", description='Type your title out. Keep it brief please!', color=self.bot.DEFAULT_COLOR) 115 | embed.set_author(name=ctx.guild.name, icon_url=ctx.guild.icon_url) 116 | embed.set_footer(text='Type abort to stop thread creation.') 117 | elif step == 2: 118 | embed=discord.Embed(title="OK, we got your title done. Now what do you want to talk about?", description='Your thoughts and questions will be here.', color=self.bot.DEFAULT_COLOR) 119 | embed.set_author(name=ctx.guild.name, icon_url=ctx.guild.icon_url) 120 | embed.set_footer(text='Type abort to stop thread creation.') 121 | return embed 122 | 123 | def fill_cache(self, thread_channel, ctx, thread_op, category, json): 124 | self.bot.cache[ctx.guild.id]['active_threads'][thread_channel.id] = {} 125 | self.bot.cache[ctx.guild.id]['active_threads'][thread_channel.id]['last_message_time'] = datetime.now() 126 | self.bot.cache[ctx.guild.id]['active_threads'][thread_channel.id]['keep'] = False 127 | self.bot.cache[ctx.guild.id]['active_threads'][thread_channel.id]['author_id'] = ctx.author.id 128 | self.bot.cache[ctx.guild.id]['active_threads'][thread_channel.id]['original_msg_id'] = thread_op.id 129 | self.bot.cache[ctx.guild.id]['active_threads'][thread_channel.id]['json'] = json 130 | self.bot.cache[ctx.guild.id]['active_threads'][thread_channel.id]['category'] = category.id 131 | 132 | @commands.command(name='makethread',aliases=['tmake'], description='Make a new thread') 133 | @commands.cooldown(2, 7200,type=commands.BucketType.member) 134 | @commands.bot_has_permissions(manage_messages=True, manage_channels=True) 135 | @commands.guild_only() 136 | @commands.check(has_required_role) 137 | # Need to use tuple for consume rest instead. Easier, efficient 138 | async def manual_create(self, ctx, *thread): 139 | """Create a thread. You can ether run the command by itself and follow the prompts, invoke the command with a properly formatted JSON attached, or use the `-t`(title) and `-b`(body) flags to create a thread with one message. """ 140 | if ctx.guild.id not in self.bot.cache: 141 | await ctx.send(f"I don't remember this guild. Perhaps I joined without the proper permissions? Find someone that has the `manage_guild` permission and tell them to run `{ctx.prefix}tsetup`") 142 | 143 | # Try and find the channel used for posting threads in 144 | # If it isn't found, abort the creation 145 | # In a try block incase a server isn't using categories for whatever reason 146 | try: 147 | if ctx.channel.category.id in [channel_id for channel_id in self.bot.cache[ctx.guild.id]["custom_categories"].keys()]: 148 | category = ctx.channel.category 149 | else: 150 | category = ctx.guild.get_channel(self.bot.cache[ctx.guild.id]["default_thread_channel"]) 151 | if category is None: 152 | category = await ctx.guild.fetch_channel(self.bot.cache[ctx.guild.id]["default_thread_channel"]) 153 | if category is None: 154 | await ctx.send(f"No thread category found. Please run {ctx.prefix}tsetup.\n\nBe warned, running this command will essentially wipe the data for this server. Any active threads and custom categories will be forgotten.") 155 | return 156 | except: 157 | pass 158 | 159 | thread_data = {} 160 | thread_data['author'] = ctx.author 161 | messages = [ctx.message] 162 | 163 | # See if user supplied a file, mainly a json used to make their thread. 164 | # Highly customizable 165 | if ctx.message.attachments: 166 | json_bytes = await ctx.message.attachments[0].read(use_cached=False) 167 | thread_op_msg = await self.parse_json(json_bytes, ctx) 168 | if thread_op_msg[1] == False: 169 | await ctx.send(embed=thread_op_msg[0]) 170 | return 171 | thread_channel = await category.create_text_channel(thread_op_msg[0].title, overwrites=ctx.channel.overwrites) 172 | thread_op = await thread_channel.send(embed=thread_op_msg[0]) 173 | await thread_op.pin() 174 | embed = discord.Embed(title="Success!", description=f"Your requested thread was made! It can be found at {thread_channel.mention}", color=self.bot.success_color) 175 | embed.set_footer(text='This thread has the same permissions as this channel does.') 176 | await ctx.send(embed=embed) 177 | self.fill_cache(thread_channel, ctx, thread_op, category, True) 178 | await self.bot.write_db(ctx.guild) 179 | return 180 | 181 | else: 182 | cli = False 183 | if len(thread) > 0: 184 | title = [] 185 | body = [] 186 | track = 0 187 | found = [] 188 | for i,item in enumerate(thread): 189 | if item == "-t": 190 | track += 1 191 | found.append("-t") 192 | # Title flag found. Start at that index and append text until "-b" is hit or out of options 193 | for opt in thread[i+1:]: 194 | if opt == "-b": 195 | break 196 | title.append(opt) 197 | if item == "-b": 198 | track += 1 199 | found.append("-b") 200 | # body flag found. Start at that index and append text until "-t" is hit or out of options 201 | for opt in thread[i+1:]: 202 | if opt == "-t": 203 | break 204 | body.append(opt) 205 | if track < 2: 206 | missing = ["-t", "-b"] 207 | try: 208 | missing.remove(found[0]) 209 | except: 210 | pass 211 | await ctx.send(f"Missing flag(s): `{', '.join(missing)}`\nPlease rerun with all required flags set.") 212 | return 213 | cli = True 214 | thread_data['title'] = ' '.join(title) 215 | thread_data['description'] = ' '.join(body) 216 | 217 | # Go through and create a thread from scratch. 218 | if not cli: 219 | content = [] 220 | abort = 'no' 221 | for i in range(2): 222 | if abort == 'abort' or i > 2: 223 | break 224 | text = await ctx.send(embed=self.manual_creation(ctx,i+1)) 225 | messages.append(text) 226 | try: 227 | msg = await self.bot.wait_for('message', check=lambda m: m.author == ctx.author and m.channel == ctx.channel, timeout=180) 228 | except asyncio.TimeoutError: 229 | await ctx.send("Timeout reached. These messages timeout after 3 minutes. Try typing what you want to say before initiating this command.") 230 | return 231 | else: 232 | messages.append(msg) 233 | abort = msg.content 234 | if i == 0: 235 | thread_data['title'] = msg.content 236 | else: 237 | thread_data['description'] = msg.content 238 | if abort == 'abort': 239 | await ctx.send("Thread creation aborted. User provoked.") 240 | for msg in messages: 241 | await msg.delete() 242 | return 243 | 244 | # Final stage. Create the embed for the thread, post it, pin it, add to cache, add to database [TODO] 245 | embed = await self.create_thread(thread_data) 246 | thread_channel = await category.create_text_channel(thread_data['title'], topic=thread_data['description'], overwrites=ctx.channel.overwrites) 247 | thread_op = await thread_channel.send(embed=embed) 248 | await thread_op.pin() 249 | 250 | if self.bot.cache[ctx.guild.id]['settings']['cleanup']: 251 | for msg in messages: 252 | await msg.delete() 253 | 254 | embed = discord.Embed(title="Success!", description=f"Your requested thread was made! It can be found at {thread_channel.mention}", color=self.bot.success_color) 255 | embed.set_footer(text='This thread has the same permissions as this channel does.') 256 | await ctx.send(embed=embed) 257 | self.fill_cache(thread_channel, ctx, thread_op, category, False) 258 | await self.bot.write_db(ctx.guild) 259 | 260 | 261 | 262 | def setup(bot): 263 | bot.add_cog(TB_Thread_Creation(bot)) 264 | -------------------------------------------------------------------------------- /cogs/settings.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | class TB_Settings(commands.Cog): 5 | 6 | def __init__(self, bot): 7 | self.bot = bot 8 | 9 | @commands.command(name='setup', aliases=['tsetup'], description='Initialize guild') 10 | @commands.has_permissions(manage_guild=True) 11 | @commands.bot_has_permissions(manage_channels=True) 12 | @commands.guild_only() 13 | async def create_guild_join(self, ctx): 14 | """If for some reason your guild was NOT added to the database, this command will force your guild into it.\nWARNING: RUNNING THIS WILL ESSENTIALLY RESET YOUR GUILD. ACTIVE THREADS WILL BE LOST AND WILL NO LONGER BE MANAGED!""" 15 | category = discord.utils.get(ctx.guild.categories, name='THREADS') 16 | if category is None: 17 | try: 18 | category = await ctx.guild.create_category('THREADS', reason='Initial category of threads creation.') 19 | except (discord.HTTPException, discord.InvalidArgument): 20 | await ctx.send("Unable to create channel for threads. Please try creating a category manually and name it `THREADS`, then re-run this command.") 21 | return 22 | self.bot.cache[ctx.guild.id] = { 23 | "prefix" : ".", 24 | "default_thread_channel" : category.id, 25 | "settings" : { 26 | "role_required" : False, 27 | "allowed_roles" : [], 28 | "TTD" : 3, 29 | "cleanup" : True, 30 | "admin_roles" : [], 31 | "admin_bypass" : False, 32 | "cooldown" : { 33 | "enabled": True, 34 | "rate" : 0, 35 | "per" : 0, 36 | "bucket" : "user" 37 | } 38 | }, 39 | "active_threads" : {}, 40 | "custom_categories" : {} 41 | } 42 | await ctx.send('Setup ran. Guild added to database.') 43 | await self.bot.write_db(ctx.guild) 44 | 45 | 46 | 47 | @commands.command(name='prefix', aliases=['tprefix'], description='Sets the custom prefix') 48 | @commands.has_permissions(manage_guild=True) 49 | @commands.guild_only() 50 | async def update_prefix(self, ctx, prefix: str): 51 | """Lets you modify the servers prefix. You must have the `manage_guild` permission to use this.""" 52 | self.bot.cache[ctx.guild.id]['prefix'] = prefix 53 | embed=discord.Embed(title="Guild settings updated", description=f"You have changed the **prefix** for this guild.\nThe current prefix is: `{self.bot.cache[ctx.guild.id]['prefix']}`", color=self.bot.success_color) 54 | embed.set_author(name=ctx.bot.user.name, icon_url=ctx.bot.user.avatar_url) 55 | embed.set_thumbnail(url=ctx.guild.icon_url) 56 | embed.set_footer(text=f"To issue commands, you must now type {self.bot.cache[ctx.guild.id]['prefix']} instead of {ctx.prefix} . Example: {self.bot.cache[ctx.guild.id]['prefix']}tmake") 57 | await self.bot.write_db(ctx.guild) 58 | await ctx.send(embed=embed) 59 | 60 | 61 | 62 | @commands.command(name='timetodead', aliases=['tttd'], description='Sets the period to wait before a thread becomes inactive, in days') 63 | @commands.has_permissions(manage_guild=True) 64 | @commands.guild_only() 65 | async def update_ttd(self, ctx, ttd): 66 | """Change the number of days the bot will wait before marking a channel for deletion. You must have the `manage_guild` permission to use this.""" 67 | self.bot.cache[ctx.guild.id]['settings']['TTD'] = int(ttd) 68 | await self.bot.write_db(ctx.guild) 69 | await ctx.send(f"Inactivity time set to: {ttd} days") 70 | return 71 | 72 | 73 | @commands.command(name='roles', aliases=['troles','trolls'], description='Toggles and adds/removes roles from running `tmake` command.') 74 | @commands.has_permissions(manage_guild=True) 75 | @commands.guild_only() 76 | async def update_roles(self, ctx, *roles: discord.Role): 77 | """Running with no arguments will toggle whether a specific set of roles are required or not. If ran with arguments, the arguments should be role mentions, their names, or the role ID which will add or remove the role (Does not delete or modify server roles).""" 78 | if len(roles) == 0: 79 | self.bot.cache[ctx.guild.id]['settings']['role_required'] = not self.bot.cache[ctx.guild.id]['settings']['role_required'] 80 | embed=discord.Embed(title="Guild settings updated", description=f"You have changed the **roles_required** flag for this guild.\nThe current value is: `{self.bot.cache[ctx.guild.id]['settings']['role_required']}`", color=self.bot.success_color) 81 | embed.set_author(name=ctx.bot.user.name, icon_url=ctx.bot.user.avatar_url) 82 | embed.set_thumbnail(url=ctx.guild.icon_url) 83 | embed.set_footer(text=f"A True value means users need a specific(s) role in order to use {ctx.prefix}tmake. A False value means anyone in your server can make threads.") 84 | await ctx.send(embed=embed) 85 | else: 86 | added = [] 87 | removed = [] 88 | for role in roles: 89 | if role.id in self.bot.cache[ctx.guild.id]['settings']['allowed_roles']: 90 | self.bot.cache[ctx.guild.id]['settings']['allowed_roles'].remove(role.id) 91 | removed.append(f"- {role.name}") 92 | else: 93 | self.bot.cache[ctx.guild.id]['settings']['allowed_roles'].append(role.id) 94 | added.append(f"+ {role.name}") 95 | 96 | if not added: 97 | added.append("+ No roles added!") 98 | if not removed: 99 | removed.append("- No roles removed") 100 | 101 | added_list = "```patch\n" 102 | for role in added: 103 | added_list += f"{role}\n" 104 | added_list += "```" 105 | removed_list = "```patch\n" 106 | for role in removed: 107 | removed_list += f"{role}\n" 108 | removed_list += "```" 109 | 110 | current = [] 111 | for role in self.bot.cache[ctx.guild.id]['settings']['allowed_roles']: 112 | cur = ctx.guild.get_role(role) 113 | current.append(cur.name) 114 | if not current: 115 | current.append("No configured roles.") 116 | 117 | embed = discord.Embed(title=f"{ctx.guild.name} Role Required List", description='The following is a summary of what roles you just added, removed and the current list of allowed roles.', color=self.bot.success_color) 118 | embed.set_author(name=ctx.bot.user.name, icon_url=ctx.bot.user.avatar_url) 119 | embed.set_thumbnail(url=ctx.guild.icon_url) 120 | embed.add_field(name="ADDED", value=added_list, inline=False) 121 | embed.add_field(name="REMOVED", value=removed_list, inline=False) 122 | embed.add_field(name="CURRENT LIST", value=" | ".join(current), inline=False) 123 | embed.set_footer(text="Did you add your admin roles? This setting does not care about role or user permissions!") 124 | await ctx.send(embed=embed) 125 | await self.bot.write_db(ctx.guild) 126 | 127 | 128 | @update_roles.error 129 | async def ur_error(self, ctx, error): 130 | if isinstance(error, commands.BadArgument): 131 | error_str = str(error) 132 | role_name = error_str[6:-12] 133 | message_list = ctx.message.content.split(' ') 134 | arg_list = message_list[1:] 135 | good_roles = [] 136 | for arg in arg_list: 137 | good_roles.append(discord.utils.find(lambda r : r.id == arg or r.name == arg or r.mention == arg, ctx.guild.roles)) 138 | good_roles = [i for i in good_roles if i] 139 | if not good_roles: 140 | await ctx.send("ran out") 141 | 142 | 143 | 144 | @commands.command(name='aroles', aliases=['taroles'], description='Toggles and adds/removes admin roles from bypassing `tmake` command cooldown.') 145 | @commands.has_permissions(manage_guild=True) 146 | @commands.guild_only() 147 | async def update_aroles(self, ctx, *roles: discord.Role): 148 | """Running with no arguments will toggle whether admin roles are activate or not. If ran with arguments, the arguments should be role mentions, their names, or the role ID which will add or remove the role (Does not delete or modify server roles).""" 149 | if len(roles) == 0: 150 | self.bot.cache[ctx.guild.id]['settings']['admin_bypass'] = not self.bot.cache[ctx.guild.id]['settings']['admin_bypass'] 151 | embed=discord.Embed(title="Guild settings updated", description=f"You have changed the **admin_bypass** flag for this guild.\nThe current value is: `{self.bot.cache[ctx.guild.id]['settings']['admin_bypass']}`", color=self.bot.success_color) 152 | embed.set_author(name=ctx.bot.user.name, icon_url=ctx.bot.user.avatar_url) 153 | embed.set_thumbnail(url=ctx.guild.icon_url) 154 | embed.set_footer(text=f"A True value means admin roles can bypass the cooldown on {ctx.prefix}tmake. A False value means admin roles do not get to bypass the cooldown.") 155 | await ctx.send(embed=embed) 156 | else: 157 | added = [] 158 | removed = [] 159 | for role in roles: 160 | if role.id in self.bot.cache[ctx.guild.id]['settings']['admin_roles']: 161 | self.bot.cache[ctx.guild.id]['settings']['admin_roles'].remove(role.id) 162 | removed.append(f"- {role.name}") 163 | else: 164 | self.bot.cache[ctx.guild.id]['settings']['admin_roles'].append(role.id) 165 | added.append(f"+ {role.name}") 166 | 167 | if not added: 168 | added.append("+ No roles added!") 169 | if not removed: 170 | removed.append("- No roles removed") 171 | 172 | added_list = "```patch\n" 173 | for role in added: 174 | added_list += f"{role}\n" 175 | added_list += "```" 176 | removed_list = "```patch\n" 177 | for role in removed: 178 | removed_list += f"{role}\n" 179 | removed_list += "```" 180 | 181 | current = [] 182 | for role in self.bot.cache[ctx.guild.id]['settings']['admin_roles']: 183 | cur = ctx.guild.get_role(role) 184 | current.append(cur.name) 185 | if not current: 186 | current.append("No configured roles.") 187 | 188 | embed = discord.Embed(title=f"{ctx.guild.name} Admin Role List", description='The following is a summary of what roles you just added, removed, and the current list of allowed roles.', color=self.bot.success_color) 189 | embed.add_field(name="ADDED", value=added_list, inline=False) 190 | embed.add_field(name="REMOVED", value=removed_list, inline=False) 191 | embed.add_field(name="CURRENT LIST", value=" | ".join(current), inline=False) 192 | await ctx.send(embed=embed) 193 | await self.bot.write_db(ctx.guild) 194 | 195 | @update_aroles.error 196 | async def ar_error(self, ctx, error): 197 | if isinstance(error, commands.BadArgument): 198 | error_str = str(error) 199 | role_name = error_str[6:-12] 200 | message_list = ctx.message.content.split(' ') 201 | arg_list = message_list[1:] 202 | good_roles = [] 203 | for arg in arg_list: 204 | good_roles.append(discord.utils.find(lambda r : r.id == arg or r.name == arg or r.mention == arg, ctx.guild.roles)) 205 | good_roles = [i for i in good_roles if i] 206 | if not good_roles: 207 | await ctx.send("ran out") 208 | 209 | 210 | @commands.command(name="bypass", aliases=['tbypass'], description='Toggle if admin roles can bypass cooldown or not.') 211 | @commands.has_permissions(manage_guild=True) 212 | @commands.guild_only() 213 | async def update_bypass(self, ctx): 214 | """Toggle the ability to allow admin roles to bypass the cooldown.""" 215 | self.bot.cache[ctx.guild.id]['settings']['admin_bypass'] = not self.bot.cache[ctx.guild.id]['settings']['admin_bypass'] 216 | embed=discord.Embed(title="Guild settings updated", description=f"You have changed the **admin bypass** flag for this guild.\nThe current value is: `{self.bot.cache[ctx.guild.id]['settings']['admin_bypass']}`", color=self.bot.success_color) 217 | embed.set_author(name=ctx.bot.user.name, icon_url=ctx.bot.user.avatar_url) 218 | embed.set_thumbnail(url=ctx.guild.icon_url) 219 | embed.set_footer(text=f"A True value means defined admin roles are able to bypass the cooldown. A False value means they cannot bypass the cooldown.") 220 | await self.bot.write_db(ctx.guild) 221 | await ctx.send(embed=embed) 222 | 223 | @commands.command(name='clean', aliases=['tclean'], description='Toggles the flag that controls if the bot should delete messages used to setup threads.') 224 | @commands.has_permissions(manage_guild=True) 225 | @commands.guild_only() 226 | async def update_cleaning(self, ctx): 227 | """Why you would change this is beyond me. Determines if the bot should delete messages used to create threads.""" 228 | self.bot.cache[ctx.guild.id]['settings']['cleanup'] = not self.bot.cache[ctx.guild.id]['settings']['cleanup'] 229 | embed=discord.Embed(title="Guild settings updated", description=f"You have changed the **cleanup** flag for this guild.\nThe current value is: `{self.bot.cache[ctx.guild.id]['settings']['cleanup']}`", color=self.bot.success_color) 230 | embed.set_author(name=ctx.bot.user.name, icon_url=ctx.bot.user.avatar_url) 231 | embed.set_thumbnail(url=ctx.guild.icon_url) 232 | embed.set_footer(text=f"A True value means commands used to make threads will be deleted when possible. A False value means all messages used to create threads are kept.") 233 | await self.bot.write_db(ctx.guild) 234 | await ctx.send(embed=embed) 235 | 236 | def setup(bot): 237 | bot.add_cog(TB_Settings(bot)) -------------------------------------------------------------------------------- /cogs/updates.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import asyncio 3 | from discord.ext import tasks, commands 4 | 5 | class TB_updates(commands.Cog): 6 | 7 | def __init__(self, bot): 8 | self.bot = bot 9 | self.bot_update_status.start() 10 | 11 | @commands.command(name="update", aliases=['pn', 'patchnotes', 'tupdate'], description="Show the most recent patchnotes") 12 | async def pn(self, ctx): 13 | embed = discord.Embed(title="One Year Later... (Thank You & Patch Notes)", colour=discord.Colour(0xffff), url="https://github.com/Mailstorm-ctrl/Threadstorm", description="It's been about a year since I launched this bot. It's grown far more than I ever imagined it would. I thought maybe it would top out at 60 or 70 servers but it's doubled that and keeps going up. So for that, I thank you all for showing me this bot is useful to your communities.\n\nWith that, I've released a pretty big update. It adds a lot of features a bot of this nature should of had to begin with. So here they are.") 14 | AppInfo = await self.bot.application_info() 15 | embed.set_author(name="Mailstorm", icon_url=AppInfo.owner.avatar_url) 16 | embed.set_thumbnail(url=AppInfo.icon_url) 17 | embed.set_footer(text=f"To learn how to use these new features use the {ctx.prefix}thelp command, read the readme on github, or join the support server") 18 | embed.add_field(name="Patch Notes", value=f"```patch\n+ {ctx.prefix}tmake can accept JSON files to make more customized threads\n\n+ Threads can be made in one message with the {ctx.prefix}tmake command\n\n+ Guilds can now restrict {ctx.prefix}tmake to certain roles only and toggle the restriction\n\n+ Additionally, some roles can be allowed to bypass the cooldown\n\n+ Guilds can choose how long to wait until a thread is marked as \"inactive\"\n\n+ Automatic cleanup of commands and messages used to make a thread. This can be toggled\n\n+ A way to view all of your guilds settings (excluding active threads and categories) in one command\n\n+ Database improvements\n\n+ Optimizations. More cache, less database\n\n+ Deleted categories will attempt to recreate themselves (View_Audit_Log permission needed)\n\n- Cooldowns are still in place until I figure out how to make custom per-guild cooldowns work.```", inline=False) 19 | embed.add_field(name="Potential Issues", value="I essentially rewrote this bot to be more efficient and easier to add and fix features. With this, the old format needed to be converted to the new format. If you find there are any issues with existing threads, please [join the support server](https://discord.com/invite/M8DmU86) and give me some details on what's happening!", inline=False) 20 | await ctx.send(embed=embed) 21 | 22 | @tasks.loop(minutes=15) 23 | async def bot_update_status(self): 24 | await asyncio.sleep(60) 25 | activity = discord.Activity(type=discord.ActivityType.watching, 26 | name=f"Update released! .tupdate") 27 | await self.bot.change_presence(activity=activity) 28 | 29 | @bot_update_status.before_loop 30 | async def wait3(self): 31 | await self.bot.wait_until_ready() 32 | 33 | def setup(bot): 34 | bot.add_cog(TB_updates(bot)) -------------------------------------------------------------------------------- /cogs/utils.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import typing 3 | import copy 4 | import asyncio 5 | from datetime import datetime, timedelta 6 | from discord.ext import tasks, commands 7 | 8 | class TB_Utils(commands.Cog): 9 | 10 | def __init__(self, bot): 11 | self.bot = bot 12 | self.checker.start() 13 | self.update_status.start() 14 | self.integrity_check.start() 15 | 16 | # Maybe some day this will be supported 17 | @commands.Cog.listener() 18 | async def on_guild_channel_delete(self, channel): 19 | """Listen for category delete events and undo the damage if possible.""" 20 | if channel.id in [self.bot.cache[channel.guild.id]['default_thread_channel']] + [channel_id for channel_id in self.bot.cache[channel.guild.id]['custom_categories'].keys()]: 21 | try: 22 | async for entry in channel.guild.audit_logs(limit=10, action=discord.AuditLogAction.channel_delete): 23 | if channel.id == self.bot.cache[channel.guild.id]['default_thread_channel'] and entry.user.id != self.bot.user.id: 24 | new_default = await channel.guild.create_category(name=channel.name, overwrites=channel.overwrites, reason=f"User: {entry.user.name} manually deleted default thread category.") 25 | 26 | for thread in self.bot.cache[channel.guild.id]['active_threads']: 27 | try: 28 | if self.bot.cache[channel.guild.id]['active_threads'][thread]['category'] == channel.id: 29 | channel_obj = self.bot.get_channel(thread) 30 | if channel_obj is None: 31 | continue 32 | await channel_obj.edit(category=new_default, reason=f"User: {entry.user.name} manually deleted default thread category. Fixing...") 33 | self.bot.cache[channel.guild.id]['active_threads'][thread]['category'] = new_default.id 34 | except: 35 | continue 36 | 37 | self.bot.cache[channel.guild.id]['default_thread_channel'] = new_default.id 38 | await self.bot.write_db(channel.guild) 39 | await entry.user.send("It seems like you deleted the category I use to put uncategorized threads in. I've recreated the channel but please don't do this.\n\nIf you want a different name, simply rename the channel. If you don't want people using the channel, restrict my permissions to custom categories only and a channel to issue admin commands in.") 40 | break 41 | 42 | if channel.id in self.bot.cache[channel.guild.id]['custom_categories'] and entry.user.id != self.bot.user.id: 43 | 44 | new_category = await channel.guild.create_category(name=channel.name, overwrites=channel.overwrites, reason=f"User: {entry.user.name} manually deleted category.") 45 | hub_channel = self.bot.get_channel(self.bot.cache[channel.guild.id]['custom_categories'][channel.id]) 46 | if hub_channel is None: 47 | hub_channel = await self.bot.fetch_channel(self.bot.cache[channel.guild.id]['custom_categories'][channel.id]) 48 | 49 | await hub_channel.edit(category=new_category, reason=f"User: {entry.user.name} manually deleted a category. Fixing...") 50 | # Get rid of the old k,v pair and replace with updated values 51 | self.bot.cache[channel.guild.id]['custom_categories'].pop(channel.id, None) 52 | self.bot.cache[channel.guild.id]['custom_categories'][new_category.id] = hub_channel.id 53 | 54 | for thread in self.bot.cache[channel.guild.id]['active_threads']: 55 | try: 56 | if self.bot.cache[channel.guild.id]['active_threads'][thread]['category'] == channel.id: 57 | channel_obj = self.bot.get_channel(thread) 58 | if channel_obj is None: 59 | continue 60 | await channel_obj.edit(category=new_category, reason=f"User: {entry.user.name} manually deleted a category. Fixing...") 61 | self.bot.cache[channel.guild.id]['active_threads'][thread]['category'] = new_category.id 62 | except: 63 | continue 64 | 65 | await self.bot.write_db(channel.guild) 66 | await entry.user.send("It seems like you deleted a category I control. I've gone ahead and undid this but please consider using the proper commands to get rid of a category.") 67 | break 68 | except Exception as e: 69 | # Need a way to warn that a category was deleted and the bot wasn't able to correct it 70 | pass 71 | else: 72 | pass 73 | 74 | @commands.Cog.listener() 75 | async def on_guild_join(self, guild): 76 | """Try and create the default place for threads. Else just fail silently. Continue to add guild to database with defaults""" 77 | self.bot.cache[guild.id] = { 78 | "prefix" : ".", 79 | "default_thread_channel" : 0, 80 | "settings" : { 81 | "role_required" : False, 82 | "allowed_roles" : [], 83 | "TTD" : 3, 84 | "cleanup" : True, 85 | "admin_roles" : [], 86 | "admin_bypass" : False, 87 | "cooldown" : { 88 | "enabled" : True, 89 | "rate" : 0, 90 | "per" : 0, 91 | "bucket" : "user" 92 | } 93 | }, 94 | "active_threads" : {}, 95 | "custom_categories" : {} 96 | } 97 | try: 98 | category = await guild.create_category("THREADS", reason='Initial category of threads creation.') 99 | self.bot.cache[guild.id]["default_thread_channel"] = category.id 100 | except: 101 | self.bot.cache[guild.id]["default_thread_channel"] = 0 102 | await self.bot.write_db(guild) 103 | 104 | @commands.Cog.listener() 105 | async def on_message(self, message): 106 | if message.guild is None or message.author.bot: 107 | #if message.author.id == self.bot.user.id: 108 | # await message.add_reaction('🗑️') 109 | return 110 | if message.content == self.bot.user.mention: 111 | try: 112 | embed = discord.Embed(title='Guild prefix:', 113 | description=f"{message.guild.name} uses the prefix: `{self.bot.cache[message.guild.id]['prefix']}` for all commands.", 114 | color=self.bot.DEFAULT_COLOR) 115 | except: 116 | embed = discord.Embed(title='Guild prefix:', 117 | description=f"{message.guild.name} uses the prefix: `.` for all commands.", 118 | color=self.bot.DEFAULT_COLOR) 119 | 120 | embed.set_author(name=message.guild.name, icon_url=message.guild.icon_url) 121 | await message.channel.send(embed=embed) 122 | try: 123 | if message.channel.id in self.bot.cache[message.guild.id]['active_threads']: 124 | self.bot.cache[message.guild.id]['active_threads'][message.channel.id]['last_message_time'] = datetime.now() 125 | await self.bot.write_db(message.guild) 126 | except: 127 | pass 128 | 129 | 130 | 131 | @commands.command(name='help', aliases=['thelp'], hidden=True) 132 | async def new_help(self, ctx, *, cmd=None): 133 | if cmd is None: 134 | embed=discord.Embed(title=f"{ctx.bot.user.name} Help Menu", description=f"Need some help? Use `{ctx.prefix}thelp command` to get specific help", color=self.bot.DEFAULT_COLOR) 135 | embed.set_author(name=ctx.bot.user.name, icon_url=ctx.bot.user.avatar_url) 136 | cmd_list = {} 137 | for command in ctx.bot.commands: 138 | try: 139 | if await command.can_run(ctx) and not command.hidden: 140 | cmd_list[command.name] = command.description 141 | except (discord.ext.commands.CommandError, IndexError): 142 | continue 143 | avail_cmds = [] 144 | longest_command = max(cmd_list.keys(), key=len) 145 | for k,v in cmd_list.items(): 146 | avail_cmds.append('`' + k + ' ' + '-'*(len(longest_command)-len(k)) + '->>` ' + v) 147 | avail_cmds = '\n'.join(sorted(avail_cmds)) 148 | embed.add_field(name=f'Available Commands:', value=avail_cmds, inline=False) 149 | embed.add_field(name=f'Support Server', value='This bot has a support server! If you run into issues or have ideas, feel free to join. I listen to everything you have to say. Good and bad!\nInvite link: [discord.gg/M8DmU86](https://discord.gg/M8DmU86)', inline=False) 150 | embed.add_field(name='Want to help this bot grow?', value=f'Spread the word! You can also help by giving this bot a vote [here (top.gg)](https://top.gg/bot/617376702187700224/vote) or [here (botsfordiscord.com)](https://botsfordiscord.com/bot/617376702187700224/vote).', inline=False) 151 | embed.add_field(name='Want to add this bot to your server?', value=f"Awesome! Just [click this link.](https://threadstorm.app/invite)") 152 | embed.set_footer(text=f"Only commands you have permission to run in #{ctx.channel.name} are shown here.") 153 | else: 154 | help_cmd = ctx.bot.get_command(cmd) 155 | if help_cmd is None: 156 | await ctx.send("Unable to find that command. Run this again with no arguments for a list of commands.") 157 | return 158 | aliases = [alias for alias in help_cmd.aliases] 159 | embed = discord.Embed(title=f'{help_cmd.name.upper()} MAN PAGE', description=f'This is all the info you need for the `{ctx.prefix}{help_cmd.name}` command.', color=self.bot.DEFAULT_COLOR) 160 | embed.set_author(name=ctx.bot.user.name, icon_url=ctx.bot.user.avatar_url) 161 | embed.add_field(name='Alises for this command:', value=', '.join(aliases), inline=False) 162 | embed.add_field(name='Usage:', value=f'{ctx.prefix}{help_cmd.name} {help_cmd.signature}') 163 | embed.add_field(name='Description:', value=help_cmd.short_doc, inline=False) 164 | await ctx.send(embed=embed) 165 | 166 | 167 | @commands.command(name='debug', aliases=['tdebug'], hidden=True, description="Provide guilds cache area") 168 | @commands.has_permissions(manage_guild=True) 169 | @commands.guild_only() 170 | async def self_debug(self, ctx, channel: typing.Optional[str] = None): 171 | allowed = [] 172 | allowed_admin = [] 173 | categories = [] 174 | 175 | for role in self.bot.cache[ctx.guild.id]['settings']['allowed_roles']: 176 | try: 177 | role = ctx.guild.get_role(role) 178 | allowed.append(role.name) 179 | except: 180 | pass 181 | for role in self.bot.cache[ctx.guild.id]['settings']['admin_roles']: 182 | try: 183 | role = ctx.guild.get_role(role) 184 | allowed_admin.append(role.name) 185 | except: 186 | pass 187 | for category in self.bot.cache[ctx.guild.id]['custom_categories']: 188 | try: 189 | category = ctx.guild.get_channel(category) 190 | categories.append(category.name) 191 | except: 192 | pass 193 | 194 | default_category = ctx.guild.get_channel(self.bot.cache[ctx.guild.id]['default_thread_channel']) 195 | if default_category is None: 196 | default_category = "Default category not found or error fetching channel name." 197 | else: 198 | default_category = default_category.name 199 | 200 | 201 | if not categories: 202 | categories.append("No custom categories or error fetching channels.") 203 | if not allowed: 204 | allowed.append("No roles defined or error fetching guild roles.") 205 | if not allowed_admin: 206 | allowed_admin.append("No roles defined or error fetching guild roles.") 207 | 208 | embed=discord.Embed(title=f"{ctx.guild.name} Debug Output", description="Here is all your guilds stored information, exluding active threads", color=self.bot.DEFAULT_COLOR) 209 | embed.set_author(name="Threadstorm", icon_url=ctx.bot.user.avatar_url) 210 | embed.set_thumbnail(url=ctx.guild.icon_url) 211 | embed.add_field(name="Guild ID", value=ctx.guild.id, inline=True) 212 | embed.add_field(name="Prefix", value=self.bot.cache[ctx.guild.id]['prefix'], inline=True) 213 | embed.add_field(name="Roles Required", value=f"`{self.bot.cache[ctx.guild.id]['settings']['role_required']}`", inline=True) 214 | embed.add_field(name="Allowed Roles", value=", ".join(allowed), inline=False) 215 | embed.add_field(name="Time-Till-Inactive", value=f"`{self.bot.cache[ctx.guild.id]['settings']['TTD']} Days`", inline=True) 216 | embed.add_field(name="Cleanup", value=f"`{self.bot.cache[ctx.guild.id]['settings']['cleanup']}`", inline=True) 217 | embed.add_field(name="Admin Bypass", value=f"`{self.bot.cache[ctx.guild.id]['settings']['admin_bypass']}`", inline=True) 218 | embed.add_field(name="Admin Roles", value=", ".join(allowed_admin), inline=False) 219 | embed.add_field(name="Custom Categories", value=", ".join(categories), inline=True) 220 | embed.add_field(name="Default Category", value=default_category, inline=True) 221 | embed.set_footer(text="Active threads are excluded due to potential character limits. Related information such as channel id's, the keep flag, the author of a thread and the first message id are also kept in memory and on a local database.") 222 | await ctx.send(embed=embed) 223 | 224 | @tasks.loop(count=1) 225 | async def integrity_check(self): 226 | for guild in self.bot.guilds: 227 | if guild.id not in self.bot.cache: 228 | self.bot.cache[guild.id] = { 229 | "prefix" : ".", 230 | "default_thread_channel" : 0, 231 | "settings" : { 232 | "role_required" : False, 233 | "allowed_roles" : [], 234 | "TTD" : 3, 235 | "cleanup" : True, 236 | "admin_roles" : [], 237 | "admin_bypass" : False, 238 | "cooldown" : { 239 | "rate" : 0, 240 | "per" : 0, 241 | "bucket" : "user" 242 | } 243 | }, 244 | "active_threads" : {}, 245 | "custom_categories" : {} 246 | } 247 | try: 248 | category = await guild.create_category("THREADS", reason='Initial category of threads creation.') 249 | self.bot.cache[guild.id]["default_thread_channel"] = category.id 250 | except: 251 | self.bot.cache[guild.id]["default_thread_channel"] = 0 252 | await self.bot.write_db(guild) 253 | 254 | @integrity_check.before_loop 255 | async def wait2(self): 256 | await self.bot.wait_until_ready() 257 | 258 | @tasks.loop(hours=1.0) 259 | async def update_status(self): 260 | active_threads = 0 261 | for k,v in self.bot.cache.items(): 262 | active_threads += len(v['active_threads']) 263 | activity = discord.Activity(type=discord.ActivityType.watching, 264 | name=f"{active_threads} threads | {len(self.bot.guilds)} guilds") 265 | await self.bot.change_presence(activity=activity) 266 | 267 | @update_status.before_loop 268 | async def wait3(self): 269 | await self.bot.wait_until_ready() 270 | 271 | @tasks.loop(hours=24.0) 272 | async def checker(self): 273 | tlock = self.bot.get_command('lock') 274 | self.bot.last_run = datetime.now() 275 | for guild in self.bot.cache.keys(): 276 | guild_obj = self.bot.get_guild(guild) 277 | if guild_obj is None: 278 | continue 279 | ttd = timedelta(days=self.bot.cache[guild]['settings']['TTD']) 280 | 281 | 282 | # Make a copy because we may potentially change the size of the loop 283 | working_loop = copy.deepcopy(self.bot.cache[guild]['active_threads']) 284 | for thread in working_loop: 285 | await asyncio.sleep(3) 286 | now = datetime.now() 287 | converted_last_msg = self.bot.cache[guild]['active_threads'][thread]['last_message_time'] 288 | expiration_date = converted_last_msg + ttd 289 | one_day_before_expiration = expiration_date - timedelta(days=1) 290 | 291 | if now >= expiration_date and not self.bot.cache[guild]['active_threads'][thread]['keep']: 292 | thread_channel = guild_obj.get_channel(thread) 293 | if thread_channel is None: 294 | try: 295 | thread_channel = await self.bot.fetch_channel(thread) 296 | if thread_channel is None: 297 | continue 298 | except discord.errors.NotFound: 299 | self.bot.cache[guild]['active_threads'].pop(thread, None) 300 | await self.bot.write_db(guild_obj) 301 | try: 302 | await thread_channel.delete(reason=f"Thread expired") 303 | self.bot.cache[guild]['active_threads'].pop(thread, None) 304 | await self.bot.write_db(guild_obj) 305 | #If it's any of these exceptions, don't bother trying again and just update cache/db 306 | except (discord.NotFound, discord.Forbidden, AttributeError): 307 | self.bot.cache[guild]['active_threads'].pop(thread, None) 308 | await self.bot.write_db(guild_obj) 309 | 310 | # Try again in 24 hours. Some generic error happened 311 | except discord.HTTPException: 312 | pass 313 | continue 314 | 315 | #It's about to be yoinked 316 | if (one_day_before_expiration <= now < expiration_date) and not self.bot.cache[guild]['active_threads'][thread]['keep']: 317 | thread_channel = guild_obj.get_channel(thread) 318 | if thread_channel is None: 319 | try: 320 | thread_channel = await self.bot.fetch_channel(thread) 321 | if thread_channel is None: 322 | continue 323 | except discord.errors.NotFound: 324 | self.bot.cache[guild]['active_threads'].pop(thread, None) 325 | await self.bot.write_db(guild_obj) 326 | 327 | # Call our lock command to lock the thread 328 | try: 329 | prefix = self.bot.cache[thread_channel.guild.id]['prefix'] 330 | except: 331 | prefix = '.' 332 | try: 333 | await tlock.__call__(thread_channel, f'Inactive.\nUse {prefix}tkeep to keep this thread. Use {prefix}tunlock to unlock this thread. Otherwise this channel will be deleted in 24 hours.') 334 | await thread_channel.send("If this is spamming your channel, sorry. I updated the bot and it hasn't gone as smooth as I'd like it to.") 335 | except: 336 | pass 337 | 338 | @checker.before_loop 339 | async def wait4(self): 340 | await self.bot.wait_until_ready() 341 | 342 | def setup(bot): 343 | bot.add_cog(TB_Utils(bot)) 344 | --------------------------------------------------------------------------------