Merge pull request #22 from StudyLions/staging
Bugfixes, Link commands, and Achievements
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
CONFIG_FILE = "config/bot.conf"
|
||||
DATA_VERSION = 9
|
||||
DATA_VERSION = 10
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from .module import module
|
||||
|
||||
from . import cointop_cmd
|
||||
from . import send_cmd
|
||||
from . import shop_cmds
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import discord
|
||||
from cmdClient.Context import Context
|
||||
from LionContext import LionContext as Context
|
||||
|
||||
from meta import client
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# flake8: noqa
|
||||
from .module import module
|
||||
|
||||
from . import help
|
||||
from . import links
|
||||
from . import nerd
|
||||
from . import join_message
|
||||
|
||||
@@ -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
|
||||
|
||||
46
bot/modules/meta/join_message.py
Normal file
46
bot/modules/meta/join_message.py
Normal 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
5
bot/modules/meta/lib.py
Normal 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
57
bot/modules/meta/links.py
Normal 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
144
bot/modules/meta/nerd.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
485
bot/modules/stats/achievements.py
Normal file
485
bot/modules/stats/achievements.py
Normal 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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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']:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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. "
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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``:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
154
data/migration/v9-v10/migration.sql
Normal file
154
data/migration/v9-v10/migration.sql
Normal 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');
|
||||
@@ -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.*;
|
||||
|
||||
@@ -7,3 +7,4 @@ iso8601==0.1.16
|
||||
psycopg2==2.9.1
|
||||
pytz==2021.1
|
||||
topggpy
|
||||
psutil
|
||||
|
||||
Reference in New Issue
Block a user