diff --git a/bot/constants.py b/bot/constants.py index b3757a74..297c49a9 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,2 +1,2 @@ CONFIG_FILE = "config/bot.conf" -DATA_VERSION = 9 +DATA_VERSION = 10 diff --git a/bot/core/data.py b/bot/core/data.py index b6b0e7b3..58c1331b 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -74,7 +74,7 @@ def add_pending(pending): """ UPDATE members SET - coins = coins + t.coin_diff + coins = LEAST(coins + t.coin_diff, 2147483647) FROM (VALUES %s) AS diff --git a/bot/core/lion.py b/bot/core/lion.py index 969c3e4f..b63b56bb 100644 --- a/bot/core/lion.py +++ b/bot/core/lion.py @@ -7,6 +7,8 @@ from meta import client from data import tables as tb from settings import UserSettings, GuildSettings +from LionContext import LionContext + class Lion: """ @@ -63,7 +65,11 @@ class Lion: return (self.guildid, self.userid) @property - def member(self): + def guild(self) -> discord.Guild: + return client.get_guild(self.guildid) + + @property + def member(self) -> discord.Member: """ The discord `Member` corresponding to this user. May be `None` if the member is no longer in the guild or the caches aren't populated. @@ -110,6 +116,15 @@ class Lion: """ return GuildSettings(self.guildid) + @property + def ctx(self) -> LionContext: + """ + Manufacture a `LionContext` with the lion member as an author. + Useful for accessing member context utilities. + Be aware that `author` may be `None` if the member was not cached. + """ + return LionContext(client, guild=self.guild, author=self.member) + @property def time(self): """ @@ -246,6 +261,20 @@ class Lion: return remaining + @property + def profile_tags(self): + """ + Returns a list of profile tags, or the default tags. + """ + tags = tb.profile_tags.queries.get_tags_for(self.guildid, self.userid) + prefix = self.ctx.best_prefix + return tags or [ + f"Use {prefix}setprofile", + "and add your tags", + "to this section", + f"See {prefix}help setprofile for more" + ] + @property def name(self): """ @@ -260,7 +289,6 @@ class Lion: return name - def update_saved_data(self, member: discord.Member): """ Update the stored discord data from the givem member. @@ -280,11 +308,11 @@ class Lion: timezone = self.settings.timezone.value return naive_utc_dt.replace(tzinfo=pytz.UTC).astimezone(timezone) - def addCoins(self, amount, flush=True, ignorebonus=False): + def addCoins(self, amount, flush=True, bonus=False): """ Add coins to the user, optionally store the transaction in pending. """ - self._pending_coins += amount * (1 if ignorebonus else self.economy_bonus) + self._pending_coins += amount * (self.economy_bonus if bonus else 1) self._pending[self.key] = self if flush: self.flush() diff --git a/bot/data/interfaces.py b/bot/data/interfaces.py index 7673b0e0..501acdc0 100644 --- a/bot/data/interfaces.py +++ b/bot/data/interfaces.py @@ -188,7 +188,7 @@ class RowTable(Table): self.columns = columns self.id_col = id_col self.multi_key = isinstance(id_col, tuple) - self.row_cache = (cache or LRUCache(cache_size)) if use_cache else None + self.row_cache = (cache if cache is not None else LRUCache(cache_size)) if use_cache else None def id_from_row(self, row): if self.multi_key: diff --git a/bot/meta/config.py b/bot/meta/config.py index e0913c55..819bdf42 100644 --- a/bot/meta/config.py +++ b/bot/meta/config.py @@ -33,6 +33,33 @@ class configEmoji(PartialEmoji): ) +class MapDotProxy: + """ + Allows dot access to an underlying Mappable object. + """ + __slots__ = ("_map", "_converter") + + def __init__(self, mappable, converter=None): + self._map = mappable + self._converter = converter + + def __getattribute__(self, key): + _map = object.__getattribute__(self, '_map') + if key == '_map': + return _map + if key in _map: + _converter = object.__getattribute__(self, '_converter') + if _converter: + return _converter(_map[key]) + else: + return _map[key] + else: + return object.__getattribute__(_map, key) + + def __getitem__(self, key): + return self._map.__getitem__(key) + + class Conf: def __init__(self, configfile, section_name="DEFAULT"): self.configfile = configfile @@ -49,9 +76,12 @@ class Conf: self.section_name = section_name if section_name in self.config else 'DEFAULT' self.default = self.config["DEFAULT"] - self.section = self.config[self.section_name] + self.section = MapDotProxy(self.config[self.section_name]) self.bot = self.section - self.emojis = self.config['EMOJIS'] if 'EMOJIS' in self.config else self.section + self.emojis = MapDotProxy( + self.config['EMOJIS'] if 'EMOJIS' in self.config else self.section, + converter=configEmoji.from_str + ) # Config file recursion, read in configuration files specified in every "ALSO_READ" key. more_to_read = self.section.getlist("ALSO_READ", []) diff --git a/bot/modules/accountability/TimeSlot.py b/bot/modules/accountability/TimeSlot.py index fa2b8f98..43f3d664 100644 --- a/bot/modules/accountability/TimeSlot.py +++ b/bot/modules/accountability/TimeSlot.py @@ -427,7 +427,7 @@ class TimeSlot: reward += guild_settings.accountability_bonus.value for memid in self.members: - Lion.fetch(self.guild.id, memid).addCoins(reward) + Lion.fetch(self.guild.id, memid).addCoins(reward, bonus=True) async def cancel(self): """ diff --git a/bot/modules/accountability/admin.py b/bot/modules/accountability/admin.py index 6f72df96..2e26a42f 100644 --- a/bot/modules/accountability/admin.py +++ b/bot/modules/accountability/admin.py @@ -84,7 +84,7 @@ class accountability_price(settings.Integer, GuildSetting): display_name = "session_price" desc = "Cost of booking a scheduled session." - _default = 100 + _default = 500 long_desc = ( "The price of booking each one hour scheduled session slot." @@ -106,7 +106,7 @@ class accountability_bonus(settings.Integer, GuildSetting): display_name = "session_bonus" desc = "Bonus given when everyone attends a scheduled session slot." - _default = 1000 + _default = 750 long_desc = ( "The extra bonus given to each scheduled session member when everyone who booked attended the session." @@ -128,7 +128,7 @@ class accountability_reward(settings.Integer, GuildSetting): display_name = "session_reward" desc = "The individual reward given when a member attends their booked scheduled session." - _default = 200 + _default = 500 long_desc = ( "Reward given to a member who attends a booked scheduled session." diff --git a/bot/modules/economy/__init__.py b/bot/modules/economy/__init__.py index 0dc48d5f..8784ca9e 100644 --- a/bot/modules/economy/__init__.py +++ b/bot/modules/economy/__init__.py @@ -1,5 +1,4 @@ from .module import module -from . import cointop_cmd from . import send_cmd from . import shop_cmds diff --git a/bot/modules/economy/cointop_cmd.py b/bot/modules/economy/cointop_cmd.py deleted file mode 100644 index 81bdbad9..00000000 --- a/bot/modules/economy/cointop_cmd.py +++ /dev/null @@ -1,114 +0,0 @@ -from cmdClient.checks import in_guild - -import data -from data import tables -from core import Lion -from utils import interactive # noqa - -from .module import module - - -first_emoji = "🥇" -second_emoji = "🥈" -third_emoji = "🥉" - - -@module.cmd( - "cointop", - group="Economy", - desc="View the LionCoin leaderboard.", - aliases=('topc', 'ctop', 'topcoins', 'topcoin', 'cointop100'), - help_aliases={'cointop100': "View the LionCoin top 100."} -) -@in_guild() -async def cmd_topcoin(ctx): - """ - Usage``: - {prefix}cointop - {prefix}cointop 100 - Description: - Display the LionCoin leaderboard, or top 100. - - Use the paging reactions or send `p` to switch pages (e.g. `p11` to switch to page 11). - """ - # Handle args - if ctx.args and not ctx.args == "100": - return await ctx.error_reply( - "**Usage:**`{prefix}topcoin` or `{prefix}topcoin100`.".format(prefix=ctx.best_prefix) - ) - top100 = (ctx.args == "100" or ctx.alias == "cointop100") - - # Flush any pending coin transactions - Lion.sync() - - # Fetch the leaderboard - exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members) - exclude.update(ctx.client.user_blacklist()) - exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) - - args = { - 'guildid': ctx.guild.id, - 'select_columns': ('userid', 'total_coins::INTEGER'), - '_extra': "AND total_coins > 0 ORDER BY total_coins DESC " + ("LIMIT 100" if top100 else "") - } - if exclude: - args['userid'] = data.NOT(list(exclude)) - - user_data = tables.members_totals.select_where(**args) - - # Quit early if the leaderboard is empty - if not user_data: - return await ctx.reply("No leaderboard entries yet!") - - # Extract entries - author_index = None - entries = [] - for i, (userid, coins) in enumerate(user_data): - member = ctx.guild.get_member(userid) - name = member.display_name if member else str(userid) - name = name.replace('*', ' ').replace('_', ' ') - - num_str = "{}.".format(i+1) - - coin_str = "{} LC".format(coins) - - if ctx.author.id == userid: - author_index = i - - entries.append((num_str, name, coin_str)) - - # Extract blocks - blocks = [entries[i:i+20] for i in range(0, len(entries), 20)] - block_count = len(blocks) - - # Build strings - header = "LionCoin Top 100" if top100 else "LionCoin Leaderboard" - if block_count > 1: - header += " (Page {{page}}/{})".format(block_count) - - # Build pages - pages = [] - for i, block in enumerate(blocks): - max_num_l, max_name_l, max_coin_l = [max(len(e[i]) for e in block) for i in (0, 1, 2)] - body = '\n'.join( - "{:>{}} {:<{}} \t {:>{}} {} {}".format( - entry[0], max_num_l, - entry[1], max_name_l + 2, - entry[2], max_coin_l + 1, - first_emoji if i == 0 and j == 0 else ( - second_emoji if i == 0 and j == 1 else ( - third_emoji if i == 0 and j == 2 else '' - ) - ), - "⮜" if author_index is not None and author_index == i * 20 + j else "" - ) - for j, entry in enumerate(block) - ) - title = header.format(page=i+1) - line = '='*len(title) - pages.append( - "```md\n{}\n{}\n{}```".format(title, line, body) - ) - - # Finally, page the results - await ctx.pager(pages, start_at=(author_index or 0)//20 if not top100 else 0) diff --git a/bot/modules/economy/send_cmd.py b/bot/modules/economy/send_cmd.py index db997f22..5086ee67 100644 --- a/bot/modules/economy/send_cmd.py +++ b/bot/modules/economy/send_cmd.py @@ -60,7 +60,7 @@ async def cmd_send(ctx): return await ctx.embed_reply("We are still waiting for {} to open an account.".format(target.mention)) # Finally, send the amount and the ack message - target_lion.addCoins(amount, ignorebonus=True) + target_lion.addCoins(amount) source_lion.addCoins(-amount) embed = discord.Embed( diff --git a/bot/modules/guild_admin/economy/set_coins.py b/bot/modules/guild_admin/economy/set_coins.py index cbbf85d9..c0744a13 100644 --- a/bot/modules/guild_admin/economy/set_coins.py +++ b/bot/modules/guild_admin/economy/set_coins.py @@ -61,10 +61,10 @@ async def cmd_set(ctx): # Postgres `coins` column is `integer`, sanity check postgres int limits - which are smalled than python int range target_coins_to_set = target_lion.coins + amount if target_coins_to_set >= 0 and target_coins_to_set <= POSTGRES_INT_MAX: - target_lion.addCoins(amount, ignorebonus=True) + target_lion.addCoins(amount) elif target_coins_to_set < 0: target_coins_to_set = -target_lion.coins # Coins cannot go -ve, cap to 0 - target_lion.addCoins(target_coins_to_set, ignorebonus=True) + target_lion.addCoins(target_coins_to_set) target_coins_to_set = 0 else: return await ctx.embed_reply("Member coins cannot be more than {}".format(POSTGRES_INT_MAX)) diff --git a/bot/modules/guild_admin/new_members/greetings.py b/bot/modules/guild_admin/new_members/greetings.py index 74ebe980..ec06fd38 100644 --- a/bot/modules/guild_admin/new_members/greetings.py +++ b/bot/modules/guild_admin/new_members/greetings.py @@ -1,5 +1,5 @@ import discord -from cmdClient.Context import Context +from LionContext import LionContext as Context from meta import client diff --git a/bot/modules/guild_admin/new_members/settings.py b/bot/modules/guild_admin/new_members/settings.py index 8c5cde2c..307d8d42 100644 --- a/bot/modules/guild_admin/new_members/settings.py +++ b/bot/modules/guild_admin/new_members/settings.py @@ -203,7 +203,7 @@ class starting_funds(stypes.Integer, GuildSetting): "Members will be given this number of coins the first time they join the server." ) - _default = 0 + _default = 1000 @property def success_response(self): diff --git a/bot/modules/guild_admin/reaction_roles/command.py b/bot/modules/guild_admin/reaction_roles/command.py index 0f915ffa..ab9de7b1 100644 --- a/bot/modules/guild_admin/reaction_roles/command.py +++ b/bot/modules/guild_admin/reaction_roles/command.py @@ -227,6 +227,8 @@ async def cmd_reactionroles(ctx, flags): For example to disable event logging, run `{prefix}rroles link --log off`. For per-reaction settings, instead use `{prefix}rroles link emoji --setting value`. + + *(!) Replace `setting` with one of the settings below!* Message Settings:: maximum: Maximum number of roles obtainable from this message. log: Whether to log reaction role usage into the event log. @@ -235,7 +237,7 @@ async def cmd_reactionroles(ctx, flags): default_price: The default price of each role on this message. required_role: The role required to use these reactions roles. Reaction Settings:: - price: The price of this reaction role. + price: The price of this reaction role. (May be negative for a reward.) tduration: How long this role will last after being selected or bought. Configuration Examples``: {prefix}rroles {ctx.msg.id} --maximum 5 @@ -350,6 +352,7 @@ async def cmd_reactionroles(ctx, flags): elif not target_id: # Confirm enabling of all reaction messages await reaction_ask( + ctx, "Are you sure you want to enable all reaction role messages in this server?", timeout_msg="Prompt timed out, no reaction roles enabled.", cancel_msg="User cancelled, no reaction roles enabled." @@ -390,6 +393,7 @@ async def cmd_reactionroles(ctx, flags): elif not target_id: # Confirm disabling of all reaction messages await reaction_ask( + ctx, "Are you sure you want to disable all reaction role messages in this server?", timeout_msg="Prompt timed out, no reaction roles disabled.", cancel_msg="User cancelled, no reaction roles disabled." @@ -429,6 +433,7 @@ async def cmd_reactionroles(ctx, flags): elif not target_id: # Confirm disabling of all reaction messages await reaction_ask( + ctx, "Are you sure you want to remove all reaction role messages in this server?", timeout_msg="Prompt timed out, no messages removed.", cancel_msg="User cancelled, no messages removed." @@ -909,7 +914,8 @@ async def cmd_reactionroles(ctx, flags): "{settings_table}\n" "To update a message setting: `{prefix}rroles messageid --setting value`\n" "To update an emoji setting: `{prefix}rroles messageid emoji --setting value`\n" - "See examples and more usage information with `{prefix}help rroles`." + "See examples and more usage information with `{prefix}help rroles`.\n" + "**(!) Replace the `setting` with one of the settings on this page.**\n" ).format( prefix=ctx.best_prefix, settings_table=target.settings.tabulated() diff --git a/bot/modules/guild_admin/reaction_roles/settings.py b/bot/modules/guild_admin/reaction_roles/settings.py index dbb4b8cf..530eadbd 100644 --- a/bot/modules/guild_admin/reaction_roles/settings.py +++ b/bot/modules/guild_admin/reaction_roles/settings.py @@ -191,7 +191,7 @@ class price(setting_types.Integer, ReactionSetting): _data_column = 'price' display_name = "price" - desc = "Price of this reaction role." + desc = "Price of this reaction role (may be negative)." long_desc = ( "The number of coins that will be deducted from the user when this reaction is used.\n" diff --git a/bot/modules/guild_admin/reaction_roles/tracker.py b/bot/modules/guild_admin/reaction_roles/tracker.py index bfc43ba5..1ab71fe6 100644 --- a/bot/modules/guild_admin/reaction_roles/tracker.py +++ b/bot/modules/guild_admin/reaction_roles/tracker.py @@ -174,7 +174,7 @@ class ReactionRoleMessage: Returns the generated `ReactionRoleReaction`s for convenience. """ # Fetch reactions and pre-populate reaction cache - rows = reaction_role_reactions.fetch_rows_where(messageid=self.messageid) + rows = reaction_role_reactions.fetch_rows_where(messageid=self.messageid, _extra="ORDER BY reactionid ASC") reactions = [ReactionRoleReaction(row.reactionid) for row in rows] self._reactions[self.messageid] = reactions return reactions @@ -425,7 +425,7 @@ class ReactionRoleMessage: self.message_link, role.mention, member.mention, - " for `{}` coins.".format(price) if price else '', + " for `{}` coins".format(price) if price else '', "\nThis role will expire at .".format( expiry.timestamp() ) if expiry else '' @@ -501,7 +501,7 @@ class ReactionRoleMessage: if price and refund: # Give the user the refund lion = Lion.fetch(self.guild.id, member.id) - lion.addCoins(price, ignorebonus=True) + lion.addCoins(price) # Notify the user embed = discord.Embed( @@ -548,7 +548,7 @@ class ReactionRoleMessage: @client.add_after_event('raw_reaction_add') async def reaction_role_add(client, payload): reaction_message = ReactionRoleMessage.fetch(payload.message_id) - if payload.guild_id and not payload.member.bot and reaction_message and reaction_message.enabled: + if payload.guild_id and payload.user_id != client.user.id and reaction_message and reaction_message.enabled: try: await reaction_message.process_raw_reaction_add(payload) except Exception: diff --git a/bot/modules/meta/__init__.py b/bot/modules/meta/__init__.py index d1888a17..3803e00a 100644 --- a/bot/modules/meta/__init__.py +++ b/bot/modules/meta/__init__.py @@ -1,3 +1,7 @@ +# flake8: noqa from .module import module from . import help +from . import links +from . import nerd +from . import join_message diff --git a/bot/modules/meta/help.py b/bot/modules/meta/help.py index ada81e18..1329fc02 100644 --- a/bot/modules/meta/help.py +++ b/bot/modules/meta/help.py @@ -6,11 +6,15 @@ from utils import interactive, ctx_addons # noqa from wards import is_guild_admin from .module import module +from .lib import guide_link +new_emoji = " 🆕" +new_commands = {'achievements', 'nerd', 'invite', 'support'} + # Set the command groups to appear in the help group_hints = { - '🆕 Pomodoro': "*Stay in sync with your friends using our timers!*", + 'Pomodoro': "*Stay in sync with your friends using our timers!*", 'Productivity': "*Use these to help you stay focused and productive!*", 'Statistics': "*StudyLion leaderboards and study statistics.*", 'Economy': "*Buy, sell, and trade with your hard-earned coins!*", @@ -21,22 +25,22 @@ group_hints = { } standard_group_order = ( - ('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings', 'Meta'), + ('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings', 'Meta'), ) mod_group_order = ( ('Moderation', 'Meta'), - ('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') + ('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') ) admin_group_order = ( ('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'), - ('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') + ('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') ) bot_admin_group_order = ( ('Bot Admin', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'), - ('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') + ('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') ) # Help embed format @@ -46,14 +50,15 @@ header = """ [StudyLion](https://bot.studylions.com/) is a fully featured study assistant \ that tracks your study time and offers productivity tools \ such as to-do lists, task reminders, private study rooms, group accountability sessions, and much much more.\n -Use `{ctx.best_prefix}help ` (e.g. `{ctx.best_prefix}help send`) to learn how to use each command, \ - or [click here](https://discord.studylions.com/tutorial) for a comprehensive tutorial. -""" +Use `{{ctx.best_prefix}}help ` (e.g. `{{ctx.best_prefix}}help send`) to learn how to use each command, \ + or [click here]({guide_link}) for a comprehensive tutorial. +""".format(guide_link=guide_link) @module.cmd("help", group="Meta", - desc="StudyLion command list.") + desc="StudyLion command list.", + aliases=('man', 'ls', 'list')) async def cmd_help(ctx): """ Usage``: @@ -168,7 +173,9 @@ async def cmd_help(ctx): cmd_groups[group] = cmd_group # Add the command name and description to the group - cmd_group.append((command.name, getattr(command, 'desc', ''))) + cmd_group.append( + (command.name, (getattr(command, 'desc', '') + (new_emoji if command.name in new_commands else ''))) + ) # Add any required aliases for alias, desc in getattr(command, 'help_aliases', {}).items(): @@ -178,7 +185,12 @@ async def cmd_help(ctx): stringy_cmd_groups = {} for group_name, cmd_group in cmd_groups.items(): cmd_group.sort(key=lambda tup: len(tup[0])) - stringy_cmd_groups[group_name] = prop_tabulate(*zip(*cmd_group)) + if ctx.alias == 'ls': + stringy_cmd_groups[group_name] = ', '.join( + f"`{name}`" for name, _ in cmd_group + ) + else: + stringy_cmd_groups[group_name] = prop_tabulate(*zip(*cmd_group)) # Now put everything into a bunch of embeds if await is_owner.run(ctx): diff --git a/bot/modules/meta/join_message.py b/bot/modules/meta/join_message.py new file mode 100644 index 00000000..dc3eaec3 --- /dev/null +++ b/bot/modules/meta/join_message.py @@ -0,0 +1,46 @@ +import discord + +from cmdClient import cmdClient + +from meta import client, conf +from .lib import guide_link, animation_link + + +message = """ +Thank you for inviting me to your community. +Get started by typing `{prefix}help` to see my commands, and `{prefix}config info` \ + to read about my configuration options! + +To learn how to configure me and use all of my features, \ + make sure to [click here]({guide_link}) to read our full setup guide. + +Remember, if you need any help configuring me, \ + want to suggest a feature, report a bug and stay updated, \ + make sure to join our main support and study server by [clicking here]({support_link}). + +Best of luck with your studies! + +""".format( + guide_link=guide_link, + support_link=conf.bot.get('support_link'), + prefix=client.prefix +) + + +@client.add_after_event('guild_join', priority=0) +async def post_join_message(client: cmdClient, guild: discord.Guild): + if (channel := guild.system_channel) and channel.permissions_for(guild.me).embed_links: + embed = discord.Embed( + description=message + ) + embed.set_author( + name="Hello everyone! My name is Leo, the StudyLion!", + icon_url="https://cdn.discordapp.com/emojis/933610591459872868.webp" + ) + embed.set_image(url=animation_link) + try: + await channel.send(embed=embed) + except discord.HTTPException: + # Something went wrong sending the hi message + # Not much we can do about this + pass diff --git a/bot/modules/meta/lib.py b/bot/modules/meta/lib.py new file mode 100644 index 00000000..22b42474 --- /dev/null +++ b/bot/modules/meta/lib.py @@ -0,0 +1,5 @@ +guide_link = "https://discord.studylions.com/tutorial" + +animation_link = ( + "https://media.discordapp.net/attachments/879412267731542047/926837189814419486/ezgif.com-resize.gif" +) diff --git a/bot/modules/meta/links.py b/bot/modules/meta/links.py new file mode 100644 index 00000000..476caf26 --- /dev/null +++ b/bot/modules/meta/links.py @@ -0,0 +1,57 @@ +import discord + +from meta import conf + +from LionContext import LionContext as Context + +from .module import module +from .lib import guide_link + + +@module.cmd( + "support", + group="Meta", + desc=f"Have a question? Join my [support server]({conf.bot.get('support_link')})" +) +async def cmd_support(ctx: Context): + """ + Usage``: + {prefix}support + Description: + Replies with an invite link to my support server. + """ + await ctx.reply( + f"Click here to join my support server: {conf.bot.get('support_link')}" + ) + + +@module.cmd( + "invite", + group="Meta", + desc=f"[Invite me]({conf.bot.get('invite_link')}) to your server so I can help your members stay productive!" +) +async def cmd_invite(ctx: Context): + """ + Usage``: + {prefix}invite + Description: + Replies with my invite link so you can add me to your server. + """ + embed = discord.Embed( + colour=discord.Colour.orange(), + description=f"Click here to add me to your server: {conf.bot.get('invite_link')}" + ) + embed.add_field( + name="Setup tips", + value=( + "Remember to check out `{prefix}help` for the full command list, " + "and `{prefix}config info` for the configuration options.\n" + "[Click here]({guide}) for our comprehensive setup tutorial, and if you still have questions you can " + "join our support server [here]({support}) to talk to our friendly support team!" + ).format( + prefix=ctx.best_prefix, + support=conf.bot.get('support_link'), + guide=guide_link + ) + ) + await ctx.reply(embed=embed) diff --git a/bot/modules/meta/nerd.py b/bot/modules/meta/nerd.py new file mode 100644 index 00000000..8eb0930d --- /dev/null +++ b/bot/modules/meta/nerd.py @@ -0,0 +1,144 @@ +import datetime +import asyncio +import discord +import psutil +import sys +import gc + +from data import NOTNULL +from data.queries import select_where +from utils.lib import prop_tabulate, utc_now + +from LionContext import LionContext as Context + +from .module import module + + +process = psutil.Process() +process.cpu_percent() + + +@module.cmd( + "nerd", + group="Meta", + desc="Information and statistics about me!" +) +async def cmd_nerd(ctx: Context): + """ + Usage``: + {prefix}nerd + Description: + View nerdy information and statistics about me! + """ + # Create embed + embed = discord.Embed( + colour=discord.Colour.orange(), + title="Nerd Panel", + description=( + "Hi! I'm [StudyLion]({studylion}), a study management bot owned by " + "[Ari Horesh]({ari}) and developed by [Conatum#5317]({cona}), with [contributors]({github})." + ).format( + studylion="http://studylions.com/", + ari="https://arihoresh.com/", + cona="https://github.com/Intery", + github="https://github.com/StudyLions/StudyLion" + ) + ) + + # ----- Study stats ----- + # Current studying statistics + current_students, current_channels, current_guilds= ( + ctx.client.data.current_sessions.select_one_where( + select_columns=( + "COUNT(*) AS studying_count", + "COUNT(DISTINCT(channelid)) AS channel_count", + "COUNT(DISTINCT(guildid)) AS guild_count" + ) + ) + ) + + # Past studying statistics + past_sessions, past_students, past_duration, past_guilds = ctx.client.data.session_history.select_one_where( + select_columns=( + "COUNT(*) AS session_count", + "COUNT(DISTINCT(userid)) AS user_count", + "SUM(duration) / 3600 AS total_hours", + "COUNT(DISTINCT(guildid)) AS guild_count" + ) + ) + + # Tasklist statistics + tasks = ctx.client.data.tasklist.select_one_where( + select_columns=( + 'COUNT(*)' + ) + )[0] + + tasks_completed = ctx.client.data.tasklist.select_one_where( + completed_at=NOTNULL, + select_columns=( + 'COUNT(*)' + ) + )[0] + + # Timers + timer_count, timer_guilds = ctx.client.data.timers.select_one_where( + select_columns=("COUNT(*)", "COUNT(DISTINCT(guildid))") + ) + + study_fields = { + "Currently": f"`{current_students}` people working in `{current_channels}` rooms of `{current_guilds}` guilds", + "Recorded": f"`{past_duration}` hours from `{past_students}` people across `{past_sessions}` sessions", + "Tasks": f"`{tasks_completed}` out of `{tasks}` tasks completed", + "Timers": f"`{timer_count}` timers running in `{timer_guilds}` communities" + } + study_table = prop_tabulate(*zip(*study_fields.items())) + + # ----- Shard statistics ----- + shard_number = ctx.client.shard_id + shard_count = ctx.client.shard_count + guilds = len(ctx.client.guilds) + member_count = sum(guild.member_count for guild in ctx.client.guilds) + commands = len(ctx.client.cmds) + aliases = len(ctx.client.cmd_names) + dpy_version = discord.__version__ + py_version = sys.version.split()[0] + data_version, data_time, _ = select_where( + "VersionHistory", + _extra="ORDER BY time DESC LIMIT 1" + )[0] + data_timestamp = int(data_time.replace(tzinfo=datetime.timezone.utc).timestamp()) + + shard_fields = { + "Shard": f"`{shard_number}` of `{shard_count}`", + "Guilds": f"`{guilds}` servers with `{member_count}` members (on this shard)", + "Commands": f"`{commands}` commands with `{aliases}` keywords", + "Version": f"`v{data_version}`, last updated ", + "Py version": f"`{py_version}` running discord.py `{dpy_version}`" + } + shard_table = prop_tabulate(*zip(*shard_fields.items())) + + + # ----- Execution statistics ----- + running_commands = len(ctx.client.active_contexts) + tasks = len(asyncio.all_tasks()) + objects = len(gc.get_objects()) + cpu_percent = process.cpu_percent() + mem_percent = int(process.memory_percent()) + uptime = int(utc_now().timestamp() - process.create_time()) + + execution_fields = { + "Running": f"`{running_commands}` commands", + "Waiting for": f"`{tasks}` tasks to complete", + "Objects": f"`{objects}` loaded in memory", + "Usage": f"`{cpu_percent}%` CPU, `{mem_percent}%` MEM", + "Uptime": f"`{uptime // (24 * 3600)}` days, `{uptime // 3600 % 24:02}:{uptime // 60 % 60:02}:{uptime % 60:02}`" + } + execution_table = prop_tabulate(*zip(*execution_fields.items())) + + # ----- Combine and output ----- + embed.add_field(name="Study Stats", value=study_table, inline=False) + embed.add_field(name=f"Shard Info", value=shard_table, inline=False) + embed.add_field(name=f"Process Stats", value=execution_table, inline=False) + + await ctx.reply(embed=embed) diff --git a/bot/modules/moderation/admin.py b/bot/modules/moderation/admin.py index 236aad00..73402a35 100644 --- a/bot/modules/moderation/admin.py +++ b/bot/modules/moderation/admin.py @@ -69,6 +69,15 @@ class studyban_durations(settings.SettingList, settings.ListData, settings.Setti _data_column = 'duration' _order_column = "rowid" + _default = [ + 5 * 60, + 60 * 60, + 6 * 60 * 60, + 24 * 60 * 60, + 168 * 60 * 60, + 720 * 60 * 60 + ] + _setting = settings.Duration write_ward = guild_admin diff --git a/bot/modules/stats/__init__.py b/bot/modules/stats/__init__.py index cf342274..d835a785 100644 --- a/bot/modules/stats/__init__.py +++ b/bot/modules/stats/__init__.py @@ -1,3 +1,4 @@ +# flake8: noqa from .module import module from . import data @@ -5,3 +6,4 @@ from . import profile from . import setprofile from . import top_cmd from . import goals +from . import achievements diff --git a/bot/modules/stats/achievements.py b/bot/modules/stats/achievements.py new file mode 100644 index 00000000..a4c15fbb --- /dev/null +++ b/bot/modules/stats/achievements.py @@ -0,0 +1,485 @@ +from typing import NamedTuple, Optional, Union +from datetime import timedelta + +import pytz +import discord + +from cmdClient.checks import in_guild +from LionContext import LionContext + +from meta import client, conf +from core import Lion +from data.conditions import NOTNULL, LEQ +from utils.lib import utc_now + +from modules.topgg.utils import topgg_upvote_link + +from .module import module + + +class AchievementLevel(NamedTuple): + name: str + threshold: Union[int, float] + emoji: discord.PartialEmoji + + +class Achievement: + """ + ABC for a member or user achievement. + """ + # Name of the achievement + name: str = None + + subtext: str = None + congrats_text: str = "Congratulations, you completed this challenge!" + + # List of levels for the achievement. Must always contain a 0 level! + levels: list[AchievementLevel] = None + + def __init__(self, guildid: int, userid: int): + self.guildid = guildid + self.userid = userid + + # Current status of the achievement. None until calculated by `update`. + self.value: int = None + + # Current level index in levels. None until calculated by `update`. + self.level_id: int = None + + @staticmethod + def progress_bar(value, minimum, maximum, width=10) -> str: + """ + Build a text progress bar representing `value` between `minimum` and `maximum`. + """ + emojis = conf.emojis + + proportion = (value - minimum) / (maximum - minimum) + sections = min(max(int(proportion * width), 0), width) + + bar = [] + # Starting segment + bar.append(str(emojis.progress_left_empty) if sections == 0 else str(emojis.progress_left_full)) + + # Full segments up to transition or end + if sections >= 2: + bar.append(str(emojis.progress_middle_full) * (sections - 2)) + + # Transition, if required + if 1 < sections < width: + bar.append(str(emojis.progress_middle_transition)) + + # Empty sections up to end + if sections < width: + bar.append(str(emojis.progress_middle_empty) * (width - max(sections, 1) - 1)) + + # End section + bar.append(str(emojis.progress_right_empty) if sections < width else str(emojis.progress_right_full)) + + # Join all the sections together and return + return ''.join(bar) + + @property + def progress_text(self) -> str: + """ + A brief textual description of the current progress. + Intended to be overridden by achievement implementations. + """ + return f"{int(self.value)}/{self.next_level.threshold if self.next_level else self.level.threshold}" + + def progress_field(self) -> tuple[str, str]: + """ + Builds the progress field for the achievement display. + """ + # TODO: Not adjusted for levels + # TODO: Add hint if progress is empty? + name = f"{self.levels[1].emoji} {self.name} ({self.progress_text})" + value = "**0** {progress_bar} **{threshold}**\n*{subtext}*".format( + subtext=(self.subtext if self.next_level else self.congrats_text) or '', + progress_bar=self.progress_bar(self.value, self.levels[0].threshold, self.levels[1].threshold), + threshold=self.levels[1].threshold + ) + return (name, value) + + @classmethod + async def fetch(cls, guildid: int, userid: int) -> 'Achievement': + """ + Fetch an Achievement status for the given member. + """ + return await cls(guildid, userid).update() + + @property + def level(self) -> AchievementLevel: + """ + The current `AchievementLevel` for this member achievement. + """ + if self.level_id is None: + raise ValueError("Cannot obtain level before first update!") + return self.levels[self.level_id] + + @property + def next_level(self) -> Optional[AchievementLevel]: + """ + The next `AchievementLevel` for this member achievement, + or `None` if it is at the maximum level. + """ + if self.level_id is None: + raise ValueError("Cannot obtain level before first update!") + + if self.level_id == len(self.levels) - 1: + return None + else: + return self.levels[self.level_id + 1] + + async def update(self) -> 'Achievement': + """ + Calculate and store the current member achievement status. + Returns `self` for easy chaining. + """ + # First fetch the value + self.value = await self._calculate_value() + + # Then determine the current level + # Using 0 as a fallback in case the value is negative + self.level_id = next( + (i for i, level in reversed(list(enumerate(self.levels))) if level.threshold <= self.value), + 0 + ) + + # And return `self` for chaining + return self + + async def _calculate_value(self) -> Union[int, float]: + """ + Calculate the current `value` of the member achievement. + Must be overridden by Achievement implementations. + """ + raise NotImplementedError + + +class Workout(Achievement): + sorting_index = 8 + emoji_index = 4 + name = "It's about Power" + subtext = "Workout 50 times" + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 50, conf.emojis.active_achievement_4), + ] + + async def _calculate_value(self) -> int: + """ + Returns the total number of workouts from this user. + """ + return client.data.workout_sessions.select_one_where( + userid=self.userid, + select_columns="COUNT(*)" + )[0] + + +class StudyHours(Achievement): + sorting_index = 1 + emoji_index = 1 + name = "Dream Big" + subtext = "Study a total of 1000 hours" + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 1000, conf.emojis.active_achievement_1), + ] + + async def _calculate_value(self) -> float: + """ + Returns the total number of hours this user has studied. + """ + past_session_total = client.data.session_history.select_one_where( + userid=self.userid, + select_columns="SUM(duration)" + )[0] or 0 + current_session_total = client.data.current_sessions.select_one_where( + userid=self.userid, + select_columns="SUM(EXTRACT(EPOCH FROM (NOW() - start_time)))" + )[0] or 0 + + session_total = past_session_total + current_session_total + hours = session_total / 3600 + return hours + + +class StudyStreak(Achievement): + sorting_index = 2 + emoji_index = 2 + name = "Consistency is Key" + subtext = "Reach a 100-day study streak" + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 100, conf.emojis.active_achievement_2) + ] + + async def _calculate_value(self) -> int: + """ + Return the user's maximum global study streak. + """ + lion = Lion.fetch(self.guildid, self.userid) + history = client.data.session_history.select_where( + userid=self.userid, + select_columns=( + "start_time", + "(start_time + duration * interval '1 second') AS end_time" + ), + _extra="ORDER BY start_time DESC" + ) + + # Streak statistics + streak = 0 + max_streak = 0 + + day_attended = True if 'sessions' in client.objects and lion.session else None + date = lion.day_start + daydiff = timedelta(days=1) + + periods = [(row['start_time'], row['end_time']) for row in history] + + i = 0 + while i < len(periods): + row = periods[i] + i += 1 + if row[1] > date: + # They attended this day + day_attended = True + continue + elif day_attended is None: + # Didn't attend today, but don't break streak + day_attended = False + date -= daydiff + i -= 1 + continue + elif not day_attended: + # Didn't attend the day, streak broken + date -= daydiff + i -= 1 + pass + else: + # Attended the day + streak += 1 + + # Move window to the previous day and try the row again + day_attended = False + prev_date = date + date -= daydiff + i -= 1 + + # Special case, when the last session started in the previous day + # Then the day is already attended + if i > 1 and date < periods[i-2][0] <= prev_date: + day_attended = True + + continue + + max_streak = max(max_streak, streak) + streak = 0 + + # Handle loop exit state, i.e. the last streak + if day_attended: + streak += 1 + max_streak = max(max_streak, streak) + + return max_streak + + +class Voting(Achievement): + sorting_index = 7 + emoji_index = 7 + name = "We're a Team" + subtext = "[Vote]({}) 100 times on top.gg".format(topgg_upvote_link) + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 100, conf.emojis.active_achievement_7) + ] + + async def _calculate_value(self) -> int: + """ + Returns the number of times the user has voted for the bot. + """ + return client.data.topgg.select_one_where( + userid=self.userid, + select_columns="COUNT(*)" + )[0] + + +class DaysStudying(Achievement): + sorting_index = 3 + emoji_index = 3 + name = "Aim For The Moon" + subtext = "Study on 90 different days" + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 90, conf.emojis.active_achievement_3) + ] + + async def _calculate_value(self) -> int: + """ + Returns the number of days the user has studied in total. + """ + lion = Lion.fetch(self.guildid, self.userid) + offset = int(lion.day_start.utcoffset().total_seconds()) + with client.data.session_history.conn as conn: + cursor = conn.cursor() + # TODO: Consider DST offset. + cursor.execute( + """ + SELECT + COUNT(DISTINCT(date_trunc('day', (time AT TIME ZONE 'utc') + interval '{} seconds'))) + FROM ( + (SELECT start_time AS time FROM session_history WHERE userid=%s) + UNION + (SELECT (start_time + duration * interval '1 second') AS time FROM session_history WHERE userid=%s) + ) AS times; + """.format(offset), + (self.userid, self.userid) + ) + data = cursor.fetchone() + return data[0] + + +class TasksComplete(Achievement): + sorting_index = 4 + emoji_index = 8 + name = "One Step at a Time" + subtext = "Complete 1000 tasks" + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 1000, conf.emojis.active_achievement_8) + ] + + async def _calculate_value(self) -> int: + """ + Returns the number of tasks the user has completed. + """ + return client.data.tasklist.select_one_where( + userid=self.userid, + completed_at=NOTNULL, + select_columns="COUNT(*)" + )[0] + + +class ScheduledSessions(Achievement): + sorting_index = 5 + emoji_index = 5 + name = "Be Accountable" + subtext = "Attend 500 scheduled sessions" + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 500, conf.emojis.active_achievement_5) + ] + + async def _calculate_value(self) -> int: + """ + Returns the number of scheduled sesions the user has attended. + """ + return client.data.accountability_member_info.select_one_where( + userid=self.userid, + start_at=LEQ(utc_now()), + select_columns="COUNT(*)", + _extra="AND (duration > 0 OR last_joined_at IS NOT NULL)" + )[0] + + +class MonthlyHours(Achievement): + sorting_index = 6 + emoji_index = 6 + name = "The 30 Days Challenge" + subtext = "Study 100 hours in 30 days" + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 100, conf.emojis.active_achievement_6) + ] + + async def _calculate_value(self) -> float: + """ + Returns the maximum number of hours the user has studied in a month. + """ + # Get the first session so we know how far back to look + first_session = client.data.session_history.select_one_where( + userid=self.userid, + select_columns="MIN(start_time)" + )[0] + + # Get the user's timezone + lion = Lion.fetch(self.guildid, self.userid) + + # If the first session doesn't exist, simulate an existing session (to avoid an extra lookup) + first_session = first_session or lion.day_start - timedelta(days=1) + + # Build the list of month start timestamps + month_start = lion.day_start.replace(day=1) + months = [month_start.astimezone(pytz.utc)] + + while month_start >= first_session: + month_start -= timedelta(days=1) + month_start = month_start.replace(day=1) + months.append(month_start.astimezone(pytz.utc)) + + # Query the study times + data = client.data.session_history.queries.study_times_since( + self.guildid, self.userid, *months + ) + cumulative_times = [row[0] or 0 for row in data] + times = [nxt - crt for nxt, crt in zip(cumulative_times[1:], cumulative_times[0:])] + max_time = max(cumulative_times[0], *times) if len(months) > 1 else cumulative_times[0] + + return max_time / 3600 + + +# Define the displayed achivement order +achievements = [ + Workout, + StudyHours, + StudyStreak, + Voting, + DaysStudying, + TasksComplete, + ScheduledSessions, + MonthlyHours +] + + +async def get_achievements_for(member, panel_sort=False): + status = [ + await ach.fetch(member.guild.id, member.id) + for ach in sorted(achievements, key=lambda cls: (cls.sorting_index if panel_sort else cls.emoji_index)) + ] + return status + + +@module.cmd( + name="achievements", + desc="View your progress towards the achievements!", + group="Statistics", +) +@in_guild() +async def cmd_achievements(ctx: LionContext): + """ + Usage``: + {prefix}achievements + Description: + View your progress towards attaining the achievement badges shown on your `profile`. + """ + status = await get_achievements_for(ctx.author, panel_sort=True) + + embed = discord.Embed( + title="Achievements", + colour=discord.Colour.orange() + ) + for achievement in status: + name, value = achievement.progress_field() + embed.add_field( + name=name, value=value, inline=False + ) + await ctx.reply(embed=embed) diff --git a/bot/modules/study/badges/badge_tracker.py b/bot/modules/study/badges/badge_tracker.py index 721f3962..307cadf8 100644 --- a/bot/modules/study/badges/badge_tracker.py +++ b/bot/modules/study/badges/badge_tracker.py @@ -256,10 +256,20 @@ async def _update_member_roles(row, member, guild_roles, log_lines, flags_used, # Send notification to member # TODO: Config customisation if notify and new_row and (old_row is None or new_row.required_time > old_row.required_time): + req = new_row.required_time + if req < 3600: + timestr = "{} minutes".format(int(req // 60)) + elif req == 3600: + timestr = "1 hour" + elif req % 3600: + timestr = "{:.1f} hours".format(req / 3600) + else: + timestr = "{} hours".format(int(req // 3600)) embed = discord.Embed( title="New Study Badge!", - description="Congratulations! You have earned {}!".format( - "**{}**".format(to_add.name) if to_add else "a new study badge!" + description="Congratulations! You have earned {} for studying **{}**!".format( + "**{}**".format(to_add.name) if to_add else "a new study badge!", + timestr ), timestamp=datetime.datetime.utcnow(), colour=discord.Colour.orange() diff --git a/bot/modules/study/timers/Timer.py b/bot/modules/study/timers/Timer.py index 88a15f4f..df603aaa 100644 --- a/bot/modules/study/timers/Timer.py +++ b/bot/modules/study/timers/Timer.py @@ -131,7 +131,7 @@ class Timer: """ stage = self.current_stage name_format = self.data.channel_name or "{remaining} {stage} -- {name}" - return name_format.replace( + name = name_format.replace( '{remaining}', "{}m".format( int(5 * math.ceil((stage.end - utc_now()).total_seconds() / 300)), ) @@ -147,6 +147,7 @@ class Timer: int(self.focus_length // 60), int(self.break_length // 60) ) ) + return name[:100] async def notify_change_stage(self, old_stage, new_stage): # Update channel name @@ -245,9 +246,15 @@ class Timer: if self._voice_update_task: self._voice_update_task.cancel() + if not self.channel: + return + if self.channel.name == self.channel_name: return + if not self.channel.permissions_for(self.channel.guild.me).manage_channels: + return + if self._last_voice_update: to_wait = ((self._last_voice_update + timedelta(minutes=5)) - utc_now()).total_seconds() if to_wait > 0: diff --git a/bot/modules/study/timers/commands.py b/bot/modules/study/timers/commands.py index c8d4c6d9..5a0daad0 100644 --- a/bot/modules/study/timers/commands.py +++ b/bot/modules/study/timers/commands.py @@ -1,11 +1,12 @@ import asyncio import discord -from cmdClient import Context from cmdClient.checks import in_guild from cmdClient.lib import SafeCancellation +from LionContext import LionContext as Context + from wards import guild_admin -from utils.lib import utc_now, tick +from utils.lib import utc_now, tick, prop_tabulate from ..module import module @@ -15,6 +16,14 @@ from .Timer import Timer config_flags = ('name==', 'threshold=', 'channelname==', 'text==') MAX_TIMERS_PER_GUILD = 10 +options = { + "--name": "The timer name (as shown in alerts and `{prefix}timer`).", + "--channelname": "The name of the voice channel, see below for substitutions.", + "--threshold": "How many focus+break cycles before a member is kicked.", + "--text": "Text channel to send timer alerts in (defaults to value of `{prefix}config pomodoro_channel`)." +} +options_str = prop_tabulate(*zip(*options.items())) + @module.cmd( "timer", @@ -126,7 +135,7 @@ async def cmd_timer(ctx: Context, flags): @module.cmd( "pomodoro", - group="🆕 Pomodoro", + group="Pomodoro", desc="Add and configure timers for your study rooms.", flags=config_flags ) @@ -320,11 +329,6 @@ async def _pomo_admin(ctx, flags): # Post a new status message await timer.update_last_status() - - await ctx.embed_reply( - f"Started a timer in {channel.mention} with **{focus_length}** minutes focus " - f"and **{break_length}** minutes break." - ) else: # Update timer and restart stage = timer.current_stage @@ -344,10 +348,25 @@ async def _pomo_admin(ctx, flags): await timer.notify_change_stage(stage, timer.current_stage) timer.runloop() - await ctx.embed_reply( + # Ack timer creation + embed = discord.Embed( + colour=discord.Colour.orange(), + title="Timer Started!", + description=( f"Started a timer in {channel.mention} with **{focus_length}** " f"minutes focus and **{break_length}** minutes break." ) + ) + embed.add_field( + name="Further configuration", + value=( + "Use `{prefix}{ctx.alias} --setting value` to configure your new timer.\n" + "*Replace `--setting` with one of the below settings, " + "please see `{prefix}help pomodoro` for examples.*\n" + f"{options_str.format(prefix=ctx.best_prefix)}" + ).format(prefix=ctx.best_prefix, ctx=ctx, channel=channel) + ) + await ctx.reply(embed=embed) to_set = [] if flags['name']: diff --git a/bot/modules/study/tracking/commands.py b/bot/modules/study/tracking/commands.py index e8a7e778..bdef7059 100644 --- a/bot/modules/study/tracking/commands.py +++ b/bot/modules/study/tracking/commands.py @@ -1,5 +1,5 @@ -from cmdClient import Context from cmdClient.checks import in_guild +from LionContext import LionContext as Context from core import Lion from wards import is_guild_admin diff --git a/bot/modules/study/tracking/data.py b/bot/modules/study/tracking/data.py index b3cb8dc7..549a7ca6 100644 --- a/bot/modules/study/tracking/data.py +++ b/bot/modules/study/tracking/data.py @@ -1,3 +1,5 @@ +from psycopg2.extras import execute_values + from data import Table, RowTable, tables from utils.lib import FieldEnum @@ -60,4 +62,25 @@ def study_time_since(guildid, userid, timestamp): return (rows[0][0] if rows else None) or 0 +@session_history.save_query +def study_times_since(guildid, userid, *timestamps): + """ + Retrieve the total member study time (in seconds) since the given timestamps. + Includes the current session, if it exists. + """ + with session_history.conn as conn: + cursor = conn.cursor() + data = execute_values( + cursor, + """ + SELECT study_time_since(t.guildid, t.userid, t.timestamp) + FROM (VALUES %s) + AS t (guildid, userid, timestamp) + """, + [(guildid, userid, timestamp) for timestamp in timestamps], + fetch=True + ) + return data + + members_totals = Table('members_totals') diff --git a/bot/modules/study/tracking/settings.py b/bot/modules/study/tracking/settings.py index 53f7ecf8..a96651fe 100644 --- a/bot/modules/study/tracking/settings.py +++ b/bot/modules/study/tracking/settings.py @@ -77,6 +77,7 @@ class hourly_reward(settings.Integer, settings.GuildSetting): desc = "Number of LionCoins given per hour of study." _default = 50 + _max = 32767 long_desc = ( "Each spent in a voice channel will reward this number of LionCoins." @@ -98,7 +99,8 @@ class hourly_live_bonus(settings.Integer, settings.GuildSetting): display_name = "hourly_live_bonus" desc = "Number of extra LionCoins given for a full hour of streaming (via go live or video)." - _default = 10 + _default = 150 + _max = 32767 long_desc = ( "LionCoin bonus earnt for every hour a member streams in a voice channel, including video. " diff --git a/bot/modules/study/tracking/time_tracker.py b/bot/modules/study/tracking/time_tracker.py index 46f88ec7..96f102c3 100644 --- a/bot/modules/study/tracking/time_tracker.py +++ b/bot/modules/study/tracking/time_tracker.py @@ -65,7 +65,7 @@ def _scan(guild): if member.voice.self_stream or member.voice.self_video: hour_reward += guild_hourly_live_bonus - lion.addCoins(hour_reward * interval / (3600), flush=False) + lion.addCoins(hour_reward * interval / (3600), flush=False, bonus=True) async def _study_tracker(): diff --git a/bot/modules/todo/Tasklist.py b/bot/modules/todo/Tasklist.py index 3618b734..74df8d64 100644 --- a/bot/modules/todo/Tasklist.py +++ b/bot/modules/todo/Tasklist.py @@ -350,7 +350,7 @@ class Tasklist: # Rewarding process, now that we know what we need to reward # Add coins user = Lion.fetch(self.member.guild.id, self.member.id) - user.addCoins(reward_coins) + user.addCoins(reward_coins, bonus=True) # Mark tasks as rewarded taskids = [task['taskid'] for task in reward_tasks] diff --git a/bot/modules/todo/admin.py b/bot/modules/todo/admin.py index 0364a817..6a672ccd 100644 --- a/bot/modules/todo/admin.py +++ b/bot/modules/todo/admin.py @@ -38,7 +38,7 @@ class task_reward(settings.Integer, GuildSetting): display_name = "task_reward" desc = "Number of LionCoins given for each completed TODO task." - _default = 250 + _default = 50 long_desc = ( "LionCoin reward given for completing each task on the TODO list." diff --git a/bot/modules/topgg/commands.py b/bot/modules/topgg/commands.py index b9ff116c..e70c05ca 100644 --- a/bot/modules/topgg/commands.py +++ b/bot/modules/topgg/commands.py @@ -1,6 +1,6 @@ import discord from .module import module -from bot.cmdClient.checks import in_guild, is_owner +from cmdClient.checks import is_owner from settings.user_settings import UserSettings from LionContext import LionContext @@ -41,7 +41,6 @@ async def cmd_forcevote(ctx: LionContext): group="Economy", aliases=('topgg', 'topggvote', 'upvote') ) -@in_guild() async def cmd_vote(ctx: LionContext): """ Usage``: diff --git a/bot/modules/topgg/module.py b/bot/modules/topgg/module.py index a6e6857e..d2ff79f5 100644 --- a/bot/modules/topgg/module.py +++ b/bot/modules/topgg/module.py @@ -16,6 +16,7 @@ async def attach_topgg_webhook(client): init_webhook() client.log("Attached top.gg voiting webhook.", context="TOPGG") + @module.launch_task async def register_hook(client): LionContext.reply.add_wrapper(topgg_reply_wrapper) @@ -31,18 +32,17 @@ async def unregister_hook(client): client.log("Unloaded top.gg hooks.", context="TOPGG") +boostfree_groups = {'Meta'} +boostfree_commands = {'config', 'pomodoro'} -async def topgg_reply_wrapper(func, *args, suggest_vote=True, **kwargs): - ctx = args[0] +async def topgg_reply_wrapper(func, ctx: LionContext, *args, suggest_vote=True, **kwargs): if not suggest_vote: pass - elif ctx.cmd and ctx.cmd.name == 'config': + elif ctx.cmd and (ctx.cmd.name in boostfree_commands or ctx.cmd.group in boostfree_groups): pass - elif ctx.cmd and ctx.cmd.name == 'help' and ctx.args and ctx.args.split(maxsplit=1)[0].lower() == 'vote': - pass - elif not get_last_voted_timestamp(args[0].author.id): - upvote_info_formatted = upvote_info.format(lion_yayemote, args[0].best_prefix, lion_loveemote) + elif not get_last_voted_timestamp(ctx.author.id): + upvote_info_formatted = upvote_info.format(lion_yayemote, ctx.best_prefix, lion_loveemote) if 'embed' in kwargs: # Add message as an extra embed field @@ -55,15 +55,17 @@ async def topgg_reply_wrapper(func, *args, suggest_vote=True, **kwargs): ) else: # Add message to content - if 'content' in kwargs and kwargs['content'] and len(kwargs['content']) + len(upvote_info_formatted) < 1998: - kwargs['content'] += '\n\n' + upvote_info_formatted - elif len(args) > 1 and len(args[1]) + len(upvote_info_formatted) < 1998: - args = list(args) - args[1] += '\n\n' + upvote_info_formatted + if 'content' in kwargs and kwargs['content']: + if len(kwargs['content']) + len(upvote_info_formatted) < 1998: + kwargs['content'] += '\n\n' + upvote_info_formatted + elif args: + if len(args[0]) + len(upvote_info_formatted) < 1998: + args = list(args) + args[0] += '\n\n' + upvote_info_formatted else: kwargs['content'] = upvote_info_formatted - return await func(*args, **kwargs) + return await func(ctx, *args, **kwargs) def economy_bonus(lion): diff --git a/bot/modules/workout/tracker.py b/bot/modules/workout/tracker.py index be3438df..a0f19802 100644 --- a/bot/modules/workout/tracker.py +++ b/bot/modules/workout/tracker.py @@ -130,12 +130,12 @@ async def workout_complete(member, workout): settings = GuildSettings(member.guild.id) reward = settings.workout_reward.value - user.addCoins(reward) + user.addCoins(reward, bonus=True) settings.event_log.log( "{} completed their daily workout and was rewarded `{}` coins! (`{:.2f}` minutes)".format( member.mention, - reward, + int(reward * user.economy_bonus), workout.duration / 60, ), title="Workout Completed" ) @@ -143,7 +143,7 @@ async def workout_complete(member, workout): embed = discord.Embed( description=( "Congratulations on completing your daily workout!\n" - "You have been rewarded with `{}` LionCoins. Good job!".format(reward) + "You have been rewarded with `{}` LionCoins. Good job!".format(int(reward * user.economy_bonus)) ), timestamp=dt.datetime.utcnow(), colour=discord.Color.orange() diff --git a/bot/settings/base.py b/bot/settings/base.py index c8929d58..8b6f67a1 100644 --- a/bot/settings/base.py +++ b/bot/settings/base.py @@ -1,10 +1,12 @@ import discord -from cmdClient.cmdClient import cmdClient, Context +from cmdClient.cmdClient import cmdClient from cmdClient.lib import SafeCancellation from cmdClient.Check import Check from utils.lib import prop_tabulate, DotDict +from LionContext import LionContext as Context + from meta import client from data import Table, RowTable diff --git a/bot/settings/setting_types.py b/bot/settings/setting_types.py index 805a28a8..4b5e1dd3 100644 --- a/bot/settings/setting_types.py +++ b/bot/settings/setting_types.py @@ -8,12 +8,13 @@ from typing import Any, Optional import pytz import discord -from cmdClient.Context import Context from cmdClient.lib import SafeCancellation from meta import client from utils.lib import parse_dur, strfdur, prop_tabulate, multiple_replace +from LionContext import LionContext as Context + from .base import UserInputError @@ -679,7 +680,7 @@ class Duration(SettingType): ) if cls._min is not None and num < cls._min: raise UserInputError( - "Duration connot be shorter than `{}`!".format( + "Duration cannot be shorter than `{}`!".format( strfdur(cls._min, short=False, show_days=cls._show_days) ) ) diff --git a/bot/utils/ctx_addons.py b/bot/utils/ctx_addons.py index 12d89cda..c216731f 100644 --- a/bot/utils/ctx_addons.py +++ b/bot/utils/ctx_addons.py @@ -46,7 +46,7 @@ async def error_reply(ctx, error_str, send_args={}, **kwargs): ctx.sent_messages.append(message) return message except discord.Forbidden: - if not ctx.guild or ctx.ch.permissions_for(ctx.guild.me).send_mssages: + if not ctx.guild or ctx.ch.permissions_for(ctx.guild.me).send_messages: await ctx.reply("Command failed, I don't have permission to send embeds in this channel!") raise SafeCancellation diff --git a/config/example-bot.conf b/config/example-bot.conf index 0ac3142a..d0fed43d 100644 --- a/config/example-bot.conf +++ b/config/example-bot.conf @@ -19,6 +19,36 @@ topgg_password = topgg_route = topgg_port = +invite_link = https://discord.studylions.com/invite +support_link = https://discord.gg/StudyLions + [EMOJIS] lionyay = lionlove = + +progress_left_empty = +progress_left_full = +progress_middle_empty = +progress_middle_full = +progress_middle_transition = +progress_right_empty = +progress_right_full = + + +inactive_achievement_1 = +inactive_achievement_2 = +inactive_achievement_3 = +inactive_achievement_4 = +inactive_achievement_5 = +inactive_achievement_6 = +inactive_achievement_7 = +inactive_achievement_8 = + +active_achievement_1 = +active_achievement_2 = +active_achievement_3 = +active_achievement_4 = +active_achievement_5 = +active_achievement_6 = +active_achievement_7 = +active_achievement_8 = diff --git a/data/migration/v9-v10/migration.sql b/data/migration/v9-v10/migration.sql new file mode 100644 index 00000000..e2cf10ae --- /dev/null +++ b/data/migration/v9-v10/migration.sql @@ -0,0 +1,154 @@ +-- Add coin cap to close_study_session +DROP FUNCTION close_study_session(_guildid BIGINT, _userid BIGINT); + +CREATE FUNCTION close_study_session(_guildid BIGINT, _userid BIGINT) + RETURNS SETOF members +AS $$ + BEGIN + RETURN QUERY + WITH + current_sesh AS ( + DELETE FROM current_sessions + WHERE guildid=_guildid AND userid=_userid + RETURNING + *, + EXTRACT(EPOCH FROM (NOW() - start_time)) AS total_duration, + stream_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - stream_start)), 0) AS total_stream_duration, + video_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - video_start)), 0) AS total_video_duration, + live_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - live_start)), 0) AS total_live_duration + ), bonus_userid AS ( + SELECT COUNT(boostedTimestamp), + CASE WHEN EXISTS ( + SELECT 1 FROM Topgg + WHERE Topgg.userid=_userid AND EXTRACT(EPOCH FROM (NOW() - boostedTimestamp)) < 12.5*60*60 + ) THEN + (array_agg( + CASE WHEN boostedTimestamp <= current_sesh.start_time THEN + 1.25 + ELSE + (((current_sesh.total_duration - EXTRACT(EPOCH FROM (boostedTimestamp - current_sesh.start_time)))/current_sesh.total_duration)*0.25)+1 + END))[1] + ELSE + 1 + END + AS bonus + FROM Topgg, current_sesh + WHERE Topgg.userid=_userid AND EXTRACT(EPOCH FROM (NOW() - boostedTimestamp)) < 12.5*60*60 + ORDER BY (array_agg(boostedTimestamp))[1] DESC LIMIT 1 + ), saved_sesh AS ( + INSERT INTO session_history ( + guildid, userid, channelid, rating, tag, channel_type, start_time, + duration, stream_duration, video_duration, live_duration, + coins_earned + ) SELECT + guildid, userid, channelid, rating, tag, channel_type, start_time, + total_duration, total_stream_duration, total_video_duration, total_live_duration, + ((total_duration * hourly_coins + live_duration * hourly_live_coins) * bonus_userid.bonus )/ 3600 + FROM current_sesh, bonus_userid + RETURNING * + ) + UPDATE members + SET + tracked_time=(tracked_time + saved_sesh.duration), + coins=LEAST(coins + saved_sesh.coins_earned, 2147483647) + FROM saved_sesh + WHERE members.guildid=saved_sesh.guildid AND members.userid=saved_sesh.userid + RETURNING members.*; + END; +$$ LANGUAGE PLPGSQL; + + +-- Add support for NULL guildid +DROP FUNCTION study_time_since(_guildid BIGINT, _userid BIGINT, _timestamp TIMESTAMPTZ); + +CREATE FUNCTION study_time_since(_guildid BIGINT, _userid BIGINT, _timestamp TIMESTAMPTZ) + RETURNS INTEGER +AS $$ + BEGIN + RETURN ( + SELECT + SUM( + CASE + WHEN start_time >= _timestamp THEN duration + ELSE EXTRACT(EPOCH FROM (end_time - _timestamp)) + END + ) + FROM ( + SELECT + start_time, + duration, + (start_time + duration * interval '1 second') AS end_time + FROM session_history + WHERE + (_guildid IS NULL OR guildid=_guildid) + AND userid=_userid + AND (start_time + duration * interval '1 second') >= _timestamp + UNION + SELECT + start_time, + EXTRACT(EPOCH FROM (NOW() - start_time)) AS duration, + NOW() AS end_time + FROM current_sessions + WHERE + (_guildid IS NULL OR guildid=_guildid) + AND userid=_userid + ) AS sessions + ); + END; +$$ LANGUAGE PLPGSQL; + + +-- Rebuild study data views +DROP VIEW current_sessions_totals CASCADE; + +CREATE VIEW current_sessions_totals AS + SELECT + *, + EXTRACT(EPOCH FROM (NOW() - start_time)) AS total_duration, + stream_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - stream_start)), 0) AS total_stream_duration, + video_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - video_start)), 0) AS total_video_duration, + live_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - live_start)), 0) AS total_live_duration + FROM current_sessions; + + +CREATE VIEW members_totals AS + SELECT + *, + sesh.start_time AS session_start, + tracked_time + COALESCE(sesh.total_duration, 0) AS total_tracked_time, + coins + COALESCE((sesh.total_duration * sesh.hourly_coins + sesh.live_duration * sesh.hourly_live_coins) / 3600, 0) AS total_coins + FROM members + LEFT JOIN current_sessions_totals sesh USING (guildid, userid); + + +CREATE VIEW member_ranks AS + SELECT + *, + row_number() OVER (PARTITION BY guildid ORDER BY total_tracked_time DESC, userid ASC) AS time_rank, + row_number() OVER (PARTITION BY guildid ORDER BY total_coins DESC, userid ASC) AS coin_rank + FROM members_totals; + +CREATE VIEW current_study_badges AS + SELECT + *, + (SELECT r.badgeid + FROM study_badges r + WHERE r.guildid = members_totals.guildid AND members_totals.total_tracked_time > r.required_time + ORDER BY r.required_time DESC + LIMIT 1) AS current_study_badgeid + FROM members_totals; + +CREATE VIEW new_study_badges AS + SELECT + current_study_badges.* + FROM current_study_badges + WHERE + last_study_badgeid IS DISTINCT FROM current_study_badgeid + ORDER BY guildid; + + +-- API changes +ALTER TABLE user_config ADD COLUMN API_timestamp BIGINT; + + +INSERT INTO VersionHistory (version, author) VALUES (10, 'v9-v10 migration'); diff --git a/data/schema.sql b/data/schema.sql index bfe51e28..5287648e 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -4,7 +4,7 @@ CREATE TABLE VersionHistory( time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, author TEXT ); -INSERT INTO VersionHistory (version, author) VALUES (9, 'Initial Creation'); +INSERT INTO VersionHistory (version, author) VALUES (10, 'Initial Creation'); CREATE OR REPLACE FUNCTION update_timestamp_column() @@ -43,7 +43,8 @@ CREATE TABLE user_config( userid BIGINT PRIMARY KEY, timezone TEXT, topgg_vote_reminder, - avatar_hash TEXT + avatar_hash TEXT, + API_timestamp BIGINT ); -- }}} @@ -485,7 +486,7 @@ AS $$ (start_time + duration * interval '1 second') AS end_time FROM session_history WHERE - guildid=_guildid + (_guildid IS NULL OR guildid=_guildid) AND userid=_userid AND (start_time + duration * interval '1 second') >= _timestamp UNION @@ -495,7 +496,7 @@ AS $$ NOW() AS end_time FROM current_sessions WHERE - guildid=_guildid + (_guildid IS NULL OR guildid=_guildid) AND userid=_userid ) AS sessions ); @@ -552,7 +553,7 @@ AS $$ UPDATE members SET tracked_time=(tracked_time + saved_sesh.duration), - coins=(coins + saved_sesh.coins_earned) + coins=LEAST(coins + saved_sesh.coins_earned, 2147483647) FROM saved_sesh WHERE members.guildid=saved_sesh.guildid AND members.userid=saved_sesh.userid RETURNING members.*; diff --git a/requirements.txt b/requirements.txt index c9254347..aec57783 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ iso8601==0.1.16 psycopg2==2.9.1 pytz==2021.1 topggpy +psutil