From e62583c6453a6c222d4574dfb83d4444100b6602 Mon Sep 17 00:00:00 2001 From: Brandon Date: Tue, 16 Nov 2021 03:54:04 -0500 Subject: [PATCH] Bunch of new features and bug fixes --- README.md | 21 + activitylog/activitylog.py | 206 ++++--- birthday/birthday.py | 30 +- markov/markov.py | 110 +++- moreadmin/moreadmin.py | 40 +- planttycoon/__init__.py | 10 + planttycoon/data/badges.json | 11 + planttycoon/data/defaults.json | 22 + planttycoon/data/notifications.json | 7 + planttycoon/data/plants.json | 666 ++++++++++++++++++++ planttycoon/data/products.json | 42 ++ planttycoon/info.json | 19 + planttycoon/planttycoon.py | 779 ++++++++++++++++++++++++ scriptgen/script.py | 14 +- suggestion/discord_thread_feature.py | 84 +++ suggestion/suggestion.py | 37 +- threadmanager/__init__.py | 7 + threadmanager/discord_thread_feature.py | 84 +++ threadmanager/info.json | 8 + threadmanager/thread_manager.py | 130 ++++ warnings_custom/warnings.py | 12 +- 21 files changed, 2230 insertions(+), 109 deletions(-) create mode 100644 planttycoon/__init__.py create mode 100644 planttycoon/data/badges.json create mode 100644 planttycoon/data/defaults.json create mode 100644 planttycoon/data/notifications.json create mode 100644 planttycoon/data/plants.json create mode 100644 planttycoon/data/products.json create mode 100644 planttycoon/info.json create mode 100644 planttycoon/planttycoon.py create mode 100644 suggestion/discord_thread_feature.py create mode 100644 threadmanager/__init__.py create mode 100644 threadmanager/discord_thread_feature.py create mode 100644 threadmanager/info.json create mode 100644 threadmanager/thread_manager.py diff --git a/README.md b/README.md index 99c924a..9821a5a 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,16 @@ Twitter style following system that is guild-agnostic. Allows users to follow so - Works from DMs as well as in guilds, use user and channel IDs for easiest use. +### Image Magic +Image transformation commands to create funny or interesting photos. +**Features** +- Barrel effect +- Implode effect +- Zoom +- Black and white +- Sketch transformation + + #### Isolate Carbon copy of punish cog, except this one will remove all roles from a user and by default sets permissions so they cannot see or talk in any channel except the channel set for isolation. @@ -68,6 +78,10 @@ Carbon copy of punish cog, except this one will remove all roles from a user and Based off of [Malarne's](https://github.com/Malarne/discord_cogs) cog. Has some bug fixes and reduces the starting EXP by 50. Also cleaned up the code a bit, and have features planned. +### Markov +Markov chains! This cog builds markov chain models per channel and optionally per user, allowing for funny and interesting text generation! + + #### MoreAdmin More admin commands that provide various functionality. **Features**: @@ -102,6 +116,7 @@ Modified from Fixator10, added functionality of automatically creating/deleting - Users can customize their role name and color through the bot. - Blacklist words that aren't allowed in role names. - Automatically create/manage personal roles. +- Users can add and remove role icons if the guild has the feature #### Pony Search derpibooru for pony images. Ported from [Alzarath](https://github.com/Alzarath/Booru-Cogs). @@ -189,11 +204,17 @@ Modifed from @saurichable. Adds a few features we needed. - Listing the final vote count when a suggestion is approved or denied - Added reasons for approved suggestions, since sometimes we accept a suggestion but we may modify it - Approved reasons are marked green, denied are marked red +- Optionally create threads for each suggestion for easier discussion #### Smart React Auto react to messages based on keywords. Based off of [FlapJack's](https://github.com/flapjax/FlapJack-Cogs/) cog. Minor bug fixes and planned features, like using regex to parse messages. + +### Thread Manager +A simple thread manager that allows guild staff to set certain roles to create a customable number of threads per channel. Manual archive by users is not supported right now. + + #### Warnings Custom Adds a few features that are needed for my server, modified from the built in warning cog. **Added Features** diff --git a/activitylog/activitylog.py b/activitylog/activitylog.py index 18e19a3..ce5fb24 100644 --- a/activitylog/activitylog.py +++ b/activitylog/activitylog.py @@ -1,6 +1,6 @@ # redbot/discord from redbot.core.utils.chat_formatting import * -from redbot.core import Config, checks, commands, modlog +from redbot.core import Config, checks, commands, modlog, bank from redbot.core.data_manager import cog_data_path from redbot.core.utils.mod import is_mod_or_superior import discord @@ -88,6 +88,20 @@ class ActivityLogger(commands.Cog): self.cache = {} # remove userinfo since we are replacing it + self.badge_emojis = { + "staff": 848556248832016384, + "early_supporter": 706198530837970998, + "hypesquad_balance": 706198531538550886, + "hypesquad_bravery": 706198532998299779, + "hypesquad_brilliance": 706198535846101092, + "hypesquad": 706198537049866261, + "verified_bot_developer": 706198727953612901, + "bug_hunter": 848556247632052225, + "bug_hunter_level_2": 706199712402898985, + "partner": 848556249192202247, + "verified_bot": 848561838974697532, + "verified_bot2": 848561839260434482, + } self.bot.remove_command("userinfo") self.load_task = asyncio.create_task(self.initialize()) @@ -142,91 +156,139 @@ class ActivityLogger(commands.Cog): author = ctx.author guild = ctx.guild is_mod = await is_mod_or_superior(self.bot, author) + if not user or not is_mod: user = author - if is_mod: - roles = [x for x in user.roles if x.name != "@everyone"] - else: - roles = [x.name for x in sorted(user.roles, reverse=True) if x.name != "@everyone"] + async with ctx.typing(): + if is_mod: + roles = [x for x in user.roles if x.name != "@everyone"] + else: + roles = [x.name for x in sorted(user.roles, reverse=True) if x.name != "@everyone"] - joined_at = user.joined_at - since_created = (ctx.message.created_at - user.created_at).days - if joined_at is not None: - since_joined = (ctx.message.created_at - joined_at).days - user_joined = joined_at.strftime("%b %d, %Y %H:%M UTC") - else: - since_joined = "?" - user_joined = "Unknown" - user_created = user.created_at.strftime("%b %d, %Y %H:%M UTC") - member_number = sorted(guild.members, key=lambda m: m.joined_at or ctx.message.created_at).index(user) + 1 + joined_at = user.joined_at + since_created = (ctx.message.created_at - user.created_at).days + if joined_at is not None: + since_joined = (ctx.message.created_at - joined_at).days + user_joined = f"" + else: + since_joined = "?" + user_joined = "Unknown" - created_on = "{}\n({} days ago)".format(user_created, since_created) - joined_on = "{}\n({} days ago)".format(user_joined, since_joined) + user_created = f"" + member_number = sorted(guild.members, key=lambda m: m.joined_at or ctx.message.created_at).index(user) + 1 - game = "Chilling in {} status".format(user.status) + created_on = "{}\n({} days ago)".format(user_created, since_created) + joined_on = "{}\n({} days ago)".format(user_joined, since_joined) - if user.activity is None: # Default status - activity = None - elif user.activity.type == discord.ActivityType.playing: - activity = "Playing {}".format(user.activity.name) - elif user.activity.type == discord.ActivityType.streaming: - activity = "Streaming [{}]({})".format(user.activity.name, user.activity.url) - elif user.activity.type == discord.ActivityType.listening: - activity = "Listening to {}".format(user.activity.name) - elif user.activity.type == discord.ActivityType.watching: - activity = "Watching {}".format(user.activity.name) - else: - activity = None + if user.is_on_mobile(): + statusemoji = "\N{MOBILE PHONE}" + elif any(a.type is discord.ActivityType.streaming for a in user.activities): + statusemoji = "\N{LARGE PURPLE CIRCLE}" + elif user.status.name == "online": + statusemoji = "\N{LARGE GREEN CIRCLE}" + elif user.status.name == "offline": + statusemoji = "\N{MEDIUM WHITE CIRCLE}" + elif user.status.name == "dnd": + statusemoji = "\N{LARGE RED CIRCLE}" + elif user.status.name == "idle": + statusemoji = "\N{LARGE ORANGE CIRCLE}" + else: + statusemoji = "\N{MEDIUM BLACK CIRCLE}\N{VARIATION SELECTOR-16}" - if roles and is_mod: - roles = " ".join([x.mention for x in sorted(roles, reverse=True)]) - elif roles: - roles = ", ".join(roles) - else: - roles = "None" + if user.activity is None: # Default status + activity = "No Status" + elif user.activity.type == discord.ActivityType.playing: + activity = "Playing {}".format(user.activity.name) + elif user.activity.type == discord.ActivityType.streaming: + activity = "Streaming [{}]({})".format(user.activity.name, user.activity.url) + elif user.activity.type == discord.ActivityType.listening: + activity = "Listening to {}".format(user.activity.name) + elif user.activity.type == discord.ActivityType.watching: + activity = "Watching {}".format(user.activity.name) + else: + activity = "No Status" - if user.id != self.bot.user.id: - stats, names = await self.userstats(guild, user) - else: - stats = "Stats are unavailable for this account." - names = None + if roles and is_mod: + roles = " ".join([x.mention for x in sorted(roles, reverse=True)]) + elif roles: + roles = ", ".join(roles) + else: + roles = "None" - title = guild.name if not is_mod else None + if user.id != self.bot.user.id: + stats, names = await self.userstats(guild, user) + if is_mod: + # also add notes + moreadmin = self.bot.get_cog("MoreAdmin") + if moreadmin: + num_notes = len(await moreadmin.config.member(user).notes()) + stats += f", Notes: `{num_notes}`" + else: + stats = "Stats are unavailable for this account." + names = None - data = discord.Embed(title=title, description=activity, colour=user.colour) - data.add_field(name="Joined Discord on", value=created_on) - data.add_field(name="Joined this server on", value=joined_on) - data.add_field(name="Roles", value=roles, inline=False) - data.add_field(name="Stats", value=stats) - if names: - names = pagify(names, page_length=1000) - for name in names: - data.add_field(name="Also known as:", value=name, inline=False) - data.set_footer(text="Member #{} | User ID:{}" "".format(member_number, user.id)) + title = guild.name - name = str(user) - name = " ~ ".join((name, user.nick)) if user.nick else name + data = discord.Embed(title=title, description=f"{statusemoji} {activity}", colour=user.colour) + data.add_field(name="Joined Discord on", value=created_on) + data.add_field(name="Joined this server on", value=joined_on) + data.add_field(name="Roles", value=roles, inline=False) + data.add_field(name="Stats", value=stats) + if names: + names = pagify(names, page_length=1000) + for name in names: + data.add_field(name="Also known as:", value=name, inline=False) + data.set_footer(text="Member #{} | User ID: {}" "".format(member_number, user.id)) - if user.avatar: - avatar = user.avatar_url_as(static_format="png") - data.set_author(name=name, url=avatar) - data.set_thumbnail(url=avatar) - else: - data.set_author(name=name) + name = str(user) + name = " ~ ".join((name, user.nick)) if user.nick else name - if is_mod: - try: - await ctx.send(embed=data, allowed_mentions=discord.AllowedMentions.all()) - except discord.HTTPException: - await ctx.send("I need the `Embed links` permission to send this") - else: - try: - await author.send(embed=data) - except discord.HTTPException: - await ctx.send("Please allow messages from server members to get your info.") - except Exception as e: - print(f"Error in userinfo: {e}") + if user.avatar: + avatar = user.avatar_url_as(static_format="png") + data.set_author(name=name, url=avatar) + data.set_thumbnail(url=avatar) + else: + data.set_author(name=name) + + flags = [f.name for f in user.public_flags.all()] + badges = "" + badge_count = 0 + if flags: + for badge in sorted(flags): + if badge == "verified_bot": + emoji1 = self.badge_emojis["verified_bot"] + emoji2 = self.badge_emojis["verified_bot2"] + if emoji1: + emoji = f"{emoji1}{emoji2}" + else: + emoji = None + else: + emoji = self.badge_emojis[badge] + if emoji: + badges += f"{emoji} {badge.replace('_', ' ').title()}\n" + else: + badges += f"\N{BLACK QUESTION MARK ORNAMENT}\N{VARIATION SELECTOR-16} {badge.replace('_', ' ').title()}\n" + badge_count += 1 + if badges: + data.add_field(name="Badges" if badge_count > 1 else "Badge", value=badges) + if "Economy" in self.bot.cogs: + balance_count = 1 + bankstat = f"**Bank**: {str(humanize_number(await bank.get_balance(user)))} {await bank.get_currency_name(ctx.guild)}\n" + data.add_field(name="Balance", value=bankstat) + + if is_mod: + try: + await ctx.send(embed=data, allowed_mentions=discord.AllowedMentions.all()) + except discord.HTTPException: + await ctx.send("I need the `Embed links` permission to send this") + else: + try: + await author.send(embed=data) + except discord.HTTPException: + await ctx.send("Please allow messages from server members to get your info.") + except Exception as e: + print(f"Error in userinfo: {e}") async def userstats(self, guild, user): """ diff --git a/birthday/birthday.py b/birthday/birthday.py index 98db148..1259b85 100644 --- a/birthday/birthday.py +++ b/birthday/birthday.py @@ -60,7 +60,7 @@ class Birthday(commands.Cog): await self.check_bdays() await asyncio.sleep((tomorrow - now).total_seconds()) ### TESTING: - # await asyncio.sleep(30) + # await asyncio.sleep(5) async def check_bdays(self): for guild in self.bot.guilds: @@ -101,7 +101,8 @@ class Birthday(commands.Cog): embed.description = f"Happy Birthday to {member.mention}!" # embed.set_footer("Add your birthday using the `bday` command!") try: - await channel.send(embed=embed, allowed_mentions=discord.AllowedMentions.all()) + content = f"Congratulations {member.mention}!" + await channel.send(content=content, embed=embed, allowed_mentions=discord.AllowedMentions.all()) except: pass @@ -130,7 +131,8 @@ class Birthday(commands.Cog): # @commands.command() # async def test(self, ctx, *, member: discord.Member): - # await self.check_bdays() + # await self.check_bdays() + # await self.check_member_bday(member) @commands.group(name="bdayset") @commands.guild_only() @@ -246,30 +248,32 @@ class Birthday(commands.Cog): @bday.command(name="list") async def bday_list(self, ctx): """List birthdays in the server""" - embeds = [] + msg = "" for member in ctx.guild.members: bday = await self.config.member(member).birthday() if bday: - embed = discord.Embed(title=f"{member.display_name}", colour=ctx.guild.me.colour) bday_datetime = self.parse_date(bday) bday, age = self.get_date_and_age(bday_datetime) - embed.add_field(name="Birthday", value=bday) + msg += f"{member.display_name}: {bday}" if age: now = datetime.datetime.utcnow() bday_datetime = bday_datetime.replace(year=now.year) if now > bday_datetime: - embed.add_field(name="Turned", value=age) + msg += f", Turned {age}\n" else: - embed.add_field(name="Turning", value=age) - embeds.append(embed) + msg += f", Turning {age}\n" + else: + msg += "\n" - for i, embed in enumerate(embeds): - embed.set_footer(text=f"Page {i+1} of {len(embeds)}") + pages = [] + raw = list(pagify(msg, page_length=1700, delims=["\n"], priority=True)) + for i, page in enumerate(raw): + pages.append(box(f"{page}-----------------\nPage {i+1} of {len(raw)}")) - if not embeds: + if not pages: await ctx.send("No one has their birthday set in your server!") else: - await menu(ctx, embeds, DEFAULT_CONTROLS) + await menu(ctx, pages, DEFAULT_CONTROLS) async def red_delete_data_for_user( self, diff --git a/markov/markov.py b/markov/markov.py index 421d8a1..2defe8a 100644 --- a/markov/markov.py +++ b/markov/markov.py @@ -13,9 +13,12 @@ class Markov(commands.Cog): self.bot = bot self.config = Config.get_conf(self, identifier=5989735216541313, force_registration=True) - default_guild = {"model": {}, "prefixes": [], "max_len": 200} + default_guild = {"model": {}, "prefixes": [], "max_len": 200, "member_model": False} + default_member = {"model": {}} self.config.register_guild(**default_guild) + self.config.register_member(**default_member) self.cache = {} + self.mem_cache = {} self.init_task = asyncio.create_task(self.init()) def cog_unload(self): @@ -23,6 +26,8 @@ class Markov(commands.Cog): # save all the models before full unload/shutdown for guild in self.bot.guilds: asyncio.create_task(self.config.guild(guild).model.set(self.cache[guild.id]["model"])) + for member in guild.members: + asyncio.create_task(self.config.member(member).model.set(self.mem_cache[member.id]["model"])) async def init(self): await self.bot.wait_until_ready() @@ -30,11 +35,17 @@ class Markov(commands.Cog): # slows down once file gets big otherwise for guild in self.bot.guilds: self.cache[guild.id] = await self.config.guild(guild).all() + if self.cache[guild.id]["member_model"]: + for member in guild.members: + self.mem_cache[member.id] = await self.config.member(member).all() while True: # save model every 5 minutes await asyncio.sleep(300) for guild in self.bot.guilds: await self.config.guild(guild).model.set(self.cache[guild.id]["model"]) + if self.cache[guild.id]["member_model"]: + for member in guild.members: + await self.config.member(member).model.set(self.mem_cache[member.id]["model"]) @commands.group() @checks.admin_or_permissions(administrator=True) @@ -43,6 +54,22 @@ class Markov(commands.Cog): """Manage Markov Settings""" pass + @markovset.command(name="mem-model") + async def memmodel(self, ctx, toggle: bool = None): + """ + Enable/disable models for each guild member + + **WARNING**: this can eat up a ton of RAM and space, use at your own risk! + """ + if toggle is None: + disabled = "enabled" if self.cache[ctx.guild.id]["member_model"] else "disabled" + await ctx.send(f"Member models are currently {disabled}.") + return + + self.cache[ctx.guild.id]["member_model"] = toggle + await self.config.guild(ctx.guild).member_model.set(toggle) + await ctx.tick() + @markovset.command(name="clear") async def markovset_clear(self, ctx, *, channel: discord.TextChannel): """Clear data for a specific channel""" @@ -101,17 +128,66 @@ class Markov(commands.Cog): @commands.guild_only() @commands.cooldown(rate=1, per=10, type=commands.BucketType.user) @checks.bot_has_permissions(embed_links=True) - async def markov(self, ctx, *, starting_text: str = None): + async def markov( + self, + ctx, + num_text: Union[int, discord.Member, str] = None, + member: Union[discord.Member, str] = None, + *, + starting_text: str = None, + ): """Generate text using markov chains! Text generated is based on what users say in the current channel + + You can generate a certain number of words using the num_text option, if num_text is not a number it is added to the starting text """ - model = self.cache[ctx.guild.id]["model"] - try: - model = model[str(ctx.channel.id)] - except KeyError: - await ctx.send(error("This channel has no data, try talking in it for a bit first!")) + member_model = self.cache[ctx.guild.id]["member_model"] + if member_model: + if isinstance(member, discord.Member): + if member.id not in self.mem_cache: + self.mem_cache[member.id] = {} + self.mem_cache[member.id]["model"] = {} + model = self.mem_cache[member.id]["model"] + elif isinstance(num_text, discord.Member): + member = num_text + num_text = None + if member.id not in self.mem_cache: + self.mem_cache[member.id] = {} + self.mem_cache[member.id]["model"] = {} + model = self.mem_cache[member.id]["model"] + else: + member_model = False + model = self.cache[ctx.guild.id]["model"] + else: + member_model = False + model = self.cache[ctx.guild.id]["model"] + + if member_model and not model: + await ctx.send(error("This member has no data."), delete_after=30) return + elif not member_model and str(ctx.channel.id) not in model: + await ctx.send(error("This channel has no data."), delete_after=30) + return + + if not member_model: + model = model[str(ctx.channel.id)] + + # if num_text is valid + try: + num = int(num_text) + except: + num = None + + add_num = num is None and num_text is not None + add_mem = not member_model and member is not None + + if add_num and add_mem: + starting_text = f"{num_text} {member} {starting_text if starting_text is not None else ''}" + elif add_num: + starting_text = f"{num_text} {starting_text if starting_text is not None else ''}" + elif add_mem: + starting_text = f"{member} {starting_text if starting_text is not None else ''}" starting_text = starting_text.split(" ") if starting_text else None last_word = starting_text[-1] if starting_text else None @@ -128,7 +204,11 @@ class Markov(commands.Cog): tries = 0 max_tries = 20 num_chars = len(" ".join(markov_text)) - while num_chars < max_len and tries < max_tries: + num_words = len(markov_text) + if num is None: + num = 1e6 # i know its trashy but it makes things easier + + while num_chars < max_len and tries < max_tries and num_words < num: if "?" in markov_text[-1]: break if "\r" in markov_text[-1]: @@ -149,6 +229,8 @@ class Markov(commands.Cog): markov_text.append(choice) tries += 1 + num_words += 1 + markov_text = " ".join(markov_text) if num_chars > max_len: markov_text = markov_text[:max_len] @@ -167,6 +249,7 @@ class Markov(commands.Cog): async def on_message(self, message): if await self.bot.cog_disabled_in_guild(self, message.guild): return + # updates model content = message.content guild = message.guild @@ -182,6 +265,12 @@ class Markov(commands.Cog): content = content.split(" ") model = self.cache[guild.id]["model"] + if self.cache[guild.id]["member_model"]: + if message.author.id not in self.mem_cache: + self.mem_cache[message.author.id] = {} + self.mem_cache[message.author.id]["model"] = {} + mem_model = self.mem_cache[message.author.id]["model"] + try: model[str(message.channel.id)] except KeyError: @@ -190,10 +279,15 @@ class Markov(commands.Cog): for i in range(len(content) - 1): if content[i] not in model[str(message.channel.id)]: model[str(message.channel.id)][content[i]] = list() + if self.cache[guild.id]["member_model"]: + if content[i] not in mem_model: + mem_model[content[i]] = list() + mem_model[content[i]].append(content[i + 1]) model[str(message.channel.id)][content[i]].append(content[i + 1]) self.cache[guild.id]["model"] = model + self.mem_cache[message.author.id]["model"] = mem_model async def red_delete_data_for_user( self, diff --git a/moreadmin/moreadmin.py b/moreadmin/moreadmin.py index ffddc37..366dbbd 100644 --- a/moreadmin/moreadmin.py +++ b/moreadmin/moreadmin.py @@ -937,13 +937,14 @@ class MoreAdmin(commands.Cog): try: message = await channel.fetch_message(message_id) except: - await ctx.send("Sorry, that message could not be found.") + await ctx.send("Sorry, that message could not be found.", delete_after=30) return try: await message.edit(content=msg, allowed_mentions=discord.AllowedMentions.all()) + await ctx.tick() except: - await ctx.send("Could not edit message.") + await ctx.send("Could not edit message.", delete_after=30) @commands.command() @commands.guild_only() @@ -954,8 +955,35 @@ class MoreAdmin(commands.Cog): """ try: await channel.send(msg, allowed_mentions=discord.AllowedMentions.all()) + await ctx.tick() except: - await ctx.send("Could not send message in that channel.") + await ctx.send("Could not send message in that channel.", delete_after=30) + + @commands.command() + @commands.guild_only() + @checks.admin_or_permissions(administrator=True) + async def react(self, ctx, channel: discord.TextChannel, message_id: int, emoji: Union[discord.Emoji, str]): + """ + Have the bot react to a message + + The bot must be able to access the emoji: i.e in the guild where the emoji is from + """ + try: + message = await channel.fetch_message(message_id) + except: + await ctx.send("Sorry, that message could not be found.", delete_after=30) + return + + try: + await message.add_reaction(emoji) + await ctx.tick() + except discord.NotFound: + await ctx.send(f"I could not find the emoji `{emoji}`", delete_after=30) + except discord.Forbidden: + await ctx.send("I do not have permissions to react to that message.", delete_after=30) + except discord.HTTPException: + # assume it couldnt find Emoji + await ctx.send(f"I could not find the emoji `{emoji}`", delete_after=30) @commands.command() @commands.guild_only() @@ -977,7 +1005,7 @@ class MoreAdmin(commands.Cog): filepaths.append(cog_data_path(cog_instance=self) / f"{ctx.author.id}_{a.filename}") await a.save(filepaths[-1]) else: - await ctx.send("You must provide a Discord attachment.") + await ctx.send("You must provide a Discord attachment.", delete_after=30) return files = [discord.File(file) for file in filepaths] @@ -997,7 +1025,7 @@ class MoreAdmin(commands.Cog): try: message = await channel.fetch_message(message_id) except: - await ctx.send("Sorry, that message could not be found.") + await ctx.send("Sorry, that message could not be found.", delete_after=30) return if message.content == "": @@ -1018,7 +1046,7 @@ class MoreAdmin(commands.Cog): try: message = await channel.fetch_message(message_id) except: - await ctx.send("Sorry, that message could not be found.") + await ctx.send("Sorry, that message could not be found.", delete_after=30) return async for m in channel.history(limit=100, after=message.created_at): diff --git a/planttycoon/__init__.py b/planttycoon/__init__.py new file mode 100644 index 0000000..7819b90 --- /dev/null +++ b/planttycoon/__init__.py @@ -0,0 +1,10 @@ +from redbot.core import data_manager + +from .planttycoon import PlantTycoon + + +async def setup(bot): + tycoon = PlantTycoon(bot) + data_manager.bundled_data_path(tycoon) + await tycoon._load_plants_products() # I can access protected members if I want, linter!! + bot.add_cog(tycoon) diff --git a/planttycoon/data/badges.json b/planttycoon/data/badges.json new file mode 100644 index 0000000..4a93b7f --- /dev/null +++ b/planttycoon/data/badges.json @@ -0,0 +1,11 @@ +{ + "badges": { + "Flower Power": {}, + "Fruit Brute": {}, + "Sporadic": {}, + "Odd-pod": {}, + "Greenfingers": {}, + "Nobel Peas Prize": {}, + "Annualsary": {} + } +} diff --git a/planttycoon/data/defaults.json b/planttycoon/data/defaults.json new file mode 100644 index 0000000..cf9357f --- /dev/null +++ b/planttycoon/data/defaults.json @@ -0,0 +1,22 @@ +{ + "points": { + "buy": 5, + "add_health": 5, + "fertilize": 10, + "pruning": 20, + "pesticide": 25, + "growing": 5, + "damage": 25 + }, + "timers": { + "degradation": 1, + "completion": 1, + "notification": 5 + }, + "degradation": { + "base_degradation": 1.5 + }, + "notification": { + "max_health": 50 + } +} diff --git a/planttycoon/data/notifications.json b/planttycoon/data/notifications.json new file mode 100644 index 0000000..f0b68d1 --- /dev/null +++ b/planttycoon/data/notifications.json @@ -0,0 +1,7 @@ +{ + "messages": [ + "The soil seems dry, maybe you could give your plant some water?", + "Your plant seems a bit droopy. I would give it some fertilizer if I were you.", + "Your plant seems a bit too overgrown. You should probably trim it a bit." + ] +} diff --git a/planttycoon/data/plants.json b/planttycoon/data/plants.json new file mode 100644 index 0000000..cb597af --- /dev/null +++ b/planttycoon/data/plants.json @@ -0,0 +1,666 @@ +{ + "plants": [ + { + "name": "Poppy", + "article": "a", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/S4hjyUX.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Dandelion", + "article": "a", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/emqnQP2.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Daisy", + "article": "a", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/lcFq4AB.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Chrysanthemum", + "article": "a", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/5jLtqWL.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Pansy", + "article": "a", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/f7TgD1b.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Lavender", + "article": "a", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/g3OmOSK.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Lily", + "article": "a", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/0hzy7lO.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Petunia", + "article": "a", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/rJm8ISv.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Sunflower", + "article": "a", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/AzgzQK9.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Daffodil", + "article": "a", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/pnCCRsH.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Clover", + "article": "a", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/jNTgirw.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Tulip", + "article": "a", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/kodIFjE.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Rose", + "article": "a", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/sdTNiOH.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Aster", + "article": "an", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/1tN04Hl.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Aloe Vera", + "article": "an", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/WFAYIpx.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Orchid", + "article": "an", + "time": 3600, + "rarity": "common", + "image": "http://i.imgur.com/IQrQYDC.jpg", + "health": 100, + "degradation": 0.625, + "threshold": 110, + "badge": "Flower Power", + "reward": 600 + }, + { + "name": "Dragon Fruit Plant", + "article": "a", + "time": 5400, + "rarity": "uncommon", + "image": "http://i.imgur.com/pfngpDS.jpg", + "health": 100, + "degradation": 0.75, + "threshold": 110, + "badge": "Fruit Brute", + "reward": 1200 + }, + { + "name": "Mango Tree", + "article": "a", + "time": 5400, + "rarity": "uncommon", + "image": "http://i.imgur.com/ybR78Oc.jpg", + "health": 100, + "degradation": 0.75, + "threshold": 110, + "badge": "Fruit Brute", + "reward": 1200 + }, + { + "name": "Lychee Tree", + "article": "a", + "time": 5400, + "rarity": "uncommon", + "image": "http://i.imgur.com/w9LkfhX.jpg", + "health": 100, + "degradation": 0.75, + "threshold": 110, + "badge": "Fruit Brute", + "reward": 1200 + }, + { + "name": "Durian Tree", + "article": "a", + "time": 5400, + "rarity": "uncommon", + "image": "http://i.imgur.com/jh249fz.jpg", + "health": 100, + "degradation": 0.75, + "threshold": 110, + "badge": "Fruit Brute", + "reward": 1200 + }, + { + "name": "Fig Tree", + "article": "a", + "time": 5400, + "rarity": "uncommon", + "image": "http://i.imgur.com/YkhnpEV.jpg", + "health": 100, + "degradation": 0.75, + "threshold": 110, + "badge": "Fruit Brute", + "reward": 1200 + }, + { + "name": "Jack Fruit Tree", + "article": "a", + "time": 5400, + "rarity": "uncommon", + "image": "http://i.imgur.com/2D79TlA.jpg", + "health": 100, + "degradation": 0.75, + "threshold": 110, + "badge": "Fruit Brute", + "reward": 1200 + }, + { + "name": "Prickly Pear Plant", + "article": "a", + "time": 5400, + "rarity": "uncommon", + "image": "http://i.imgur.com/GrcGAGj.jpg", + "health": 100, + "degradation": 0.75, + "threshold": 110, + "badge": "Fruit Brute", + "reward": 1200 + }, + { + "name": "Pineapple Plant", + "article": "a", + "time": 5400, + "rarity": "uncommon", + "image": "http://i.imgur.com/VopYQtr.jpg", + "health": 100, + "degradation": 0.75, + "threshold": 110, + "badge": "Fruit Brute", + "reward": 1200 + }, + { + "name": "Citron Tree", + "article": "a", + "time": 5400, + "rarity": "uncommon", + "image": "http://i.imgur.com/zh7Dr23.jpg", + "health": 100, + "degradation": 0.75, + "threshold": 110, + "badge": "Fruit Brute", + "reward": 1200 + }, + { + "name": "Cherimoya Tree", + "article": "a", + "time": 5400, + "rarity": "uncommon", + "image": "http://i.imgur.com/H62gQK6.jpg", + "health": 100, + "degradation": 0.75, + "threshold": 110, + "badge": "Fruit Brute", + "reward": 1200 + }, + { + "name": "Mangosteen Tree", + "article": "a", + "time": 5400, + "rarity": "uncommon", + "image": "http://i.imgur.com/McNnMqa.jpg", + "health": 100, + "degradation": 0.75, + "threshold": 110, + "badge": "Fruit Brute", + "reward": 1200 + }, + { + "name": "Guava Tree", + "article": "a", + "time": 5400, + "rarity": "uncommon", + "image": "http://i.imgur.com/iy8WgPt.jpg", + "health": 100, + "degradation": 0.75, + "threshold": 110, + "badge": "Fruit Brute", + "reward": 1200 + }, + { + "name": "Orange Tree", + "article": "an", + "time": 5400, + "rarity": "uncommon", + "image": "http://i.imgur.com/lwjEJTm.jpg", + "health": 100, + "degradation": 0.75, + "threshold": 110, + "badge": "Fruit Brute", + "reward": 1200 + }, + { + "name": "Apple Tree", + "article": "an", + "time": 5400, + "rarity": "uncommon", + "image": "http://i.imgur.com/QI3UTR3.jpg", + "health": 100, + "degradation": 0.75, + "threshold": 110, + "badge": "Fruit Brute", + "reward": 1200 + }, + { + "name": "Sapodilla Tree", + "article": "a", + "time": 5400, + "rarity": "uncommon", + "image": "http://i.imgur.com/6BvO5Fu.jpg", + "health": 100, + "degradation": 0.75, + "threshold": 110, + "badge": "Fruit Brute", + "reward": 1200 + }, + { + "name": "Franklin Tree", + "article": "a", + "time": 7200, + "rarity": "rare", + "image": "http://i.imgur.com/hoh17hp.jpg", + "health": 100, + "degradation": 1.5, + "threshold": 110, + "badge": "Sporadic", + "reward": 2400 + }, + { + "name": "Parrot's Beak", + "article": "a", + "time": 7200, + "rarity": "rare", + "image": "http://i.imgur.com/lhSjfQY.jpg", + "health": 100, + "degradation": 1.5, + "threshold": 110, + "badge": "Sporadic", + "reward": 2400 + }, + { + "name": "Koki'o", + "article": "a", + "time": 7200, + "rarity": "rare", + "image": "http://i.imgur.com/Dhw9ync.jpg", + "health": 100, + "degradation": 1.5, + "threshold": 110, + "badge": "Sporadic", + "reward": 2400 + }, + { + "name": "Jade Vine", + "article": "a", + "time": 7200, + "rarity": "rare", + "image": "http://i.imgur.com/h4fJo2R.jpg", + "health": 100, + "degradation": 1.5, + "threshold": 110, + "badge": "Sporadic", + "reward": 2400 + }, + { + "name": "Venus Fly Trap", + "article": "a", + "time": 7200, + "rarity": "rare", + "image": "http://i.imgur.com/NoSdxXh.jpg", + "health": 100, + "degradation": 1.5, + "threshold": 110, + "badge": "Sporadic", + "reward": 2400 + }, + { + "name": "Chocolate Cosmos", + "article": "a", + "time": 7200, + "rarity": "rare", + "image": "http://i.imgur.com/4ArSekX.jpg", + "health": 100, + "degradation": 1.5, + "threshold": 110, + "badge": "Sporadic", + "reward": 2400 + }, + { + "name": "Pizza Plant", + "article": "a", + "time": 9000, + "rarity": "super-rare", + "image": "http://i.imgur.com/ASZXr7C.png", + "health": 100, + "degradation": 2, + "threshold": 110, + "badge": "Odd-pod", + "reward": 3600 + }, + { + "name": "Piranha Plant", + "article": "a", + "time": 9000, + "rarity": "super-rare", + "image": "http://i.imgur.com/c03i9W7.jpg", + "health": 100, + "degradation": 2, + "threshold": 110, + "badge": "Odd-pod", + "reward": 3600 + }, + { + "name": "Peashooter", + "article": "a", + "time": 9000, + "rarity": "super-rare", + "image": "https://i.imgur.com/Vo4v2Ry.png", + "health": 100, + "degradation": 2, + "threshold": 110, + "badge": "Odd-pod", + "reward": 3600 + }, + { + "name": "Eldergleam Tree", + "article": "a", + "time": 10800, + "rarity": "epic", + "image": "https://i.imgur.com/pnZYKZc.jpg", + "health": 100, + "degradation": 2.5, + "threshold": 110, + "badge": "Greenfingers", + "reward": 5400 + }, + { + "name": "Pikmin", + "article": "a", + "time": 10800, + "rarity": "epic", + "image": "http://i.imgur.com/sizf7hE.png", + "health": 100, + "degradation": 2.5, + "threshold": 110, + "badge": "Greenfingers", + "reward": 5400 + }, + { + "name": "Flora Colossus", + "article": "a", + "time": 10800, + "rarity": "epic", + "image": "http://i.imgur.com/9f5QzaW.jpg", + "health": 100, + "degradation": 2.5, + "threshold": 110, + "badge": "Greenfingers", + "reward": 5400 + }, + { + "name": "Plantera Bulb", + "article": "a", + "time": 10800, + "rarity": "epic", + "image": "https://i.imgur.com/ExqLLHO.png", + "health": 100, + "degradation": 2.5, + "threshold": 110, + "badge": "Greenfingers", + "reward": 5400 + }, + { + "name": "Chorus Tree", + "article": "an", + "time": 10800, + "rarity": "epic", + "image": "https://i.imgur.com/tv2B72j.png", + "health": 100, + "degradation": 2.5, + "threshold": 110, + "badge": "Greenfingers", + "reward": 5400 + }, + { + "name": "Money Tree", + "article": "a", + "time": 35400, + "rarity": "legendary", + "image": "http://i.imgur.com/MIJQDLL.jpg", + "health": 100, + "degradation": 8, + "threshold": 110, + "badge": "Nobel Peas Prize", + "reward": 10800 + }, + { + "name": "Truffula Tree", + "article": "a", + "time": 35400, + "rarity": "legendary", + "image": "http://i.imgur.com/cFSmaHH.png", + "health": 100, + "degradation": 8, + "threshold": 110, + "badge": "Nobel Peas Prize", + "reward": 10800 + }, + { + "name": "Whomping Willow", + "article": "a", + "time": 35400, + "rarity": "legendary", + "image": "http://i.imgur.com/Ibwm2xY.jpg", + "health": 100, + "degradation": 8, + "threshold": 110, + "badge": "Nobel Peas Prize", + "reward": 10800 + } + ], + "event": { + "January": { + "name": "Tanabata Tree", + "article": "a", + "time": 70800, + "rarity": "event", + "image": "http://i.imgur.com/FD38JJj.jpg", + "health": 100, + "degradation": 9, + "threshold": 110, + "badge": "Annualsary", + "reward": 21600 + }, + "February": { + "name": "Chocolate Rose", + "article": "a", + "time": 70800, + "rarity": "event", + "image": "http://i.imgur.com/Sqg6pcG.jpg", + "health": 100, + "degradation": 9, + "threshold": 110, + "badge": "Annualsary", + "reward": 21600 + }, + "March": { + "name": "Shamrock", + "article": "a", + "time": 70800, + "rarity": "event", + "image": "http://i.imgur.com/kVig04M.jpg", + "health": 100, + "degradation": 9, + "threshold": 110, + "badge": "Annualsary", + "reward": 21600 + }, + "April": { + "name": "Easter Egg Eggplant", + "article": "an", + "time": 70800, + "rarity": "event", + "image": "http://i.imgur.com/5jltGQa.jpg", + "health": 100, + "degradation": 9, + "threshold": 110, + "badge": "Annualsary", + "reward": 21600 + }, + "October": { + "name": "Jack O' Lantern", + "article": "a", + "time": 70800, + "rarity": "event", + "image": "http://i.imgur.com/efApsxG.jpg", + "health": 100, + "degradation": 9, + "threshold": 110, + "badge": "Annualsary", + "reward": 21600 + }, + "November": { + "name": "Mayflower", + "article": "a", + "time": 70800, + "rarity": "event", + "image": "http://i.imgur.com/nntNtoL.jpg", + "health": 100, + "degradation": 9, + "threshold": 110, + "badge": "Annualsary", + "reward": 21600 + }, + "December": { + "name": "Holly", + "article": "a", + "time": 70800, + "rarity": "event", + "image": "http://i.imgur.com/maDLmJC.jpg", + "health": 100, + "degradation": 9, + "threshold": 110, + "badge": "Annualsary", + "reward": 21600 + } + } +} \ No newline at end of file diff --git a/planttycoon/data/products.json b/planttycoon/data/products.json new file mode 100644 index 0000000..f8a0a96 --- /dev/null +++ b/planttycoon/data/products.json @@ -0,0 +1,42 @@ +{ + "water": { + "cost": 5, + "health": 10, + "damage": 45, + "modifier": 0, + "category": "water", + "uses": 1 + }, + "manure": { + "cost": 20, + "health": 20, + "damage": 55, + "modifier": -0.035, + "category": "fertilizer", + "uses": 1 + }, + "vermicompost": { + "cost": 35, + "health": 30, + "damage": 60, + "modifier": -0.5, + "category": "fertilizer", + "uses": 1 + }, + "nitrates": { + "cost": 70, + "health": 60, + "damage": 75, + "modifier": -0.08, + "category": "fertilizer", + "uses": 1 + }, + "pruner": { + "cost": 500, + "health": 40, + "damage": 90, + "modifier": -0.065, + "category": "tool", + "uses": 10 + } +} \ No newline at end of file diff --git a/planttycoon/info.json b/planttycoon/info.json new file mode 100644 index 0000000..d64d70f --- /dev/null +++ b/planttycoon/info.json @@ -0,0 +1,19 @@ +{ + "author": [ + "Bobloy", + "SnappyDragon", + "PaddoInWonderland" + ], + "min_bot_version": "3.3.0", + "description": "Grow your own plants! Be sure to take care of it. Do `[p]gardening` to get started", + "hidden": false, + "install_msg": "Thank you for installing PlantTycoon. Check out all the commands with `[p]help PlantTycoon`", + "requirements": [], + "short": "Grow your own plants! Do `[p]gardening` to get started.", + "end_user_data_statement": "This cog stores user IDs along with their progress in the PlantTycoon game", + "tags": [ + "bobloy", + "games", + "environment" + ] +} \ No newline at end of file diff --git a/planttycoon/planttycoon.py b/planttycoon/planttycoon.py new file mode 100644 index 0000000..97620a1 --- /dev/null +++ b/planttycoon/planttycoon.py @@ -0,0 +1,779 @@ +import asyncio +import collections +import copy +import datetime +import json +import time +from random import choice +from typing import Literal + +import discord +from redbot.core import Config, bank, commands +from redbot.core.bot import Red +from redbot.core.data_manager import bundled_data_path +from redbot.core.utils import AsyncIter + + +class Gardener: + """Gardener class""" + + def __init__(self, user: discord.User, config: Config): + self.user = user + self.config = config + self.badges = [] + self.points = 0 + self.products = {} + self.current = {} + + def __str__(self): + return ( + "Gardener named {}\n" + "Badges: {}\n" + "Points: {}\n" + "Products: {}\n" + "Current: {}".format(self.user, self.badges, self.points, self.products, self.current) + ) + + def __repr__(self): + return "{} - {} - {} - {} - {}".format(self.user, self.badges, self.points, self.products, self.current) + + async def load_config(self): + self.badges = await self.config.user(self.user).badges() + self.points = await self.config.user(self.user).points() + self.products = await self.config.user(self.user).products() + self.current = await self.config.user(self.user).current() + + async def save_gardener(self): + await self.config.user(self.user).badges.set(self.badges) + await self.config.user(self.user).points.set(self.points) + await self.config.user(self.user).products.set(self.products) + await self.config.user(self.user).current.set(self.current) + + async def is_complete(self, now): + + message = None + if self.current: + then = self.current["timestamp"] + health = self.current["health"] + grow_time = self.current["time"] + badge = self.current["badge"] + reward = self.current["reward"] + if (now - then) > grow_time: + self.points += reward + if badge not in self.badges: + self.badges.append(badge) + message = ( + "Your plant made it! " + "You are rewarded with the **{}** badge and you have received **{}** Thneeds.".format(badge, reward) + ) + if health < 0: + message = "Your plant died!" + + if message is not None: + self.current = {} + await self.save_gardener() + await self.user.send(message) + + +async def _die_in(gardener, degradation): + # + # Calculating how much time in minutes remains until the plant's health hits 0 + # + + return int(gardener.current["health"] / degradation.degradation) + + +async def _grow_time(gardener): + # + # Calculating the remaining grow time for a plant + # + + now = int(time.time()) + then = gardener.current["timestamp"] + return (gardener.current["time"] - (now - then)) / 60 + + +async def _send_message(channel, message): + """Sendsa message""" + + em = discord.Embed(description=message, color=discord.Color.green()) + await channel.send(embed=em) + + +async def _withdraw_points(gardener: Gardener, amount): + # + # Substract points from the gardener + # + + if (gardener.points - amount) < 0: + return False + else: + gardener.points -= amount + return True + + +class PlantTycoon(commands.Cog): + """Grow your own plants! Be sure to take proper care of it.""" + + def __init__(self, bot: Red, *args, **kwargs): + super().__init__(*args, **kwargs) + self.bot = bot + self.config = Config.get_conf(self, identifier=80108971101168412199111111110) + + default_user = {"badges": [], "points": 0, "products": {}, "current": {}} + + self.config.register_user(**default_user) + + self.plants = None + + self.products = None + + self.defaults = { + "points": { + "buy": 5, + "add_health": 5, + "fertilize": 10, + "pruning": 20, + "pesticide": 25, + "growing": 5, + "damage": 25, + }, + "timers": {"degradation": 1, "completion": 1, "notification": 5}, + "degradation": {"base_degradation": 1.5}, + "notification": {"max_health": 50}, + } + + self.badges = { + "badges": { + "Flower Power": {}, + "Fruit Brute": {}, + "Sporadic": {}, + "Odd-pod": {}, + "Greenfingers": {}, + "Nobel Peas Prize": {}, + "Annualsary": {}, + } + } + + self.notifications = { + "messages": [ + "The soil seems dry, maybe you could give your plant some water?", + "Your plant seems a bit droopy. I would give it some fertilizer if I were you.", + "Your plant seems a bit too overgrown. You should probably trim it a bit.", + ] + } + + # + # Starting loops + # + + self.completion_task = bot.loop.create_task(self.check_completion_loop()) + # self.degradation_task = bot.loop.create_task(self.check_degradation()) + self.notification_task = bot.loop.create_task(self.send_notification()) + + # + # Loading bank + # + + # self.bank = bot.get_cog('Economy').bank + + async def red_delete_data_for_user( + self, + *, + requester: Literal["discord_deleted_user", "owner", "user", "user_strict"], + user_id: int, + ): + + await self.config.user_from_id(user_id).clear() + + async def _load_plants_products(self): + """Runs in __init__.py before cog is added to the bot""" + plant_path = bundled_data_path(self) / "plants.json" + product_path = bundled_data_path(self) / "products.json" + with plant_path.open() as json_data: + self.plants = json.load(json_data) + + await self._load_event_seeds() + + with product_path.open() as json_data: + self.products = json.load(json_data) + + for product in self.products: + print("PlantTycoon: Loaded {}".format(product)) + + async def _load_event_seeds(self): + self.plants["all_plants"] = copy.deepcopy(self.plants["plants"]) + plant_options = self.plants["all_plants"] + + d = datetime.date.today() + month = d.month + if month == 1: + plant_options.append(self.plants["event"]["January"]) + elif month == 2: + plant_options.append(self.plants["event"]["February"]) + elif month == 3: + plant_options.append(self.plants["event"]["March"]) + elif month == 4: + plant_options.append(self.plants["event"]["April"]) + elif month == 10: + plant_options.append(self.plants["event"]["October"]) + elif month == 11: + plant_options.append(self.plants["event"]["November"]) + elif month == 12: + plant_options.append(self.plants["event"]["December"]) + + async def _gardener(self, user: discord.User) -> Gardener: + + # + # This function returns a Gardener object for the user + # + + g = Gardener(user, self.config) + await g.load_config() + return g + + async def _degradation(self, gardener: Gardener): + + # + # Calculating the rate of degradation per check_completion_loop() cycle. + # + if self.products is None: + await self._load_plants_products() + + modifiers = sum( + [self.products[product]["modifier"] for product in gardener.products if gardener.products[product] > 0] + ) + + degradation = ( + 100 + / (gardener.current["time"] / 60) + * (self.defaults["degradation"]["base_degradation"] + gardener.current["degradation"]) + ) + modifiers + + d = collections.namedtuple("degradation", "degradation time modifiers") + + return d(degradation=degradation, time=gardener.current["time"], modifiers=modifiers) + + # async def _get_member(self, user_id): + # + # # + # # Return a member object + # # + # + # return discord.User(id=user_id) # I made it a string just to be sure + # + # async def _send_notification(self, user_id, message): + # + # # + # # Sends a Direct Message to the gardener + # # + # + # member = await self._get_member(user_id) + # em = discord.Embed(description=message, color=discord.Color.green()) + # await self.bot.send_message(member, embed=em) + + async def _add_health(self, channel, gardener: Gardener, product, product_category): + + # + # The function to add health + # + if self.products is None: + await self._load_plants_products() + product = product.lower() + product_category = product_category.lower() + if product in self.products and self.products[product]["category"] == product_category: + if product in gardener.products: + if gardener.products[product] > 0: + gardener.current["health"] += self.products[product]["health"] + gardener.products[product] -= 1 + if gardener.products[product] == 0: + del gardener.products[product.lower()] + if product_category == "water": + emoji = ":sweat_drops:" + elif product_category == "fertilizer": + emoji = ":poop:" + # elif product_category == "tool": + else: + emoji = ":scissors:" + message = "Your plant got some health back! {}".format(emoji) + if gardener.current["health"] > gardener.current["threshold"]: + gardener.current["health"] -= self.products[product]["damage"] + if product_category == "tool": + damage_msg = "You used {} too many times!".format(product) + else: + damage_msg = "You gave too much of {}.".format(product) + message = "{} Your plant lost some health. :wilted_rose:".format(damage_msg) + gardener.points += self.defaults["points"]["add_health"] + await gardener.save_gardener() + else: + message = "You have no {}. Go buy some!".format(product) + else: + if product_category == "tool": + message = "You don't have a {}. Go buy one!".format(product) + else: + message = "You have no {}. Go buy some!".format(product) + else: + message = "Are you sure you are using {}?".format(product_category) + + if product_category == "water": + emcolor = discord.Color.blue() + elif product_category == "fertilizer": + emcolor = discord.Color.dark_gold() + # elif product_category == "tool": + else: + emcolor = discord.Color.dark_grey() + + em = discord.Embed(description=message, color=emcolor) + await channel.send(embed=em) + + @commands.group(name="gardening", autohelp=False) + async def _gardening(self, ctx: commands.Context): + """Gardening commands.""" + if ctx.invoked_subcommand is None: + prefix = ctx.prefix + + title = "**Welcome to Plant Tycoon.**\n" + description = """'Grow your own plant. Be sure to take proper care of yours.\n + If it successfully grows, you get a reward.\n + As you nurture your plant, you gain Thneeds which can be exchanged for credits.\n\n + **Commands**\n\n + ``{0}gardening seed``: Plant a seed inside the earth.\n + ``{0}gardening profile``: Check your gardening profile.\n + ``{0}gardening plants``: Look at the list of the available plants.\n + ``{0}gardening plant``: Look at the details of a plant.\n + ``{0}gardening state``: Check the state of your plant.\n + ``{0}gardening buy``: Buy gardening supplies.\n + ``{0}gardening convert``: Exchange Thneeds for credits.\n + ``{0}shovel``: Shovel your plant out.\n + ``{0}water``: Water your plant.\n + ``{0}fertilize``: Fertilize the soil.\n + ``{0}prune``: Prune your plant.\n""" + + em = discord.Embed( + title=title, + description=description.format(prefix), + color=discord.Color.green(), + ) + em.set_thumbnail(url="https://image.prntscr.com/image/AW7GuFIBSeyEgkR2W3SeiQ.png") + em.set_footer( + text="This cog was made by SnappyDragon18 and PaddoInWonderland. Inspired by The Lorax (2012)." + ) + await ctx.send(embed=em) + + @commands.cooldown(1, 60 * 10, commands.BucketType.user) + @_gardening.command(name="seed") + async def _seed(self, ctx: commands.Context): + """Plant a seed inside the earth.""" + if self.plants is None: + await self._load_plants_products() + author = ctx.author + # server = context.message.server + # if author.id not in self.gardeners: + # self.gardeners[author.id] = {} + # self.gardeners[author.id]['current'] = False + # self.gardeners[author.id]['points'] = 0 + # self.gardeners[author.id]['badges'] = [] + # self.gardeners[author.id]['products'] = {} + gardener = await self._gardener(author) + + if not gardener.current: + plant_options = self.plants["all_plants"] + + plant = choice(plant_options) + plant["timestamp"] = int(time.time()) + plant["degrade_count"] = 0 + # index = len(self.plants["plants"]) - 1 + # del [self.plants["plants"][index]] + message = ( + "During one of your many heroic adventures, you came across a mysterious bag that said " + '"pick one". To your surprise it had all kinds of different seeds in them. ' + "And now that you're home, you want to plant it. " + "You went to a local farmer to identify the seed, and the farmer " + "said it was {} **{} ({})** seed.\n\n" + "Take good care of your seed and water it frequently. " + "Once it blooms, something nice might come from it. " + "If it dies, however, you will get nothing.".format(plant["article"], plant["name"], plant["rarity"]) + ) + if "water" not in gardener.products: + gardener.products["water"] = 0 + gardener.products["water"] += 5 + gardener.current = plant + await gardener.save_gardener() + + em = discord.Embed(description=message, color=discord.Color.green()) + else: + plant = gardener.current + message = "You're already growing {} **{}**, silly.".format(plant["article"], plant["name"]) + em = discord.Embed(description=message, color=discord.Color.green()) + + await ctx.send(embed=em) + + @_gardening.command(name="profile") + async def _profile(self, ctx: commands.Context, *, member: discord.Member = None): + """Check your gardening profile.""" + if member is not None: + author = member + else: + author = ctx.author + + gardener = await self._gardener(author) + try: + await self._apply_degradation(gardener) + except discord.Forbidden: + await ctx.send("ERROR\nYou blocked me, didn't you?") + + em = discord.Embed(color=discord.Color.green()) # , description='\a\n') + avatar = author.avatar_url if author.avatar else author.default_avatar_url + em.set_author(name="Gardening profile of {}".format(author.name), icon_url=avatar) + em.add_field(name="**Thneeds**", value=str(gardener.points)) + if not gardener.current: + em.add_field(name="**Currently growing**", value="None") + else: + em.set_thumbnail(url=gardener.current["image"]) + em.add_field( + name="**Currently growing**", + value="{0} ({1:.2f}%)".format(gardener.current["name"], gardener.current["health"]), + ) + if not gardener.badges: + em.add_field(name="**Badges**", value="None") + else: + badges = "" + for badge in gardener.badges: + badges += "{}\n".format(badge.capitalize()) + em.add_field(name="**Badges**", value=badges) + if not gardener.products: + em.add_field(name="**Products**", value="None") + else: + products = "" + for product_name, product_data in gardener.products.items(): + if self.products[product_name] is None: + continue + products += "{} ({}) {}\n".format( + product_name.capitalize(), + product_data / self.products[product_name]["uses"], + self.products[product_name]["modifier"], + ) + em.add_field(name="**Products**", value=products) + if gardener.current: + degradation = await self._degradation(gardener) + die_in = await _die_in(gardener, degradation) + to_grow = await _grow_time(gardener) + em.set_footer( + text="Total degradation: {0:.2f}% / {1} min (100 / ({2} / 60) * (BaseDegr {3:.2f} + PlantDegr {4:.2f}))" + " + ModDegr {5:.2f}) Your plant will die in {6} minutes " + "and {7:.1f} minutes to go for flowering.".format( + degradation.degradation, + self.defaults["timers"]["degradation"], + degradation.time, + self.defaults["degradation"]["base_degradation"], + gardener.current["degradation"], + degradation.modifiers, + die_in, + to_grow, + ) + ) + await ctx.send(embed=em) + + @_gardening.command(name="plants") + async def _plants(self, ctx): + """Look at the list of the available plants.""" + if self.plants is None: + await self._load_plants_products() + tick = "" + tock = "" + tick_tock = 0 + for plant in self.plants["all_plants"]: + if tick_tock == 0: + tick += "**{}**\n".format(plant["name"]) + tick_tock = 1 + else: + tock += "**{}**\n".format(plant["name"]) + tick_tock = 0 + em = discord.Embed(title="All plants that are growable", color=discord.Color.green()) + em.add_field(name="\a", value=tick) + em.add_field(name="\a", value=tock) + await ctx.send(embed=em) + + @_gardening.command(name="plant") + async def _plant(self, ctx: commands.Context, *, plantname): + """Look at the details of a plant.""" + if not plantname: + await ctx.send_help() + if self.plants is None: + await self._load_plants_products() + t = False + plant = None + for p in self.plants["all_plants"]: + if p["name"].lower() == plantname.lower().strip('"'): + plant = p + t = True + break + + if t: + em = discord.Embed( + title="Plant statistics of {}".format(plant["name"]), + color=discord.Color.green(), + ) + em.set_thumbnail(url=plant["image"]) + em.add_field(name="**Name**", value=plant["name"]) + em.add_field(name="**Rarity**", value=plant["rarity"].capitalize()) + em.add_field(name="**Grow Time**", value="{0:.1f} minutes".format(plant["time"] / 60)) + em.add_field(name="**Damage Threshold**", value="{}%".format(plant["threshold"])) + em.add_field(name="**Badge**", value=plant["badge"]) + em.add_field(name="**Reward**", value="{} τ".format(plant["reward"])) + else: + message = "I can't seem to find that plant." + em = discord.Embed(description=message, color=discord.Color.red()) + await ctx.send(embed=em) + + @_gardening.command(name="state") + async def _state(self, ctx): + """Check the state of your plant.""" + author = ctx.author + gardener = await self._gardener(author) + try: + await self._apply_degradation(gardener) + except discord.Forbidden: + # Couldn't DM the degradation + await ctx.send("ERROR\nYou blocked me, didn't you?") + + if not gardener.current: + message = "You're currently not growing a plant." + em_color = discord.Color.red() + else: + plant = gardener.current + degradation = await self._degradation(gardener) + die_in = await _die_in(gardener, degradation) + to_grow = await _grow_time(gardener) + message = ( + "You're growing {0} **{1}**. " + "Its health is **{2:.2f}%** and still has to grow for **{3:.1f}** minutes. " + "It is losing **{4:.2f}%** per minute and will die in **{5:.1f}** minutes.".format( + plant["article"], + plant["name"], + plant["health"], + to_grow, + degradation.degradation, + die_in, + ) + ) + em_color = discord.Color.green() + em = discord.Embed(description=message, color=em_color) + await ctx.send(embed=em) + + @_gardening.command(name="buy") + async def _buy(self, ctx, product=None, amount: int = 1): + """Buy gardening supplies.""" + if self.products is None: + await self._load_plants_products() + + author = ctx.author + if product is None: + em = discord.Embed( + title="All gardening supplies that you can buy:", + color=discord.Color.green(), + ) + for pd in self.products: + em.add_field( + name="**{}**".format(pd.capitalize()), + value="Cost: {} τ\n+{} health\n-{}% damage\nUses: {}\nCategory: {}".format( + self.products[pd]["cost"], + self.products[pd]["health"], + self.products[pd]["damage"], + self.products[pd]["uses"], + self.products[pd]["category"], + ), + ) + await ctx.send(embed=em) + else: + if amount <= 0: + message = "Invalid amount! Must be greater than 1" + else: + gardener = await self._gardener(author) + if product.lower() in self.products and amount > 0: + cost = self.products[product.lower()]["cost"] * amount + withdraw_points = await _withdraw_points(gardener, cost) + if withdraw_points: + if product.lower() not in gardener.products: + gardener.products[product.lower()] = 0 + # gardener.products[product.lower()] += amount + # Only add it once + gardener.products[product.lower()] += amount * self.products[product.lower()]["uses"] + await gardener.save_gardener() + message = "You bought {}.".format(product.lower()) + else: + message = "You don't have enough Thneeds. You have {}, but need {}.".format( + gardener.points, + self.products[product.lower()]["cost"] * amount, + ) + else: + message = "I don't have this product." + em = discord.Embed(description=message, color=discord.Color.green()) + await ctx.send(embed=em) + + @_gardening.command(name="convert") + async def _convert(self, ctx: commands.Context, amount: int): + """Exchange Thneeds for credits.""" + author = ctx.author + gardener = await self._gardener(author) + + withdraw_points = await _withdraw_points(gardener, amount) + plural = "" + if amount > 0: + plural = "s" + if withdraw_points: + await bank.deposit_credits(author, amount) + message = "{} Thneed{} successfully exchanged for credits.".format(amount, plural) + await gardener.save_gardener() + else: + message = "You don't have enough Thneed{}. " "You have {}, but need {}.".format( + plural, gardener.points, amount + ) + + em = discord.Embed(description=message, color=discord.Color.green()) + await ctx.send(embed=em) + + @commands.command(name="shovel") + async def _shovel(self, ctx: commands.Context): + """Shovel your plant out.""" + author = ctx.author + gardener = await self._gardener(author) + if not gardener.current: + message = "You're currently not growing a plant." + else: + gardener.current = {} + message = "You successfully shovelled your plant out." + if gardener.points < 0: + gardener.points = 0 + await gardener.save_gardener() + + em = discord.Embed(description=message, color=discord.Color.dark_grey()) + await ctx.send(embed=em) + + @commands.command(name="water") + async def _water(self, ctx): + """Water your plant.""" + author = ctx.author + channel = ctx.channel + gardener = await self._gardener(author) + try: + await self._apply_degradation(gardener) + except discord.Forbidden: + # Couldn't DM the degradation + await ctx.send("ERROR\nYou blocked me, didn't you?") + product = "water" + product_category = "water" + if not gardener.current: + message = "You're currently not growing a plant." + await _send_message(channel, message) + else: + await self._add_health(channel, gardener, product, product_category) + + @commands.command(name="fertilize") + async def _fertilize(self, ctx, fertilizer): + """Fertilize the soil.""" + gardener = await self._gardener(ctx.author) + try: + await self._apply_degradation(gardener) + except discord.Forbidden: + # Couldn't DM the degradation + await ctx.send("ERROR\nYou blocked me, didn't you?") + channel = ctx.channel + product = fertilizer + product_category = "fertilizer" + if not gardener.current: + message = "You're currently not growing a plant." + await _send_message(channel, message) + else: + await self._add_health(channel, gardener, product, product_category) + + @commands.command(name="prune") + async def _prune(self, ctx): + """Prune your plant.""" + gardener = await self._gardener(ctx.author) + try: + await self._apply_degradation(gardener) + except discord.Forbidden: + # Couldn't DM the degradation + await ctx.send("ERROR\nYou blocked me, didn't you?") + channel = ctx.channel + product = "pruner" + product_category = "tool" + if not gardener.current: + message = "You're currently not growing a plant." + await _send_message(channel, message) + else: + await self._add_health(channel, gardener, product, product_category) + + # async def check_degradation(self): + # while "PlantTycoon" in self.bot.cogs: + # users = await self.config.all_users() + # for user_id in users: + # user = self.bot.get_user(user_id) + # gardener = await self._gardener(user) + # await self._apply_degradation(gardener) + # await asyncio.sleep(self.defaults["timers"]["degradation"] * 60) + + async def _apply_degradation(self, gardener): + if gardener.current: + degradation = await self._degradation(gardener) + now = int(time.time()) + timestamp = gardener.current["timestamp"] + degradation_count = (now - timestamp) // (self.defaults["timers"]["degradation"] * 60) + degradation_count -= gardener.current["degrade_count"] + gardener.current["health"] -= degradation.degradation * degradation_count + gardener.points += self.defaults["points"]["growing"] * degradation_count + gardener.current["degrade_count"] += degradation_count + await gardener.save_gardener() + await gardener.is_complete(now) + + async def check_completion_loop(self): + while "PlantTycoon" in self.bot.cogs: + now = int(time.time()) + users = await self.config.all_users() + for user_id in users: + user = self.bot.get_user(user_id) + if not user: + continue + gardener = await self._gardener(user) + if not gardener: + continue + try: + await self._apply_degradation(gardener) + await gardener.is_complete(now) + except discord.Forbidden: + # Couldn't DM the results + pass + await asyncio.sleep(self.defaults["timers"]["completion"] * 60) + + async def send_notification(self): + while "PlantTycoon" in self.bot.cogs: + users = await self.config.all_users() + for user_id in users: + user = self.bot.get_user(user_id) + if not user: + continue + gardener = await self._gardener(user) + if not gardener: + continue + try: + await self._apply_degradation(gardener) + except discord.Forbidden: + # Couldn't DM the degradation + pass + + if gardener.current: + health = gardener.current["health"] + if health < self.defaults["notification"]["max_health"]: + message = choice(self.notifications["messages"]) + try: + await user.send(message) + except discord.Forbidden: + # Couldn't DM the results + pass + await asyncio.sleep(self.defaults["timers"]["notification"] * 60) + + def __unload(self): + self.completion_task.cancel() + # self.degradation_task.cancel() + self.notification_task.cancel() diff --git a/scriptgen/script.py b/scriptgen/script.py index 65f4247..6eb3c9d 100644 --- a/scriptgen/script.py +++ b/scriptgen/script.py @@ -173,7 +173,7 @@ class ScriptGen(commands.Cog): """ ### make sure model is load if not self.model: - await ctx.send(error("Model not loaded! Contact bot owner!")) + await ctx.send(error("Model not loaded! Contact bot owner!"), delete_after=30) return # make sure we are not on cooldown @@ -181,18 +181,24 @@ class ScriptGen(commands.Cog): last_ran = await self.config.guild(ctx.guild).last_ran() now = time.time() if now - last_ran < cooldown: - await ctx.send(f"Sorry, this command is on cooldown for {int((last_ran + cooldown) - now)} seconds") + await ctx.send( + f"Sorry, this command is on cooldown for {int((last_ran + cooldown) - now)} seconds", + delete_after=((last_ran + cooldown) - now), + ) return # make sure max length isnt exceeded max_len = await self.config.max_len() if num_words > max_len: - await ctx.send(error(f"Maximum number of words that can be generated is: {max_len}")) + await ctx.send(error(f"Maximum number of words that can be generated is: {max_len}"), delete_after=30) return # check for current lock: if self.lock: - await ctx.send(error("Sorry, I am currently busy generating for someone else! Please wait a few moments.")) + await ctx.send( + error("Sorry, I am currently busy generating for someone else! Please wait a few moments."), + delete_after=30, + ) return ### lock if enabled diff --git a/suggestion/discord_thread_feature.py b/suggestion/discord_thread_feature.py new file mode 100644 index 0000000..4957dc5 --- /dev/null +++ b/suggestion/discord_thread_feature.py @@ -0,0 +1,84 @@ +# NOTE: this file contains backports or unintroduced features of next versions of dpy (as for 1.7.3) +import discord +from discord.http import Route + + +async def create_thread(bot, channel: discord.TextChannel, message: discord.Message, name: str, archive: int = 1440): + """ + Creates a new thread in the channel from the message + + Args: + channel (TextChannel): The channel the thread will be apart of + message (Message): The discord message the thread will start with + name (str): The name of the thread + archive (int): The archive duration. Can be 60, 1440, 4320, and 10080. + + Returns: + int: The channel ID of the newly created thread + + Note: + The guild must be boosted for longer thread durations then a day. The archive parameter will automatically be scaled down if the feature is not present. + + Raises HTTPException 400 if thread creation fails + """ + guild = channel.guild + if archive > 4320 and "THREE_DAY_THREAD_ARCHIVE" not in guild.features: + archive = 1440 + elif archive == 10080 and "SEVEN_DAY_THREAD_ARCHIVE" not in guild.features: + archive = 4320 + + fields = {"name": name, "auto_archive_duration": archive} + reason = "Thread Manager" + + r = Route( + "POST", + "/channels/{channel_id}/messages/{message_id}/threads", + channel_id=channel.id, + message_id=message.id, + ) + + return (await bot.http.request(r, json=fields, reason=reason))["id"] + + +async def add_user_thread(bot, channel: int, member: discord.Member): + """ + Add a user to a thread + + Args: + channel (int): The channel id that represents the thread + member (Member): The member to add to the thread + """ + reason = "Thread Manager" + + r = Route( + "POST", + "/channels/{channel_id}/thread-members/{user_id}", + channel_id=channel, + user_id=member.id, + ) + + return await bot.http.request(r, reason=reason) + + +async def get_active_threads(bot, guild: discord.Guild): + """ + Get all active threads in the guild + + Args: + guild (Guild): The guild to get active threads in + + Returns: + list(int): List of thread IDs of each actuvate thread + """ + + reason = "Thread Manager" + + r = Route( + "GET", + "/guilds/{guild_id}/threads/active", + guild_id=guild.id, + ) + + res = await bot.http.request(r, reason=reason) + + return [t["id"] for t in res["threads"]] diff --git a/suggestion/suggestion.py b/suggestion/suggestion.py index ec4fc0d..893b894 100644 --- a/suggestion/suggestion.py +++ b/suggestion/suggestion.py @@ -11,6 +11,7 @@ from redbot.core.utils.menus import start_adding_reactions from redbot.core.utils.antispam import AntiSpam from redbot.core.bot import Red +from .discord_thread_feature import create_thread, add_user_thread class Suggestion(commands.Cog): @@ -37,6 +38,7 @@ class Suggestion(commands.Cog): up_emoji=None, down_emoji=None, delete_suggest=False, + create_threads=False, ) self.config.register_global(toggle=False, server_id=None, channel_id=None, next_id=1, ignore=[]) self.config.init_custom("SUGGESTION", 2) @@ -119,6 +121,15 @@ class Suggestion(commands.Cog): await ctx.message.delete() else: await ctx.tick() + + if await self.config.guild(ctx.guild).create_threads(): + # always use max archive, function will clip it if needed + try: + thread = await create_thread(self.bot, channel, msg, name=content, archive=10080) + await add_user_thread(self.bot, thread, ctx.author) + except: + await ctx.send("Error in creating a thread for this suggestion, please check permissions!") + try: await ctx.author.send(content="Your suggestion has been sent for approval!", embed=embed) except discord.Forbidden: @@ -412,6 +423,14 @@ class Suggestion(commands.Cog): """Suggestion settings""" pass + @setsuggest.command(name="threads") + async def setsuggest_threads(self, ctx: commands.Context, toggle: bool): + """ + Enable automatic thread creation for each suggestion, for discussion + """ + await self.config.guild(ctx.guild).create_threads.set(toggle) + await ctx.tick() + @checks.bot_has_permissions(manage_channels=True) @setsuggest.command(name="setup") async def setsuggest_setup(self, ctx: commands.Context): @@ -563,6 +582,20 @@ class Suggestion(commands.Cog): rejected = predchan.result await self.config.guild(ctx.guild).reject_id.set(rejected.id) await msg.delete() + + msg = await ctx.send( + "Do you want to automatically create threads for each suggestion to allow discussion of each suggestion to be seperated?" + ) + start_adding_reactions(msg, ReactionPredicate.YES_OR_NO_EMOJIS) + pred = ReactionPredicate.yes_or_no(msg, ctx.author) + try: + await self.bot.wait_for("reaction_add", timeout=30, check=pred) + except asyncio.TimeoutError: + await msg.delete() + return await ctx.send("You took too long. Try again, please.") + if pred.result: + await self.config.guild(ctx.guild).create_threads.set(True) + await ctx.send("You have finished the setup! Please, move your channels to the category you want them in.") @checks.bot_has_permissions(add_reactions=True) @@ -596,7 +629,9 @@ class Suggestion(commands.Cog): @checks.bot_has_permissions(manage_messages=True) @setsuggest.command(name="autodelete") async def setsuggest_autodelete(self, ctx: commands.Context, on_off: bool = None): - """Toggle whether after `[p]suggest`, the bot deletes the message.""" + """ + Toggle whether after `[p]suggest`, the bot deletes the message. + """ target_state = on_off if on_off else not (await self.config.guild(ctx.guild).delete_suggest()) await self.config.guild(ctx.guild).delete_suggest.set(target_state) if target_state: diff --git a/threadmanager/__init__.py b/threadmanager/__init__.py new file mode 100644 index 0000000..bd86667 --- /dev/null +++ b/threadmanager/__init__.py @@ -0,0 +1,7 @@ +from .thread_manager import ThreadManager + +__red_end_user_data_statement__ = "This doesn't store any user data." + + +def setup(bot): + bot.add_cog(ThreadManager(bot)) diff --git a/threadmanager/discord_thread_feature.py b/threadmanager/discord_thread_feature.py new file mode 100644 index 0000000..4957dc5 --- /dev/null +++ b/threadmanager/discord_thread_feature.py @@ -0,0 +1,84 @@ +# NOTE: this file contains backports or unintroduced features of next versions of dpy (as for 1.7.3) +import discord +from discord.http import Route + + +async def create_thread(bot, channel: discord.TextChannel, message: discord.Message, name: str, archive: int = 1440): + """ + Creates a new thread in the channel from the message + + Args: + channel (TextChannel): The channel the thread will be apart of + message (Message): The discord message the thread will start with + name (str): The name of the thread + archive (int): The archive duration. Can be 60, 1440, 4320, and 10080. + + Returns: + int: The channel ID of the newly created thread + + Note: + The guild must be boosted for longer thread durations then a day. The archive parameter will automatically be scaled down if the feature is not present. + + Raises HTTPException 400 if thread creation fails + """ + guild = channel.guild + if archive > 4320 and "THREE_DAY_THREAD_ARCHIVE" not in guild.features: + archive = 1440 + elif archive == 10080 and "SEVEN_DAY_THREAD_ARCHIVE" not in guild.features: + archive = 4320 + + fields = {"name": name, "auto_archive_duration": archive} + reason = "Thread Manager" + + r = Route( + "POST", + "/channels/{channel_id}/messages/{message_id}/threads", + channel_id=channel.id, + message_id=message.id, + ) + + return (await bot.http.request(r, json=fields, reason=reason))["id"] + + +async def add_user_thread(bot, channel: int, member: discord.Member): + """ + Add a user to a thread + + Args: + channel (int): The channel id that represents the thread + member (Member): The member to add to the thread + """ + reason = "Thread Manager" + + r = Route( + "POST", + "/channels/{channel_id}/thread-members/{user_id}", + channel_id=channel, + user_id=member.id, + ) + + return await bot.http.request(r, reason=reason) + + +async def get_active_threads(bot, guild: discord.Guild): + """ + Get all active threads in the guild + + Args: + guild (Guild): The guild to get active threads in + + Returns: + list(int): List of thread IDs of each actuvate thread + """ + + reason = "Thread Manager" + + r = Route( + "GET", + "/guilds/{guild_id}/threads/active", + guild_id=guild.id, + ) + + res = await bot.http.request(r, reason=reason) + + return [t["id"] for t in res["threads"]] diff --git a/threadmanager/info.json b/threadmanager/info.json new file mode 100644 index 0000000..e2df4c1 --- /dev/null +++ b/threadmanager/info.json @@ -0,0 +1,8 @@ +{ + "author" : ["brandons209"], + "install_msg" : "Thank you for installing my cog!", + "name" : "Thread Manager", + "short" : "Manage access to threads for users.", + "description" : "Allows a more finer management of users and threads.", + "tags" : ["Threads"] +} diff --git a/threadmanager/thread_manager.py b/threadmanager/thread_manager.py new file mode 100644 index 0000000..f6c9f40 --- /dev/null +++ b/threadmanager/thread_manager.py @@ -0,0 +1,130 @@ +import asyncio +import discord + +from typing import Optional, Literal +from redbot.core import Config, checks, commands + +from .discord_thread_feature import create_thread, add_user_thread, get_active_threads + + +class ThreadManager(commands.Cog): + """ + Better Thread Manager + """ + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=165164165133023130, force_registration=True) + + # allowed roles maps role id (str) -> number of threads each user can create with this role (int) + # threads maps member id str -> list of active thread ids (int) that the user created in the channel + default_channel = {"allowed_roles": {}, "threads": {}} + default_guild = {"archive": 60} + self.config.register_channel(**default_channel) + self.config.register_guild(**default_guild) + + @commands.group() + @commands.guild_only() + @checks.admin_or_permissions(administrator=True) + async def threadset(self, ctx): + """ + Manage threads + """ + pass + + @threadset.command(name="archive") + async def threadset_archive(self, ctx, archive: int): + """ + Set the archive duration of user created threads + + Must be one of: 60, 1440, 4320, and 10080 + If your guild doesn't have longer thread archival features, the archive value is clipped to the highest value available. + """ + if archive not in [60, 1440, 4320, 10080]: + return await ctx.send("Invalid archive time, try again.", delete_after=30) + + await self.config.guild(ctx.guild).archive.set(archive) + await ctx.tick() + + @threadset.command(name="add") + async def threadset_add(self, ctx, channel: discord.TextChannel, num_threads: int, *, role: discord.Role): + """ + Set the number for threads anyone with role can create for channel + + If a user has multiple roles, whatever role has the highest value is used + """ + async with self.config.channel(channel).allowed_roles() as allowed_roles: + allowed_roles[str(role.id)] = num_threads + await ctx.tick() + + @threadset.command(name="del") + async def threadset_del(self, ctx, channel: discord.TextChannel, *, role: discord.Role): + """ + Delete a role from a channel + + Does not cleanup threads currently active + """ + async with self.config.channel(channel).allowed_roles() as allowed_roles: + if str(role.id) in allowed_roles: + del allowed_roles[str(role.id)] + + await ctx.tick() + + @commands.command() + @commands.guild_only() + async def thread(self, ctx, *, name: str): + """ + Create a new thread from this channel + + You must have proper permissions set + """ + channel = ctx.channel + guild = ctx.guild + user = ctx.author + + allowed_roles = await self.config.channel(channel).allowed_roles() + roles = {int(r) for r in allowed_roles.keys()} + u_roles = {r.id for r in user.roles} + + if not (roles & u_roles): + return await ctx.send( + "Sorry, you do not have a role that allows you to create threads here.", delete_after=15 + ) + + possible_roles = roles & u_roles + num_threads = sorted([allowed_roles[str(r)] for r in possible_roles])[-1] + + threads = await self.config.channel(channel).threads() + if str(user.id) not in threads: + threads[str(user.id)] = [] + + user_threads = threads[str(user.id)] + if len(user_threads) >= num_threads: + # first, need to update active threads for this channel + activate_threads = set(await get_active_threads(self.bot, guild)) + still_active = set(user_threads) & activate_threads + + # remove not active threads + user_threads = [t for t in user_threads if t in still_active] + # update config + threads[str(user.id)] = user_threads + await self.config.channel(channel).threads.set(threads) + + if len(user_threads) >= num_threads: + return await ctx.send( + f"You have reached the maximum number ({num_threads}) of threads you can create for this channel. Please have a staff member archive one of your threads.", + delete_after=15, + ) + + # now we can create a thread + archive = await self.config.guild(guild).archive() + try: + thread = await create_thread(self.bot, channel, ctx.message, name=name, archive=archive) + await add_user_thread(self.bot, thread, user) + except: + return await ctx.send( + "Something went wrong, most likely a permissions issue. Please contact a staff member.", delete_after=30 + ) + + threads[str(user.id)].append(thread) + await self.config.channel(channel).threads.set(threads) diff --git a/warnings_custom/warnings.py b/warnings_custom/warnings.py index f6dacec..92dae7f 100644 --- a/warnings_custom/warnings.py +++ b/warnings_custom/warnings.py @@ -422,28 +422,30 @@ class Warnings_Custom(commands.Cog): # get context of reason, if provided context = "" if await self.config.guild(guild).allow_context(): - msg = await ctx.send("Would you like to provide more context to the warning? (react with yes or no)") + msg = await ctx.send( + "Would you like to provide more context to the warning? (react with yes or no)", delete_after=31 + ) start_adding_reactions(msg, ReactionPredicate.YES_OR_NO_EMOJIS) pred = ReactionPredicate.yes_or_no(msg, ctx.author) try: await self.bot.wait_for("reaction_add", check=pred, timeout=30) except asyncio.TimeoutError: - await ctx.send(error("Took too long, cancelling warning!")) + await ctx.send(error("Took too long, cancelling warning!"), delete_after=30) return if pred.result: done = False while not done: - await ctx.send("Please provide context as text and/or an attachment.") + await ctx.send("Please provide context as text and/or an attachment.", delete_after=240) pred = MessagePredicate.same_context(ctx) try: msg = await self.bot.wait_for("message", check=pred, timeout=240) except asyncio.TimeoutError: - await ctx.send(error("Took too long, cancelling warning!")) + await ctx.send(error("Took too long, cancelling warning!"), delete_after=30) return - yes_or_no = await ctx.send("Continue with provided context? React no to redo.") + yes_or_no = await ctx.send("Continue with provided context? React no to redo.", delete_after=31) start_adding_reactions(yes_or_no, ReactionPredicate.YES_OR_NO_EMOJIS) pred = ReactionPredicate.yes_or_no(yes_or_no, ctx.author) try: