Merge pull request #22 from StudyLions/staging

Bugfixes, Link commands, and Achievements
This commit is contained in:
Interitio
2022-01-29 08:54:46 +02:00
committed by GitHub
44 changed files with 1159 additions and 195 deletions

View File

@@ -1,2 +1,2 @@
CONFIG_FILE = "config/bot.conf" CONFIG_FILE = "config/bot.conf"
DATA_VERSION = 9 DATA_VERSION = 10

View File

@@ -74,7 +74,7 @@ def add_pending(pending):
""" """
UPDATE members UPDATE members
SET SET
coins = coins + t.coin_diff coins = LEAST(coins + t.coin_diff, 2147483647)
FROM FROM
(VALUES %s) (VALUES %s)
AS AS

View File

@@ -7,6 +7,8 @@ from meta import client
from data import tables as tb from data import tables as tb
from settings import UserSettings, GuildSettings from settings import UserSettings, GuildSettings
from LionContext import LionContext
class Lion: class Lion:
""" """
@@ -63,7 +65,11 @@ class Lion:
return (self.guildid, self.userid) return (self.guildid, self.userid)
@property @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. 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. 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) 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 @property
def time(self): def time(self):
""" """
@@ -246,6 +261,20 @@ class Lion:
return remaining 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 @property
def name(self): def name(self):
""" """
@@ -260,7 +289,6 @@ class Lion:
return name return name
def update_saved_data(self, member: discord.Member): def update_saved_data(self, member: discord.Member):
""" """
Update the stored discord data from the givem member. Update the stored discord data from the givem member.
@@ -280,11 +308,11 @@ class Lion:
timezone = self.settings.timezone.value timezone = self.settings.timezone.value
return naive_utc_dt.replace(tzinfo=pytz.UTC).astimezone(timezone) 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. 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 self._pending[self.key] = self
if flush: if flush:
self.flush() self.flush()

View File

@@ -188,7 +188,7 @@ class RowTable(Table):
self.columns = columns self.columns = columns
self.id_col = id_col self.id_col = id_col
self.multi_key = isinstance(id_col, tuple) 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): def id_from_row(self, row):
if self.multi_key: if self.multi_key:

View File

@@ -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: class Conf:
def __init__(self, configfile, section_name="DEFAULT"): def __init__(self, configfile, section_name="DEFAULT"):
self.configfile = configfile self.configfile = configfile
@@ -49,9 +76,12 @@ class Conf:
self.section_name = section_name if section_name in self.config else 'DEFAULT' self.section_name = section_name if section_name in self.config else 'DEFAULT'
self.default = self.config["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.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. # Config file recursion, read in configuration files specified in every "ALSO_READ" key.
more_to_read = self.section.getlist("ALSO_READ", []) more_to_read = self.section.getlist("ALSO_READ", [])

View File

@@ -427,7 +427,7 @@ class TimeSlot:
reward += guild_settings.accountability_bonus.value reward += guild_settings.accountability_bonus.value
for memid in self.members: 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): async def cancel(self):
""" """

View File

@@ -84,7 +84,7 @@ class accountability_price(settings.Integer, GuildSetting):
display_name = "session_price" display_name = "session_price"
desc = "Cost of booking a scheduled session." desc = "Cost of booking a scheduled session."
_default = 100 _default = 500
long_desc = ( long_desc = (
"The price of booking each one hour scheduled session slot." "The price of booking each one hour scheduled session slot."
@@ -106,7 +106,7 @@ class accountability_bonus(settings.Integer, GuildSetting):
display_name = "session_bonus" display_name = "session_bonus"
desc = "Bonus given when everyone attends a scheduled session slot." desc = "Bonus given when everyone attends a scheduled session slot."
_default = 1000 _default = 750
long_desc = ( long_desc = (
"The extra bonus given to each scheduled session member when everyone who booked attended the session." "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" display_name = "session_reward"
desc = "The individual reward given when a member attends their booked scheduled session." desc = "The individual reward given when a member attends their booked scheduled session."
_default = 200 _default = 500
long_desc = ( long_desc = (
"Reward given to a member who attends a booked scheduled session." "Reward given to a member who attends a booked scheduled session."

View File

@@ -1,5 +1,4 @@
from .module import module from .module import module
from . import cointop_cmd
from . import send_cmd from . import send_cmd
from . import shop_cmds from . import shop_cmds

View File

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

View File

@@ -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)) 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 # Finally, send the amount and the ack message
target_lion.addCoins(amount, ignorebonus=True) target_lion.addCoins(amount)
source_lion.addCoins(-amount) source_lion.addCoins(-amount)
embed = discord.Embed( embed = discord.Embed(

View File

@@ -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 # 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 target_coins_to_set = target_lion.coins + amount
if target_coins_to_set >= 0 and target_coins_to_set <= POSTGRES_INT_MAX: 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: elif target_coins_to_set < 0:
target_coins_to_set = -target_lion.coins # Coins cannot go -ve, cap to 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 target_coins_to_set = 0
else: else:
return await ctx.embed_reply("Member coins cannot be more than {}".format(POSTGRES_INT_MAX)) return await ctx.embed_reply("Member coins cannot be more than {}".format(POSTGRES_INT_MAX))

View File

@@ -1,5 +1,5 @@
import discord import discord
from cmdClient.Context import Context from LionContext import LionContext as Context
from meta import client from meta import client

View File

@@ -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." "Members will be given this number of coins the first time they join the server."
) )
_default = 0 _default = 1000
@property @property
def success_response(self): def success_response(self):

View File

@@ -227,6 +227,8 @@ async def cmd_reactionroles(ctx, flags):
For example to disable event logging, run `{prefix}rroles link --log off`. For example to disable event logging, run `{prefix}rroles link --log off`.
For per-reaction settings, instead use `{prefix}rroles link emoji --setting value`. For per-reaction settings, instead use `{prefix}rroles link emoji --setting value`.
*(!) Replace `setting` with one of the settings below!*
Message Settings:: Message Settings::
maximum: Maximum number of roles obtainable from this message. maximum: Maximum number of roles obtainable from this message.
log: Whether to log reaction role usage into the event log. 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. default_price: The default price of each role on this message.
required_role: The role required to use these reactions roles. required_role: The role required to use these reactions roles.
Reaction Settings:: 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. tduration: How long this role will last after being selected or bought.
Configuration Examples``: Configuration Examples``:
{prefix}rroles {ctx.msg.id} --maximum 5 {prefix}rroles {ctx.msg.id} --maximum 5
@@ -350,6 +352,7 @@ async def cmd_reactionroles(ctx, flags):
elif not target_id: elif not target_id:
# Confirm enabling of all reaction messages # Confirm enabling of all reaction messages
await reaction_ask( await reaction_ask(
ctx,
"Are you sure you want to enable all reaction role messages in this server?", "Are you sure you want to enable all reaction role messages in this server?",
timeout_msg="Prompt timed out, no reaction roles enabled.", timeout_msg="Prompt timed out, no reaction roles enabled.",
cancel_msg="User cancelled, 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: elif not target_id:
# Confirm disabling of all reaction messages # Confirm disabling of all reaction messages
await reaction_ask( await reaction_ask(
ctx,
"Are you sure you want to disable all reaction role messages in this server?", "Are you sure you want to disable all reaction role messages in this server?",
timeout_msg="Prompt timed out, no reaction roles disabled.", timeout_msg="Prompt timed out, no reaction roles disabled.",
cancel_msg="User cancelled, 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: elif not target_id:
# Confirm disabling of all reaction messages # Confirm disabling of all reaction messages
await reaction_ask( await reaction_ask(
ctx,
"Are you sure you want to remove all reaction role messages in this server?", "Are you sure you want to remove all reaction role messages in this server?",
timeout_msg="Prompt timed out, no messages removed.", timeout_msg="Prompt timed out, no messages removed.",
cancel_msg="User cancelled, no messages removed." cancel_msg="User cancelled, no messages removed."
@@ -909,7 +914,8 @@ async def cmd_reactionroles(ctx, flags):
"{settings_table}\n" "{settings_table}\n"
"To update a message setting: `{prefix}rroles messageid --setting value`\n" "To update a message setting: `{prefix}rroles messageid --setting value`\n"
"To update an emoji setting: `{prefix}rroles messageid emoji --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( ).format(
prefix=ctx.best_prefix, prefix=ctx.best_prefix,
settings_table=target.settings.tabulated() settings_table=target.settings.tabulated()

View File

@@ -191,7 +191,7 @@ class price(setting_types.Integer, ReactionSetting):
_data_column = 'price' _data_column = 'price'
display_name = "price" display_name = "price"
desc = "Price of this reaction role." desc = "Price of this reaction role (may be negative)."
long_desc = ( long_desc = (
"The number of coins that will be deducted from the user when this reaction is used.\n" "The number of coins that will be deducted from the user when this reaction is used.\n"

View File

@@ -174,7 +174,7 @@ class ReactionRoleMessage:
Returns the generated `ReactionRoleReaction`s for convenience. Returns the generated `ReactionRoleReaction`s for convenience.
""" """
# Fetch reactions and pre-populate reaction cache # 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] reactions = [ReactionRoleReaction(row.reactionid) for row in rows]
self._reactions[self.messageid] = reactions self._reactions[self.messageid] = reactions
return reactions return reactions
@@ -425,7 +425,7 @@ class ReactionRoleMessage:
self.message_link, self.message_link,
role.mention, role.mention,
member.mention, member.mention,
" for `{}` coins.".format(price) if price else '', " for `{}` coins".format(price) if price else '',
"\nThis role will expire at <t:{:.0f}>.".format( "\nThis role will expire at <t:{:.0f}>.".format(
expiry.timestamp() expiry.timestamp()
) if expiry else '' ) if expiry else ''
@@ -501,7 +501,7 @@ class ReactionRoleMessage:
if price and refund: if price and refund:
# Give the user the refund # Give the user the refund
lion = Lion.fetch(self.guild.id, member.id) lion = Lion.fetch(self.guild.id, member.id)
lion.addCoins(price, ignorebonus=True) lion.addCoins(price)
# Notify the user # Notify the user
embed = discord.Embed( embed = discord.Embed(
@@ -548,7 +548,7 @@ class ReactionRoleMessage:
@client.add_after_event('raw_reaction_add') @client.add_after_event('raw_reaction_add')
async def reaction_role_add(client, payload): async def reaction_role_add(client, payload):
reaction_message = ReactionRoleMessage.fetch(payload.message_id) 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: try:
await reaction_message.process_raw_reaction_add(payload) await reaction_message.process_raw_reaction_add(payload)
except Exception: except Exception:

View File

@@ -1,3 +1,7 @@
# flake8: noqa
from .module import module from .module import module
from . import help from . import help
from . import links
from . import nerd
from . import join_message

View File

@@ -6,11 +6,15 @@ from utils import interactive, ctx_addons # noqa
from wards import is_guild_admin from wards import is_guild_admin
from .module import module 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 # Set the command groups to appear in the help
group_hints = { 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!*", 'Productivity': "*Use these to help you stay focused and productive!*",
'Statistics': "*StudyLion leaderboards and study statistics.*", 'Statistics': "*StudyLion leaderboards and study statistics.*",
'Economy': "*Buy, sell, and trade with your hard-earned coins!*", 'Economy': "*Buy, sell, and trade with your hard-earned coins!*",
@@ -21,22 +25,22 @@ group_hints = {
} }
standard_group_order = ( standard_group_order = (
('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings', 'Meta'), ('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings', 'Meta'),
) )
mod_group_order = ( mod_group_order = (
('Moderation', 'Meta'), ('Moderation', 'Meta'),
('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') ('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings')
) )
admin_group_order = ( admin_group_order = (
('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'), ('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') ('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings')
) )
bot_admin_group_order = ( bot_admin_group_order = (
('Bot Admin', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'), ('Bot Admin', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') ('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings')
) )
# Help embed format # Help embed format
@@ -46,14 +50,15 @@ header = """
[StudyLion](https://bot.studylions.com/) is a fully featured study assistant \ [StudyLion](https://bot.studylions.com/) is a fully featured study assistant \
that tracks your study time and offers productivity tools \ 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 such as to-do lists, task reminders, private study rooms, group accountability sessions, and much much more.\n
Use `{ctx.best_prefix}help <command>` (e.g. `{ctx.best_prefix}help send`) to learn how to use each command, \ Use `{{ctx.best_prefix}}help <command>` (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. or [click here]({guide_link}) for a comprehensive tutorial.
""" """.format(guide_link=guide_link)
@module.cmd("help", @module.cmd("help",
group="Meta", group="Meta",
desc="StudyLion command list.") desc="StudyLion command list.",
aliases=('man', 'ls', 'list'))
async def cmd_help(ctx): async def cmd_help(ctx):
""" """
Usage``: Usage``:
@@ -168,7 +173,9 @@ async def cmd_help(ctx):
cmd_groups[group] = cmd_group cmd_groups[group] = cmd_group
# Add the command name and description to the 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 # Add any required aliases
for alias, desc in getattr(command, 'help_aliases', {}).items(): for alias, desc in getattr(command, 'help_aliases', {}).items():
@@ -178,7 +185,12 @@ async def cmd_help(ctx):
stringy_cmd_groups = {} stringy_cmd_groups = {}
for group_name, cmd_group in cmd_groups.items(): for group_name, cmd_group in cmd_groups.items():
cmd_group.sort(key=lambda tup: len(tup[0])) 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 # Now put everything into a bunch of embeds
if await is_owner.run(ctx): if await is_owner.run(ctx):

View File

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

5
bot/modules/meta/lib.py Normal file
View File

@@ -0,0 +1,5 @@
guide_link = "https://discord.studylions.com/tutorial"
animation_link = (
"https://media.discordapp.net/attachments/879412267731542047/926837189814419486/ezgif.com-resize.gif"
)

57
bot/modules/meta/links.py Normal file
View File

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

144
bot/modules/meta/nerd.py Normal file
View File

@@ -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 <t:{data_timestamp}:F>",
"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)

View File

@@ -69,6 +69,15 @@ class studyban_durations(settings.SettingList, settings.ListData, settings.Setti
_data_column = 'duration' _data_column = 'duration'
_order_column = "rowid" _order_column = "rowid"
_default = [
5 * 60,
60 * 60,
6 * 60 * 60,
24 * 60 * 60,
168 * 60 * 60,
720 * 60 * 60
]
_setting = settings.Duration _setting = settings.Duration
write_ward = guild_admin write_ward = guild_admin

View File

@@ -1,3 +1,4 @@
# flake8: noqa
from .module import module from .module import module
from . import data from . import data
@@ -5,3 +6,4 @@ from . import profile
from . import setprofile from . import setprofile
from . import top_cmd from . import top_cmd
from . import goals from . import goals
from . import achievements

View File

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

View File

@@ -256,10 +256,20 @@ async def _update_member_roles(row, member, guild_roles, log_lines, flags_used,
# Send notification to member # Send notification to member
# TODO: Config customisation # TODO: Config customisation
if notify and new_row and (old_row is None or new_row.required_time > old_row.required_time): 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( embed = discord.Embed(
title="New Study Badge!", title="New Study Badge!",
description="Congratulations! You have earned {}!".format( description="Congratulations! You have earned {} for studying **{}**!".format(
"**{}**".format(to_add.name) if to_add else "a new study badge!" "**{}**".format(to_add.name) if to_add else "a new study badge!",
timestr
), ),
timestamp=datetime.datetime.utcnow(), timestamp=datetime.datetime.utcnow(),
colour=discord.Colour.orange() colour=discord.Colour.orange()

View File

@@ -131,7 +131,7 @@ class Timer:
""" """
stage = self.current_stage stage = self.current_stage
name_format = self.data.channel_name or "{remaining} {stage} -- {name}" name_format = self.data.channel_name or "{remaining} {stage} -- {name}"
return name_format.replace( name = name_format.replace(
'{remaining}', "{}m".format( '{remaining}', "{}m".format(
int(5 * math.ceil((stage.end - utc_now()).total_seconds() / 300)), 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) int(self.focus_length // 60), int(self.break_length // 60)
) )
) )
return name[:100]
async def notify_change_stage(self, old_stage, new_stage): async def notify_change_stage(self, old_stage, new_stage):
# Update channel name # Update channel name
@@ -245,9 +246,15 @@ class Timer:
if self._voice_update_task: if self._voice_update_task:
self._voice_update_task.cancel() self._voice_update_task.cancel()
if not self.channel:
return
if self.channel.name == self.channel_name: if self.channel.name == self.channel_name:
return return
if not self.channel.permissions_for(self.channel.guild.me).manage_channels:
return
if self._last_voice_update: if self._last_voice_update:
to_wait = ((self._last_voice_update + timedelta(minutes=5)) - utc_now()).total_seconds() to_wait = ((self._last_voice_update + timedelta(minutes=5)) - utc_now()).total_seconds()
if to_wait > 0: if to_wait > 0:

View File

@@ -1,11 +1,12 @@
import asyncio import asyncio
import discord import discord
from cmdClient import Context
from cmdClient.checks import in_guild from cmdClient.checks import in_guild
from cmdClient.lib import SafeCancellation from cmdClient.lib import SafeCancellation
from LionContext import LionContext as Context
from wards import guild_admin 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 from ..module import module
@@ -15,6 +16,14 @@ from .Timer import Timer
config_flags = ('name==', 'threshold=', 'channelname==', 'text==') config_flags = ('name==', 'threshold=', 'channelname==', 'text==')
MAX_TIMERS_PER_GUILD = 10 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( @module.cmd(
"timer", "timer",
@@ -126,7 +135,7 @@ async def cmd_timer(ctx: Context, flags):
@module.cmd( @module.cmd(
"pomodoro", "pomodoro",
group="🆕 Pomodoro", group="Pomodoro",
desc="Add and configure timers for your study rooms.", desc="Add and configure timers for your study rooms.",
flags=config_flags flags=config_flags
) )
@@ -320,11 +329,6 @@ async def _pomo_admin(ctx, flags):
# Post a new status message # Post a new status message
await timer.update_last_status() 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: else:
# Update timer and restart # Update timer and restart
stage = timer.current_stage stage = timer.current_stage
@@ -344,10 +348,25 @@ async def _pomo_admin(ctx, flags):
await timer.notify_change_stage(stage, timer.current_stage) await timer.notify_change_stage(stage, timer.current_stage)
timer.runloop() 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"Started a timer in {channel.mention} with **{focus_length}** "
f"minutes focus and **{break_length}** minutes break." 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 = [] to_set = []
if flags['name']: if flags['name']:

View File

@@ -1,5 +1,5 @@
from cmdClient import Context
from cmdClient.checks import in_guild from cmdClient.checks import in_guild
from LionContext import LionContext as Context
from core import Lion from core import Lion
from wards import is_guild_admin from wards import is_guild_admin

View File

@@ -1,3 +1,5 @@
from psycopg2.extras import execute_values
from data import Table, RowTable, tables from data import Table, RowTable, tables
from utils.lib import FieldEnum 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 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') members_totals = Table('members_totals')

View File

@@ -77,6 +77,7 @@ class hourly_reward(settings.Integer, settings.GuildSetting):
desc = "Number of LionCoins given per hour of study." desc = "Number of LionCoins given per hour of study."
_default = 50 _default = 50
_max = 32767
long_desc = ( long_desc = (
"Each spent in a voice channel will reward this number of LionCoins." "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" display_name = "hourly_live_bonus"
desc = "Number of extra LionCoins given for a full hour of streaming (via go live or video)." 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 = ( long_desc = (
"LionCoin bonus earnt for every hour a member streams in a voice channel, including video. " "LionCoin bonus earnt for every hour a member streams in a voice channel, including video. "

View File

@@ -65,7 +65,7 @@ def _scan(guild):
if member.voice.self_stream or member.voice.self_video: if member.voice.self_stream or member.voice.self_video:
hour_reward += guild_hourly_live_bonus 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(): async def _study_tracker():

View File

@@ -350,7 +350,7 @@ class Tasklist:
# Rewarding process, now that we know what we need to reward # Rewarding process, now that we know what we need to reward
# Add coins # Add coins
user = Lion.fetch(self.member.guild.id, self.member.id) user = Lion.fetch(self.member.guild.id, self.member.id)
user.addCoins(reward_coins) user.addCoins(reward_coins, bonus=True)
# Mark tasks as rewarded # Mark tasks as rewarded
taskids = [task['taskid'] for task in reward_tasks] taskids = [task['taskid'] for task in reward_tasks]

View File

@@ -38,7 +38,7 @@ class task_reward(settings.Integer, GuildSetting):
display_name = "task_reward" display_name = "task_reward"
desc = "Number of LionCoins given for each completed TODO task." desc = "Number of LionCoins given for each completed TODO task."
_default = 250 _default = 50
long_desc = ( long_desc = (
"LionCoin reward given for completing each task on the TODO list." "LionCoin reward given for completing each task on the TODO list."

View File

@@ -1,6 +1,6 @@
import discord import discord
from .module import module 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 settings.user_settings import UserSettings
from LionContext import LionContext from LionContext import LionContext
@@ -41,7 +41,6 @@ async def cmd_forcevote(ctx: LionContext):
group="Economy", group="Economy",
aliases=('topgg', 'topggvote', 'upvote') aliases=('topgg', 'topggvote', 'upvote')
) )
@in_guild()
async def cmd_vote(ctx: LionContext): async def cmd_vote(ctx: LionContext):
""" """
Usage``: Usage``:

View File

@@ -16,6 +16,7 @@ async def attach_topgg_webhook(client):
init_webhook() init_webhook()
client.log("Attached top.gg voiting webhook.", context="TOPGG") client.log("Attached top.gg voiting webhook.", context="TOPGG")
@module.launch_task @module.launch_task
async def register_hook(client): async def register_hook(client):
LionContext.reply.add_wrapper(topgg_reply_wrapper) LionContext.reply.add_wrapper(topgg_reply_wrapper)
@@ -31,18 +32,17 @@ async def unregister_hook(client):
client.log("Unloaded top.gg hooks.", context="TOPGG") 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: if not suggest_vote:
pass 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 pass
elif ctx.cmd and ctx.cmd.name == 'help' and ctx.args and ctx.args.split(maxsplit=1)[0].lower() == 'vote': elif not get_last_voted_timestamp(ctx.author.id):
pass upvote_info_formatted = upvote_info.format(lion_yayemote, ctx.best_prefix, lion_loveemote)
elif not get_last_voted_timestamp(args[0].author.id):
upvote_info_formatted = upvote_info.format(lion_yayemote, args[0].best_prefix, lion_loveemote)
if 'embed' in kwargs: if 'embed' in kwargs:
# Add message as an extra embed field # Add message as an extra embed field
@@ -55,15 +55,17 @@ async def topgg_reply_wrapper(func, *args, suggest_vote=True, **kwargs):
) )
else: else:
# Add message to content # Add message to content
if 'content' in kwargs and kwargs['content'] and len(kwargs['content']) + len(upvote_info_formatted) < 1998: if 'content' in kwargs and kwargs['content']:
kwargs['content'] += '\n\n' + upvote_info_formatted if len(kwargs['content']) + len(upvote_info_formatted) < 1998:
elif len(args) > 1 and len(args[1]) + len(upvote_info_formatted) < 1998: kwargs['content'] += '\n\n' + upvote_info_formatted
args = list(args) elif args:
args[1] += '\n\n' + upvote_info_formatted if len(args[0]) + len(upvote_info_formatted) < 1998:
args = list(args)
args[0] += '\n\n' + upvote_info_formatted
else: else:
kwargs['content'] = upvote_info_formatted kwargs['content'] = upvote_info_formatted
return await func(*args, **kwargs) return await func(ctx, *args, **kwargs)
def economy_bonus(lion): def economy_bonus(lion):

View File

@@ -130,12 +130,12 @@ async def workout_complete(member, workout):
settings = GuildSettings(member.guild.id) settings = GuildSettings(member.guild.id)
reward = settings.workout_reward.value reward = settings.workout_reward.value
user.addCoins(reward) user.addCoins(reward, bonus=True)
settings.event_log.log( settings.event_log.log(
"{} completed their daily workout and was rewarded `{}` coins! (`{:.2f}` minutes)".format( "{} completed their daily workout and was rewarded `{}` coins! (`{:.2f}` minutes)".format(
member.mention, member.mention,
reward, int(reward * user.economy_bonus),
workout.duration / 60, workout.duration / 60,
), title="Workout Completed" ), title="Workout Completed"
) )
@@ -143,7 +143,7 @@ async def workout_complete(member, workout):
embed = discord.Embed( embed = discord.Embed(
description=( description=(
"Congratulations on completing your daily workout!\n" "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(), timestamp=dt.datetime.utcnow(),
colour=discord.Color.orange() colour=discord.Color.orange()

View File

@@ -1,10 +1,12 @@
import discord import discord
from cmdClient.cmdClient import cmdClient, Context from cmdClient.cmdClient import cmdClient
from cmdClient.lib import SafeCancellation from cmdClient.lib import SafeCancellation
from cmdClient.Check import Check from cmdClient.Check import Check
from utils.lib import prop_tabulate, DotDict from utils.lib import prop_tabulate, DotDict
from LionContext import LionContext as Context
from meta import client from meta import client
from data import Table, RowTable from data import Table, RowTable

View File

@@ -8,12 +8,13 @@ from typing import Any, Optional
import pytz import pytz
import discord import discord
from cmdClient.Context import Context
from cmdClient.lib import SafeCancellation from cmdClient.lib import SafeCancellation
from meta import client from meta import client
from utils.lib import parse_dur, strfdur, prop_tabulate, multiple_replace from utils.lib import parse_dur, strfdur, prop_tabulate, multiple_replace
from LionContext import LionContext as Context
from .base import UserInputError from .base import UserInputError
@@ -679,7 +680,7 @@ class Duration(SettingType):
) )
if cls._min is not None and num < cls._min: if cls._min is not None and num < cls._min:
raise UserInputError( raise UserInputError(
"Duration connot be shorter than `{}`!".format( "Duration cannot be shorter than `{}`!".format(
strfdur(cls._min, short=False, show_days=cls._show_days) strfdur(cls._min, short=False, show_days=cls._show_days)
) )
) )

View File

@@ -46,7 +46,7 @@ async def error_reply(ctx, error_str, send_args={}, **kwargs):
ctx.sent_messages.append(message) ctx.sent_messages.append(message)
return message return message
except discord.Forbidden: 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!") await ctx.reply("Command failed, I don't have permission to send embeds in this channel!")
raise SafeCancellation raise SafeCancellation

View File

@@ -19,6 +19,36 @@ topgg_password =
topgg_route = topgg_route =
topgg_port = topgg_port =
invite_link = https://discord.studylions.com/invite
support_link = https://discord.gg/StudyLions
[EMOJIS] [EMOJIS]
lionyay = lionyay =
lionlove = 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 =

View File

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

View File

@@ -4,7 +4,7 @@ CREATE TABLE VersionHistory(
time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
author TEXT 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() CREATE OR REPLACE FUNCTION update_timestamp_column()
@@ -43,7 +43,8 @@ CREATE TABLE user_config(
userid BIGINT PRIMARY KEY, userid BIGINT PRIMARY KEY,
timezone TEXT, timezone TEXT,
topgg_vote_reminder, 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 (start_time + duration * interval '1 second') AS end_time
FROM session_history FROM session_history
WHERE WHERE
guildid=_guildid (_guildid IS NULL OR guildid=_guildid)
AND userid=_userid AND userid=_userid
AND (start_time + duration * interval '1 second') >= _timestamp AND (start_time + duration * interval '1 second') >= _timestamp
UNION UNION
@@ -495,7 +496,7 @@ AS $$
NOW() AS end_time NOW() AS end_time
FROM current_sessions FROM current_sessions
WHERE WHERE
guildid=_guildid (_guildid IS NULL OR guildid=_guildid)
AND userid=_userid AND userid=_userid
) AS sessions ) AS sessions
); );
@@ -552,7 +553,7 @@ AS $$
UPDATE members UPDATE members
SET SET
tracked_time=(tracked_time + saved_sesh.duration), tracked_time=(tracked_time + saved_sesh.duration),
coins=(coins + saved_sesh.coins_earned) coins=LEAST(coins + saved_sesh.coins_earned, 2147483647)
FROM saved_sesh FROM saved_sesh
WHERE members.guildid=saved_sesh.guildid AND members.userid=saved_sesh.userid WHERE members.guildid=saved_sesh.guildid AND members.userid=saved_sesh.userid
RETURNING members.*; RETURNING members.*;

View File

@@ -7,3 +7,4 @@ iso8601==0.1.16
psycopg2==2.9.1 psycopg2==2.9.1
pytz==2021.1 pytz==2021.1
topggpy topggpy
psutil