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"
DATA_VERSION = 9
DATA_VERSION = 10

View File

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

View File

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

View File

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

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:
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", [])

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
from .module import module
from . import cointop_cmd
from . import send_cmd
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))
# 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(

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

View File

@@ -1,5 +1,5 @@
import discord
from cmdClient.Context import Context
from LionContext import LionContext as Context
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."
)
_default = 0
_default = 1000
@property
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 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()

View File

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

View File

@@ -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 <t:{:.0f}>.".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:

View File

@@ -1,3 +1,7 @@
# flake8: noqa
from .module import module
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 .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 <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.
"""
Use `{{ctx.best_prefix}}help <command>` (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,6 +185,11 @@ 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]))
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

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'
_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

View File

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

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
# 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()

View File

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

View File

@@ -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']:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:
if 'content' in kwargs and kwargs['content']:
if 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:
elif args:
if len(args[0]) + len(upvote_info_formatted) < 1998:
args = list(args)
args[1] += '\n\n' + upvote_info_formatted
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):

View File

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

View File

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

View File

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

View File

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

View File

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

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,
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.*;

View File

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