Merge branch 'feature-session-tracker' into staging

This commit is contained in:
2021-12-08 12:02:17 +02:00
23 changed files with 1355 additions and 170 deletions

View File

@@ -1,2 +1,2 @@
CONFIG_FILE = "config/bot.conf" CONFIG_FILE = "config/bot.conf"
DATA_VERSION = 5 DATA_VERSION = 6

View File

@@ -20,44 +20,13 @@ user_config = RowTable(
) )
@user_config.save_query
def add_pending(pending):
"""
pending:
List of tuples of the form `(userid, pending_coins, pending_time)`.
"""
with lions.conn:
cursor = lions.conn.cursor()
data = execute_values(
cursor,
"""
UPDATE members
SET
coins = coins + t.coin_diff,
tracked_time = tracked_time + t.time_diff
FROM
(VALUES %s)
AS
t (guildid, userid, coin_diff, time_diff)
WHERE
members.guildid = t.guildid
AND
members.userid = t.userid
RETURNING *
""",
pending,
fetch=True
)
return lions._make_rows(*data)
guild_config = RowTable( guild_config = RowTable(
'guild_config', 'guild_config',
('guildid', 'admin_role', 'mod_role', 'event_log_channel', 'mod_log_channel', 'alert_channel', ('guildid', 'admin_role', 'mod_role', 'event_log_channel', 'mod_log_channel', 'alert_channel',
'studyban_role', 'studyban_role', 'max_study_bans',
'min_workout_length', 'workout_reward', 'min_workout_length', 'workout_reward',
'max_tasks', 'task_reward', 'task_reward_limit', 'max_tasks', 'task_reward', 'task_reward_limit',
'study_hourly_reward', 'study_hourly_live_bonus', 'study_hourly_reward', 'study_hourly_live_bonus', 'daily_study_cap',
'renting_price', 'renting_category', 'renting_cap', 'renting_role', 'renting_sync_perms', 'renting_price', 'renting_category', 'renting_cap', 'renting_role', 'renting_sync_perms',
'accountability_category', 'accountability_lobby', 'accountability_bonus', 'accountability_category', 'accountability_lobby', 'accountability_bonus',
'accountability_reward', 'accountability_price', 'accountability_reward', 'accountability_price',
@@ -88,9 +57,66 @@ lions = RowTable(
attach_as='lions' attach_as='lions'
) )
@lions.save_query
def add_pending(pending):
"""
pending:
List of tuples of the form `(guildid, userid, pending_coins)`.
"""
with lions.conn:
cursor = lions.conn.cursor()
data = execute_values(
cursor,
"""
UPDATE members
SET
coins = coins + t.coin_diff
FROM
(VALUES %s)
AS
t (guildid, userid, coin_diff)
WHERE
members.guildid = t.guildid
AND
members.userid = t.userid
RETURNING *
""",
pending,
fetch=True
)
return lions._make_rows(*data)
lion_ranks = Table('member_ranks', attach_as='lion_ranks') lion_ranks = Table('member_ranks', attach_as='lion_ranks')
@lions.save_query
def get_member_rank(guildid, userid, untracked):
"""
Get the time and coin ranking for the given member, ignoring the provided untracked members.
"""
with lions.conn as conn:
with conn.cursor() as curs:
curs.execute(
"""
SELECT
time_rank, coin_rank
FROM (
SELECT
userid,
row_number() OVER (ORDER BY total_tracked_time DESC, userid ASC) AS time_rank,
row_number() OVER (ORDER BY total_coins DESC, userid ASC) AS coin_rank
FROM members_totals
WHERE
guildid=%s AND userid NOT IN %s
) AS guild_ranks WHERE userid=%s
""",
(guildid, tuple(untracked), userid)
)
return curs.fetchone() or (None, None)
global_guild_blacklist = Table('global_guild_blacklist') global_guild_blacklist = Table('global_guild_blacklist')
global_user_blacklist = Table('global_user_blacklist') global_user_blacklist = Table('global_user_blacklist')
ignored_members = Table('ignored_members') ignored_members = Table('ignored_members')

View File

@@ -1,4 +1,5 @@
import pytz import pytz
from datetime import datetime, timedelta
from meta import client from meta import client
from data import tables as tb from data import tables as tb
@@ -11,7 +12,7 @@ class Lion:
Mostly acts as a transparent interface to the corresponding Row, Mostly acts as a transparent interface to the corresponding Row,
but also adds some transaction caching logic to `coins` and `tracked_time`. but also adds some transaction caching logic to `coins` and `tracked_time`.
""" """
__slots__ = ('guildid', 'userid', '_pending_coins', '_pending_time', '_member') __slots__ = ('guildid', 'userid', '_pending_coins', '_member')
# Members with pending transactions # Members with pending transactions
_pending = {} # userid -> User _pending = {} # userid -> User
@@ -24,7 +25,6 @@ class Lion:
self.userid = userid self.userid = userid
self._pending_coins = 0 self._pending_coins = 0
self._pending_time = 0
self._member = None self._member = None
@@ -41,6 +41,7 @@ class Lion:
if key in cls._lions: if key in cls._lions:
return cls._lions[key] return cls._lions[key]
else: else:
# TODO: Debug log
lion = tb.lions.fetch(key) lion = tb.lions.fetch(key)
if not lion: if not lion:
tb.lions.create_row( tb.lions.create_row(
@@ -77,23 +78,103 @@ class Lion:
@property @property
def settings(self): def settings(self):
""" """
The UserSettings object for this user. The UserSettings interface for this member.
""" """
return UserSettings(self.userid) return UserSettings(self.userid)
@property
def guild_settings(self):
"""
The GuildSettings interface for this member.
"""
return GuildSettings(self.guildid)
@property @property
def time(self): def time(self):
""" """
Amount of time the user has spent studying, accounting for pending values. Amount of time the user has spent studying, accounting for a current session.
""" """
return int(self.data.tracked_time + self._pending_time) # Base time from cached member data
time = self.data.tracked_time
# Add current session time if it exists
if session := self.session:
time += session.duration
return int(time)
@property @property
def coins(self): def coins(self):
""" """
Number of coins the user has, accounting for the pending value. Number of coins the user has, accounting for the pending value and current session.
""" """
return int(self.data.coins + self._pending_coins) # Base coin amount from cached member data
coins = self.data.coins
# Add pending coin amount
coins += self._pending_coins
# Add current session coins if applicable
if session := self.session:
coins += session.coins_earned
return int(coins)
@property
def session(self):
"""
The current study session the user is in, if any.
"""
if 'sessions' not in client.objects:
raise ValueError("Cannot retrieve session before Study module is initialised!")
return client.objects['sessions'][self.guildid].get(self.userid, None)
@property
def timezone(self):
"""
The user's configured timezone.
Shortcut to `Lion.settings.timezone.value`.
"""
return self.settings.timezone.value
@property
def day_start(self):
"""
A timezone aware datetime representing the start of the user's day (in their configured timezone).
NOTE: This might not be accurate over DST boundaries.
"""
now = datetime.now(tz=self.timezone)
return now.replace(hour=0, minute=0, second=0, microsecond=0)
@property
def remaining_in_day(self):
return ((self.day_start + timedelta(days=1)) - datetime.now(self.timezone)).total_seconds()
@property
def studied_today(self):
"""
The amount of time, in seconds, that the member has studied today.
Extracted from the session history.
"""
return tb.session_history.queries.study_time_since(self.guildid, self.userid, self.day_start)
@property
def remaining_study_today(self):
"""
Maximum remaining time (in seconds) this member can study today.
May not account for DST boundaries and leap seconds.
"""
studied_today = self.studied_today
study_cap = self.guild_settings.daily_study_cap.value
remaining_in_day = self.remaining_in_day
if remaining_in_day >= (study_cap - studied_today):
remaining = study_cap - studied_today
else:
remaining = remaining_in_day + study_cap
return remaining
def localize(self, naive_utc_dt): def localize(self, naive_utc_dt):
""" """
@@ -111,15 +192,6 @@ class Lion:
if flush: if flush:
self.flush() self.flush()
def addTime(self, amount, flush=True):
"""
Add time to a user (in seconds), optionally storing the transaction in pending.
"""
self._pending_time += amount
self._pending[self.key] = self
if flush:
self.flush()
def flush(self): def flush(self):
""" """
Flush any pending transactions to the database. Flush any pending transactions to the database.
@@ -137,7 +209,7 @@ class Lion:
if lions: if lions:
# Build userid to pending coin map # Build userid to pending coin map
pending = [ pending = [
(lion.guildid, lion.userid, int(lion._pending_coins), int(lion._pending_time)) (lion.guildid, lion.userid, int(lion._pending_coins))
for lion in lions for lion in lions
] ]
@@ -147,5 +219,4 @@ class Lion:
# Cleanup pending users # Cleanup pending users
for lion in lions: for lion in lions:
lion._pending_coins -= int(lion._pending_coins) lion._pending_coins -= int(lion._pending_coins)
lion._pending_time -= int(lion._pending_time)
cls._pending.pop(lion.key, None) cls._pending.pop(lion.key, None)

View File

@@ -61,16 +61,18 @@ async def preload_studying_members(client):
""" """
userids = list(set(member.id for guild in client.guilds for ch in guild.voice_channels for member in ch.members)) userids = list(set(member.id for guild in client.guilds for ch in guild.voice_channels for member in ch.members))
if userids: if userids:
rows = client.data.lions.fetch_rows_where(userid=userids) users = client.data.user_config.fetch_rows_where(userid=userids)
members = client.data.lions.fetch_rows_where(userid=userids)
client.log( client.log(
"Preloaded member data for {} members.".format(len(rows)), "Preloaded data for {} user with {} members.".format(len(users), len(members)),
context="CORE_LOADING" context="CORE_LOADING"
) )
@module.launch_task # Removing the sync loop in favour of the studybadge sync.
async def launch_lion_sync_loop(client): # @module.launch_task
asyncio.create_task(_lion_sync_loop()) # async def launch_lion_sync_loop(client):
# asyncio.create_task(_lion_sync_loop())
@module.unload_task @module.unload_task

View File

@@ -45,10 +45,10 @@ class Table:
Intended to be subclassed to provide more derivative access for specific tables. Intended to be subclassed to provide more derivative access for specific tables.
""" """
conn = conn conn = conn
queries = DotDict()
def __init__(self, name, attach_as=None): def __init__(self, name, attach_as=None):
self.name = name self.name = name
self.queries = DotDict()
tables[attach_as or name] = self tables[attach_as or name] = self
@_connection_guard @_connection_guard

View File

@@ -46,19 +46,15 @@ async def cmd_topcoin(ctx):
exclude.update(ctx.client.objects['blacklisted_users']) exclude.update(ctx.client.objects['blacklisted_users'])
exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) 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: if exclude:
user_data = tables.lions.select_where( args['userid'] = data.NOT(list(exclude))
guildid=ctx.guild.id,
userid=data.NOT(list(exclude)), user_data = tables.members_totals.select_where(**args)
select_columns=('userid', 'coins'),
_extra="AND coins > 0 ORDER BY coins DESC " + ("LIMIT 100" if top100 else "")
)
else:
user_data = tables.lions.select_where(
guildid=ctx.guild.id,
select_columns=('userid', 'coins'),
_extra="AND coins > 0 ORDER BY coins DESC " + ("LIMIT 100" if top100 else "")
)
# Quit early if the leaderboard is empty # Quit early if the leaderboard is empty
if not user_data: if not user_data:

View File

@@ -1,3 +1,4 @@
import difflib
import discord import discord
from cmdClient.lib import SafeCancellation from cmdClient.lib import SafeCancellation
@@ -121,9 +122,15 @@ async def cmd_config(ctx, flags):
name = parts[0] name = parts[0]
setting = setting_displaynames.get(name.lower(), None) setting = setting_displaynames.get(name.lower(), None)
if setting is None: if setting is None:
matches = difflib.get_close_matches(name, setting_displaynames.keys(), n=2)
match = "`{}`".format('` or `'.join(matches)) if matches else None
return await ctx.error_reply( return await ctx.error_reply(
"Server setting `{}` doesn't exist! Use `{}config` to see all server settings".format( "Couldn't find a setting called `{}`!\n"
name, ctx.best_prefix "{}"
"Use `{}config info` to see all the server settings.".format(
name,
"Maybe you meant {}?\n".format(match) if match else "",
ctx.best_prefix
) )
) )

View File

@@ -56,10 +56,10 @@ class video_channels(settings.ChannelList, settings.ListData, settings.Setting):
if any(channel.members for channel in guild.voice_channels) if any(channel.members for channel in guild.voice_channels)
] ]
if active_guildids: if active_guildids:
cache = {guildid: [] for guildid in active_guildids}
rows = cls._table_interface.select_where( rows = cls._table_interface.select_where(
guildid=active_guildids guildid=active_guildids
) )
cache = defaultdict(list)
for row in rows: for row in rows:
cache[row['guildid']].append(row['channelid']) cache[row['guildid']].append(row['channelid'])
cls._cache.update(cache) cls._cache.update(cache)

View File

@@ -1,9 +1,8 @@
from .module import module from .module import module
from . import data from . import badges
from . import admin from . import timers
from . import badge_tracker from . import tracking
from . import time_tracker
from . import top_cmd from . import top_cmd
from . import studybadge_cmd
from . import stats_cmd from . import stats_cmd

View File

@@ -0,0 +1,2 @@
from . import badge_tracker
from . import studybadge_cmd

View File

@@ -8,12 +8,11 @@ import discord
from meta import client from meta import client
from data.conditions import GEQ from data.conditions import GEQ
from core import Lion
from core.data import lions from core.data import lions
from utils.lib import strfdur from utils.lib import strfdur
from settings import GuildSettings from settings import GuildSettings
from .module import module from ..module import module
from .data import new_study_badges, study_badges from .data import new_study_badges, study_badges
@@ -56,7 +55,8 @@ async def update_study_badges(full=False):
# Retrieve member rows with out of date study badges # Retrieve member rows with out of date study badges
if not full and client.appdata.last_study_badge_scan is not None: if not full and client.appdata.last_study_badge_scan is not None:
update_rows = new_study_badges.select_where( update_rows = new_study_badges.select_where(
_timestamp=GEQ(client.appdata.last_study_badge_scan or 0) _timestamp=GEQ(client.appdata.last_study_badge_scan or 0),
_extra="OR session_start IS NOT NULL"
) )
else: else:
update_rows = new_study_badges.select_where() update_rows = new_study_badges.select_where()
@@ -303,11 +303,10 @@ async def study_badge_tracker():
await asyncio.sleep(60) await asyncio.sleep(60)
async def _update_member_studybadge(member): async def update_member_studybadge(member):
""" """
Checks and (if required) updates the study badge for a single member. Checks and (if required) updates the study badge for a single member.
""" """
Lion.fetch(member.guild.id, member.id).flush()
update_rows = new_study_badges.select_where( update_rows = new_study_badges.select_where(
guildid=member.guild.id, guildid=member.guild.id,
userid=member.id userid=member.id
@@ -331,16 +330,6 @@ async def _update_member_studybadge(member):
await _update_guild_badges(member.guild, update_rows) await _update_guild_badges(member.guild, update_rows)
@client.add_after_event("voice_state_update")
async def voice_studybadge_updater(client, member, before, after):
if not client.is_ready():
# The poll loop will pick it up
return
if before.channel and not after.channel:
await _update_member_studybadge(member)
@module.launch_task @module.launch_task
async def launch_study_badge_tracker(client): async def launch_study_badge_tracker(client):
asyncio.create_task(study_badge_tracker()) asyncio.create_task(study_badge_tracker())

View File

@@ -2,8 +2,6 @@ from cachetools import cached
from data import Table, RowTable from data import Table, RowTable
untracked_channels = Table('untracked_channels')
study_badges = RowTable( study_badges = RowTable(
'study_badges', 'study_badges',
('badgeid', 'guildid', 'roleid', 'required_time'), ('badgeid', 'guildid', 'roleid', 'required_time'),

View File

@@ -12,7 +12,7 @@ from wards import is_guild_admin
from core.data import lions from core.data import lions
from settings import GuildSettings from settings import GuildSettings
from .module import module from ..module import module
from .data import study_badges, guild_role_cache, new_study_badges from .data import study_badges, guild_role_cache, new_study_badges
from .badge_tracker import _update_guild_badges from .badge_tracker import _update_guild_badges

View File

@@ -1,18 +1,22 @@
import datetime from datetime import datetime, timedelta
import discord import discord
from cmdClient.checks import in_guild from cmdClient.checks import in_guild
from utils.lib import strfdur from utils.lib import prop_tabulate, utc_now
from data import tables from data import tables
from data.conditions import LEQ
from core import Lion from core import Lion
from .tracking.data import session_history
from .module import module from .module import module
@module.cmd( @module.cmd(
"stats", "stats",
group="Statistics", group="Statistics",
desc="View a summary of your study statistics!", desc="View your personal server study statistics!",
aliases=('profile',),
allow_before_ready=True allow_before_ready=True
) )
@in_guild() @in_guild()
@@ -24,6 +28,7 @@ async def cmd_stats(ctx):
Description: Description:
View the study statistics for yourself or the mentioned user. View the study statistics for yourself or the mentioned user.
""" """
# Identify the target
if ctx.args: if ctx.args:
if not ctx.msg.mentions: if not ctx.msg.mentions:
return await ctx.error_reply("Please mention a user to view their statistics!") return await ctx.error_reply("Please mention a user to view their statistics!")
@@ -31,54 +36,235 @@ async def cmd_stats(ctx):
else: else:
target = ctx.author target = ctx.author
# Collect the required target data # System sync
Lion.sync()
# Fetch the required data
lion = Lion.fetch(ctx.guild.id, target.id) lion = Lion.fetch(ctx.guild.id, target.id)
rank_data = tables.lion_ranks.select_one_where(
history = session_history.select_where(
guildid=ctx.guild.id,
userid=target.id, userid=target.id,
guildid=ctx.guild.id select_columns=(
"start_time",
"(start_time + duration * interval '1 second') AS end_time"
),
_extra="ORDER BY start_time DESC"
) )
# Extract and format data # Current economy balance (accounting for current session)
time = strfdur(lion.time)
coins = lion.coins coins = lion.coins
workouts = lion.data.workout_count season_time = lion.time
if lion.data.last_study_badgeid: workout_total = lion.data.workout_count
badge_row = tables.study_badges.fetch(lion.data.last_study_badgeid)
league = "<@&{}>".format(badge_row.roleid) # Leaderboard ranks
exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members)
exclude.update(ctx.client.objects['blacklisted_users'])
exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id])
if target.id in exclude:
time_rank = None
coin_rank = None
else: else:
league = "No league yet!" time_rank, coin_rank = tables.lions.queries.get_member_rank(ctx.guild.id, target.id, list(exclude or [0]))
time_lb_pos = rank_data['time_rank'] # Study time
coin_lb_pos = rank_data['coin_rank'] # First get the all/month/week/day timestamps
day_start = lion.day_start
period_timestamps = (
datetime(1970, 1, 1),
day_start.replace(day=1),
day_start - timedelta(days=day_start.weekday()),
day_start
)
study_times = [0, 0, 0, 0]
for i, timestamp in enumerate(period_timestamps):
study_time = tables.session_history.queries.study_time_since(ctx.guild.id, target.id, timestamp)
if not study_time:
# So we don't make unecessary database calls
break
study_times[i] = study_time
# Build embed # Streak statistics
streak = 0
current_streak = None
max_streak = 0
day_attended = True if 'sessions' in ctx.client.objects and lion.session else None
date = 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)
if current_streak is None:
current_streak = streak
streak = 0
# Handle loop exit state, i.e. the last streak
if day_attended:
streak += 1
max_streak = max(max_streak, streak)
if current_streak is None:
current_streak = streak
# Accountability stats
accountability = tables.accountability_member_info.select_where(
userid=target.id,
start_at=LEQ(utc_now()),
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
_extra="ORDER BY start_at DESC"
)
if len(accountability):
acc_duration = sum(row['duration'] for row in accountability)
acc_attended = sum(row['attended'] for row in accountability)
acc_total = len(accountability)
acc_rate = (acc_attended * 100) / acc_total
else:
acc_duration = 0
acc_rate = 0
# Study League
guild_badges = tables.study_badges.fetch_rows_where(guildid=ctx.guild.id)
if lion.data.last_study_badgeid:
current_badge = tables.study_badges.fetch(lion.data.last_study_badgeid)
else:
current_badge = None
next_badge = min(
(badge for badge in guild_badges
if badge.required_time > (current_badge.required_time if current_badge else 0)),
key=lambda badge: badge.required_time,
default=None
)
# We have all the data
# Now start building the embed
embed = discord.Embed( embed = discord.Embed(
colour=discord.Colour.blue(), colour=discord.Colour.orange(),
timestamp=datetime.datetime.utcnow(), title="Study Profile for {}".format(str(target))
title="Revision Statistics"
).set_footer(text=str(target), icon_url=target.avatar_url).set_thumbnail(url=target.avatar_url)
embed.add_field(
name="📚 Study Time",
value=time
) )
embed.add_field( embed.set_thumbnail(url=target.avatar_url)
name="🦁 Revision League",
value=league # Add studying since if they have studied
if history:
embed.set_footer(text="Studying Since")
embed.timestamp = history[-1]['start_time']
# Set the description based on season time and server rank
if season_time:
time_str = "**{}:{:02}**".format(
season_time // 3600,
(season_time // 60) % 60
)
if time_rank is None:
rank_str = None
elif time_rank == 1:
rank_str = "1st"
elif time_rank == 2:
rank_str = "2nd"
elif time_rank == 3:
rank_str = "3rd"
else:
time_rank = "{}th".format(time_rank)
embed.description = "{} has studied for **{}**{}{}".format(
target.mention,
time_str,
" this season" if study_times[0] - season_time > 60 else "",
", and is ranked **{}** in the server!".format(rank_str) if rank_str else "."
)
else:
embed.description = "{} hasn't studied in this server yet!".format(target.mention)
# Build the stats table
stats = {}
stats['Coins Earned'] = "**{}** LC".format(
coins,
# "Rank `{}`".format(coin_rank) if coins and coin_rank else "Unranked"
) )
embed.add_field( if workout_total:
name="🦁 LionCoins", stats['Workouts'] = "**{}** sessions".format(workout_total)
value=coins if acc_duration:
stats['Accountability'] = "**{}** hours (`{:.0f}%` attended)".format(
acc_duration // 3600,
acc_rate
)
stats['Study Streak'] = "**{}** days{}".format(
current_streak,
" (longest **{}** days)".format(max_streak) if max_streak else ''
) )
embed.add_field(
name="🏆 Leaderboard Position", stats_table = prop_tabulate(*zip(*stats.items()))
value="Time: {}\n LC: {}".format(time_lb_pos, coin_lb_pos)
) # Build the time table
embed.add_field( time_table = prop_tabulate(
name="💪 Workouts", ('Daily', 'Weekly', 'Monthly', 'All Time'),
value=workouts ["{:02}:{:02}".format(t // 3600, (t // 60) % 60) for t in reversed(study_times)]
)
embed.add_field(
name="📋 Attendence",
value="TBD"
) )
# The order they are added depends on the size of the stats table
if len(stats) >= 4:
embed.add_field(name="Statistics", value=stats_table)
embed.add_field(name="Study Time", value=time_table)
else:
embed.add_field(name="Study Time", value=time_table)
embed.add_field(name="Statistics", value=stats_table)
# Add the study league field
if current_badge or next_badge:
current_str = (
"You are currently in <@&{}>!".format(current_badge.roleid) if current_badge else "No league yet!"
)
if next_badge:
needed = max(next_badge.required_time - season_time, 0)
next_str = "Study for **{:02}:{:02}** more to achieve <@&{}>.".format(
needed // 3600,
(needed // 60) % 60,
next_badge.roleid
)
else:
next_str = "You have reached the highest league! Congratulations!"
embed.add_field(
name="Study League",
value="{}\n{}".format(current_str, next_str),
inline=False
)
await ctx.reply(embed=embed) await ctx.reply(embed=embed)

View File

View File

@@ -38,27 +38,20 @@ async def cmd_top(ctx):
) )
top100 = (ctx.args == "100" or ctx.alias == "top100") top100 = (ctx.args == "100" or ctx.alias == "top100")
# Flush any pending coin transactions
Lion.sync()
# Fetch the leaderboard # Fetch the leaderboard
exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members) exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members)
exclude.update(ctx.client.objects['blacklisted_users']) exclude.update(ctx.client.objects['blacklisted_users'])
exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id])
args = {
'guildid': ctx.guild.id,
'select_columns': ('userid', 'total_tracked_time::INTEGER'),
'_extra': "AND total_tracked_time > 0 ORDER BY total_tracked_time DESC " + ("LIMIT 100" if top100 else "")
}
if exclude: if exclude:
user_data = tables.lions.select_where( args['userid'] = data.NOT(list(exclude))
guildid=ctx.guild.id,
userid=data.NOT(list(exclude)), user_data = tables.members_totals.select_where(**args)
select_columns=('userid', 'tracked_time'),
_extra="AND tracked_time > 0 ORDER BY tracked_time DESC " + ("LIMIT 100" if top100 else "")
)
else:
user_data = tables.lions.select_where(
guildid=ctx.guild.id,
select_columns=('userid', 'tracked_time'),
_extra="AND tracked_time > 0 ORDER BY tracked_time DESC " + ("LIMIT 100" if top100 else "")
)
# Quit early if the leaderboard is empty # Quit early if the leaderboard is empty
if not user_data: if not user_data:

View File

@@ -0,0 +1,3 @@
from . import data
from . import settings
from . import session_tracker

View File

@@ -0,0 +1,62 @@
from data import Table, RowTable, tables
from utils.lib import FieldEnum
untracked_channels = Table('untracked_channels')
class SessionChannelType(FieldEnum):
"""
The possible session channel types.
"""
# NOTE: "None" stands for Unknown, and the STANDARD description should be replaced with the channel name
STANDARD = 'STANDARD', "Standard"
ACCOUNTABILITY = 'ACCOUNTABILITY', "Accountability Room"
RENTED = 'RENTED', "Private Room"
EXTERNAL = 'EXTERNAL', "Unknown"
session_history = Table('session_history')
current_sessions = RowTable(
'current_sessions',
('guildid', 'userid', 'channelid', 'channel_type',
'start_time',
'live_duration', 'live_start',
'stream_duration', 'stream_start',
'video_duration', 'video_start',
'hourly_coins', 'hourly_live_coins'),
('guildid', 'userid'),
cache={} # Keep all current sessions in cache
)
@current_sessions.save_query
def close_study_session(guildid, userid):
"""
Close a member's current session if it exists and update the member cache.
"""
# Execute the `close_study_session` database function
with current_sessions.conn as conn:
cursor = conn.cursor()
cursor.callproc('close_study_session', (guildid, userid))
rows = cursor.fetchall()
# The row has been deleted, remove the from current sessions cache
current_sessions.row_cache.pop((guildid, userid), None)
# Use the function output to update the member cache
tables.lions._make_rows(*rows)
@session_history.save_query
def study_time_since(guildid, userid, timestamp):
"""
Retrieve the total member study time (in seconds) since the given timestamp.
Includes the current session, if it exists.
"""
with session_history.conn as conn:
cursor = conn.cursor()
cursor.callproc('study_time_since', (guildid, userid, timestamp))
rows = cursor.fetchall()
return (rows[0][0] if rows else None) or 0
members_totals = Table('members_totals')

View File

@@ -0,0 +1,499 @@
import asyncio
import discord
import logging
import traceback
from typing import Dict
from collections import defaultdict
from utils.lib import utc_now
from data import tables
from core import Lion
from meta import client
from ..module import module
from .data import current_sessions, SessionChannelType
from .settings import untracked_channels, hourly_reward, hourly_live_bonus
class Session:
"""
A `Session` describes an ongoing study session by a single guild member.
A member is counted as studying when they are in a tracked voice channel.
This class acts as an opaque interface to the corresponding `sessions` data row.
"""
__slots__ = (
'guildid',
'userid',
'_expiry_task'
)
# Global cache of ongoing sessions
sessions: Dict[int, Dict[int, 'Session']] = defaultdict(dict)
# Global cache of members pending session start (waiting for daily cap reset)
members_pending: Dict[int, Dict[int, asyncio.Task]] = defaultdict(dict)
def __init__(self, guildid, userid):
self.guildid = guildid
self.userid = userid
self._expiry_task: asyncio.Task = None
@classmethod
def get(cls, guildid, userid):
"""
Fetch the current session for the provided member.
If there is no current session, returns `None`.
"""
return cls.sessions[guildid].get(userid, None)
@classmethod
def start(cls, member: discord.Member, state: discord.VoiceState):
"""
Start a new study session for the provided member.
"""
guildid = member.guild.id
userid = member.id
now = utc_now()
if userid in cls.sessions[guildid]:
raise ValueError("A session for this member already exists!")
# If the user is study capped, schedule the session start for the next day
if (lion := Lion.fetch(guildid, userid)).remaining_study_today <= 0:
if pending := cls.members_pending[guildid].pop(userid, None):
pending.cancel()
task = asyncio.create_task(cls._delayed_start(guildid, userid, member, state))
cls.members_pending[guildid][userid] = task
client.log(
"Member (uid:{}) in (gid:{}) is study capped, "
"delaying session start for {} seconds until start of next day.".format(
userid, guildid, lion.remaining_in_day
),
context="SESSION_TRACKER",
level=logging.DEBUG
)
return
# TODO: More reliable channel type determination
if state.channel.id in tables.rented.row_cache:
channel_type = SessionChannelType.RENTED
elif state.channel.category and state.channel.category.id == lion.guild_settings.accountability_category.data:
channel_type = SessionChannelType.ACCOUNTABILITY
else:
channel_type = SessionChannelType.STANDARD
current_sessions.create_row(
guildid=guildid,
userid=userid,
channelid=state.channel.id,
channel_type=channel_type,
start_time=now,
live_start=now if (state.self_video or state.self_stream) else None,
stream_start=now if state.self_stream else None,
video_start=now if state.self_video else None,
hourly_coins=hourly_reward.get(guildid).value,
hourly_live_coins=hourly_live_bonus.get(guildid).value
)
session = cls(guildid, userid).activate()
client.log(
"Started session: {}".format(session.data),
context="SESSION_TRACKER",
level=logging.DEBUG,
)
@classmethod
async def _delayed_start(cls, guildid, userid, *args):
delay = Lion.fetch(guildid, userid).remaining_in_day
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
pass
else:
cls.start(*args)
@property
def key(self):
"""
RowTable Session identification key.
"""
return (self.guildid, self.userid)
@property
def lion(self):
"""
The Lion member object associated with this member.
"""
return Lion.fetch(self.guildid, self.userid)
@property
def data(self):
"""
Row of the `current_sessions` table corresponding to this session.
"""
return current_sessions.fetch(self.key)
@property
def duration(self):
"""
Current duration of the session.
"""
return (utc_now() - self.data.start_time).total_seconds()
@property
def coins_earned(self):
"""
Number of coins earned so far.
"""
data = self.data
coins = self.duration * data.hourly_coins
coins += data.live_duration * data.hourly_live_coins
if data.live_start:
coins += (utc_now() - data.live_start).total_seconds() * data.hourly_live_coins
return coins // 3600
def activate(self):
"""
Activate the study session.
This adds the session to the studying members cache,
and schedules the session expiry, based on the daily study cap.
"""
# Add to the active cache
self.sessions[self.guildid][self.userid] = self
# Schedule the session expiry
self.schedule_expiry()
# Return self for easy chaining
return self
def schedule_expiry(self):
"""
Schedule session termination when the user reaches the maximum daily study time.
"""
asyncio.create_task(self._schedule_expiry())
async def _schedule_expiry(self):
# Cancel any existing expiry
if self._expiry_task and not self._expiry_task.done():
self._expiry_task.cancel()
# Wait for the maximum session length
try:
self._expiry_task = await asyncio.sleep(self.lion.remaining_study_today)
except asyncio.CancelledError:
pass
else:
if self.lion.remaining_study_today <= 0:
# End the session
# Note that the user will not automatically start a new session when the day starts
# TODO: Notify user? Disconnect them?
client.log(
"Session for (uid:{}) in (gid:{}) reached daily guild study cap.\n{}".format(
self.userid, self.guildid, self.data
),
context="SESSION_TRACKER"
)
self.finish()
else:
# It's possible the expiry time was pushed forwards while waiting
# If so, reschedule
self.schedule_expiry()
def finish(self):
"""
Close the study session.
"""
# Note that save_live_status doesn't need to be called here
# The database saving procedure will account for the values.
current_sessions.queries.close_study_session(*self.key)
# Remove session from active cache
self.sessions[self.guildid].pop(self.userid, None)
# Cancel any existing expiry task
if self._expiry_task and not self._expiry_task.done():
self._expiry_task.cancel()
def save_live_status(self, state: discord.VoiceState):
"""
Update the saved live status of the member.
"""
has_video = state.self_video
has_stream = state.self_stream
is_live = has_video or has_stream
now = utc_now()
data = self.data
with data.batch_update():
# Update video session stats
if data.video_start:
data.video_duration += (now - data.video_start).total_seconds()
data.video_start = now if has_video else None
# Update stream session stats
if data.stream_start:
data.stream_duration += (now - data.stream_start).total_seconds()
data.stream_start = now if has_stream else None
# Update overall live session stats
if data.live_start:
data.live_duration += (now - data.live_start).total_seconds()
data.live_start = now if is_live else None
async def session_voice_tracker(client, member, before, after):
"""
Voice update event dispatcher for study session tracking.
"""
guild = member.guild
Lion.fetch(guild.id, member.id)
session = Session.get(guild.id, member.id)
if before.channel == after.channel:
# Voice state change without moving channel
if session and ((before.self_video != after.self_video) or (before.self_stream != after.self_stream)):
# Live status has changed!
session.save_live_status(after)
else:
# Member changed channel
# End the current session and start a new one, if applicable
if session:
if (scid := session.data.channelid) and (not before.channel or scid != before.channel.id):
client.log(
"The previous voice state for "
"member {member.name} (uid:{member.id}) in {guild.name} (gid:{guild.id}) "
"does not match their current study session!\n"
"Session channel is (cid:{scid}), but the previous channel is {previous}.".format(
member=member,
guild=member.guild,
scid=scid,
previous="{0.name} (cid:{0.id})".format(before.channel) if before.channel else "None"
),
context="SESSION_TRACKER",
level=logging.ERROR
)
client.log(
"Ending study session for {member.name} (uid:{member.id}) "
"in {member.guild.id} (gid:{member.guild.id}) since they left the voice channel.\n{session}".format(
member=member,
session=session.data
),
context="SESSION_TRACKER",
post=False
)
# End the current session
session.finish()
elif pending := Session.members_pending[guild.id].pop(member.id, None):
client.log(
"Cancelling pending study session for {member.name} (uid:{member.id}) "
"in {member.guild.name} (gid:{member.guild.id}) since they left the voice channel.".format(
member=member
),
context="SESSION_TRACKER",
post=False
)
pending.cancel()
if after.channel:
blacklist = client.objects['blacklisted_users']
guild_blacklist = client.objects['ignored_members'][guild.id]
untracked = untracked_channels.get(guild.id).data
start_session = (
(after.channel.id not in untracked)
and (member.id not in blacklist)
and (member.id not in guild_blacklist)
)
if start_session:
# Start a new session for the member
client.log(
"Starting a new voice channel study session for {member.name} (uid:{member.id}) "
"in {member.guild.name} (gid:{member.guild.id}).".format(
member=member,
),
context="SESSION_TRACKER",
post=False
)
session = Session.start(member, after)
async def leave_guild_sessions(client, guild):
"""
`guild_leave` hook.
Close all sessions in the guild when we leave.
"""
sessions = list(Session.sessions[guild.id].values())
for session in sessions:
session.finish()
client.log(
"Left {} (gid:{}) and closed {} ongoing study sessions.".format(guild.name, guild.id, len(sessions)),
context="SESSION_TRACKER"
)
async def join_guild_sessions(client, guild):
"""
`guild_join` hook.
Refresh all sessions for the guild when we rejoin.
"""
# Delete existing current sessions, which should have been closed when we left
# It is possible we were removed from the guild during an outage
current_sessions.delete_where(guildid=guild.id)
untracked = untracked_channels.get(guild.id).data
members = [
member
for channel in guild.voice_channels
for member in channel.members
if channel.members and channel.id not in untracked
]
for member in members:
client.log(
"Starting new session for '{}' (uid: {}) in '{}' (cid: {}) of '{}' (gid: {})".format(
member.name,
member.id,
member.voice.channel.name,
member.voice.channel.id,
member.guild.name,
member.guild.id
),
context="SESSION_TRACKER",
level=logging.INFO,
post=False
)
Session.start(member, member.voice)
# Log newly started sessions
client.log(
"Joined {} (gid:{}) and started {} new study sessions from current voice channel members.".format(
guild.name,
guild.id,
len(members)
),
context="SESSION_TRACKER",
)
async def _init_session_tracker(client):
"""
Load ongoing saved study sessions into the session cache,
update them depending on the current voice states,
and attach the voice event handler.
"""
# Ensure the client caches are ready and guilds are chunked
await client.wait_until_ready()
# Pre-cache the untracked channels
await untracked_channels.launch_task(client)
# Log init start and define logging counters
client.log(
"Loading ongoing study sessions.",
context="SESSION_INIT",
level=logging.DEBUG
)
resumed = 0
ended = 0
# Grab all ongoing sessions from data
rows = current_sessions.fetch_rows_where()
# Iterate through, resume or end as needed
for row in rows:
if (guild := client.get_guild(row.guildid)) is not None and row.channelid is not None:
try:
# Load the Session
session = Session(row.guildid, row.userid)
# Find the channel and member voice state
voice = None
if channel := guild.get_channel(row.channelid):
voice = next((member.voice for member in channel.members if member.id == row.userid), None)
# Resume or end as required
if voice and voice.channel:
client.log(
"Resuming ongoing session: {}".format(row),
context="SESSION_INIT",
level=logging.DEBUG
)
session.activate()
session.save_live_status(voice)
resumed += 1
else:
client.log(
"Ending already completed session: {}".format(row),
context="SESSION_INIT",
level=logging.DEBUG
)
session.finish()
ended += 1
except Exception:
# Fatal error
client.log(
"Fatal error occurred initialising session: {}\n{}".format(row, traceback.format_exc()),
context="SESSION_INIT",
level=logging.CRITICAL
)
module.ready = False
return
# Log resumed sessions
client.log(
"Resumed {} ongoing study sessions, and ended {}.".format(resumed, ended),
context="SESSION_INIT",
level=logging.INFO
)
# Now iterate through members of all tracked voice channels
# Start sessions if they don't already exist
tracked_channels = [
channel
for guild in client.guilds
for channel in guild.voice_channels
if channel.members and channel.id not in untracked_channels.get(guild.id).data
]
new_members = [
member
for channel in tracked_channels
for member in channel.members
if not Session.get(member.guild.id, member.id)
]
for member in new_members:
client.log(
"Starting new session for '{}' (uid: {}) in '{}' (cid: {}) of '{}' (gid: {})".format(
member.name,
member.id,
member.voice.channel.name,
member.voice.channel.id,
member.guild.name,
member.guild.id
),
context="SESSION_INIT",
level=logging.DEBUG
)
Session.start(member, member.voice)
# Log newly started sessions
client.log(
"Started {} new study sessions from current voice channel members.".format(len(new_members)),
context="SESSION_INIT",
level=logging.INFO
)
# Now that we are in a valid initial state, attach the session event handler
client.add_after_event("voice_state_update", session_voice_tracker)
client.add_after_event("guild_remove", leave_guild_sessions)
client.add_after_event("guild_join", join_guild_sessions)
@module.launch_task
async def launch_session_tracker(client):
"""
Launch the study session initialiser.
Doesn't block on the client being ready.
"""
client.objects['sessions'] = Session.sessions
asyncio.create_task(_init_session_tracker(client))

View File

@@ -1,5 +1,3 @@
from collections import defaultdict
import settings import settings
from settings import GuildSettings from settings import GuildSettings
from wards import guild_admin from wards import guild_admin
@@ -52,10 +50,10 @@ class untracked_channels(settings.ChannelList, settings.ListData, settings.Setti
if any(channel.members for channel in guild.voice_channels) if any(channel.members for channel in guild.voice_channels)
] ]
if active_guildids: if active_guildids:
cache = {guildid: [] for guildid in active_guildids}
rows = cls._table_interface.select_where( rows = cls._table_interface.select_where(
guildid=active_guildids guildid=active_guildids
) )
cache = defaultdict(list)
for row in rows: for row in rows:
cache[row['guildid']].append(row['channelid']) cache[row['guildid']].append(row['channelid'])
cls._cache.update(cache) cls._cache.update(cache)
@@ -111,3 +109,33 @@ class hourly_live_bonus(settings.Integer, settings.GuildSetting):
@property @property
def success_response(self): def success_response(self):
return "Members will be rewarded an extra `{}` LionCoins per hour if they stream.".format(self.formatted) return "Members will be rewarded an extra `{}` LionCoins per hour if they stream.".format(self.formatted)
@GuildSettings.attach_setting
class daily_study_cap(settings.Duration, settings.GuildSetting):
category = "Study Tracking"
attr_name = "daily_study_cap"
_data_column = "daily_study_cap"
display_name = "daily_study_cap"
desc = "Maximum amount of recorded study time per member per day."
_default = 16 * 60 * 60
_default_multiplier = 60 * 60
_max = 25 * 60 * 60
long_desc = (
"The maximum amount of study time that can be recorded for a member per day, "
"intended to remove system encouragement for unhealthy or obsessive behaviour.\n"
"The member may study for longer, but their sessions will not be tracked. "
"The start and end of the day are determined by the member's configured timezone."
)
@property
def success_response(self):
# Refresh expiry for all sessions in the guild
[session.schedule_expiry() for session in self.client.objects['sessions'][self.id].values()]
return "The maximum tracked daily study time is now {}.".format(self.formatted)

View File

@@ -7,8 +7,8 @@ from time import time
from meta import client from meta import client
from core import Lion from core import Lion
from .module import module from ..module import module
from . import admin from .settings import untracked_channels, hourly_reward, hourly_live_bonus
last_scan = {} # guildid -> timestamp last_scan = {} # guildid -> timestamp
@@ -36,9 +36,9 @@ def _scan(guild):
if interval > 60 * 20: if interval > 60 * 20:
return return
untracked = admin.untracked_channels.get(guild.id).data untracked = untracked_channels.get(guild.id).data
hourly_reward = admin.hourly_reward.get(guild.id).data guild_hourly_reward = hourly_reward.get(guild.id).data
hourly_live_bonus = admin.hourly_live_bonus.get(guild.id).data guild_hourly_live_bonus = hourly_live_bonus.get(guild.id).data
channel_members = ( channel_members = (
channel.members for channel in guild.voice_channels if channel.id not in untracked channel.members for channel in guild.voice_channels if channel.id not in untracked
@@ -61,9 +61,9 @@ def _scan(guild):
lion.addTime(interval, flush=False) lion.addTime(interval, flush=False)
# Add coins # Add coins
hour_reward = hourly_reward hour_reward = guild_hourly_reward
if member.voice.self_stream or member.voice.self_video: if member.voice.self_stream or member.voice.self_video:
hour_reward += hourly_live_bonus hour_reward += guild_hourly_live_bonus
lion.addCoins(hour_reward * interval / (3600), flush=False) lion.addCoins(hour_reward * interval / (3600), flush=False)
@@ -102,7 +102,7 @@ async def _study_tracker():
@module.launch_task @module.launch_task
async def launch_study_tracker(client): async def launch_study_tracker(client):
# First pre-load the untracked channels # First pre-load the untracked channels
await admin.untracked_channels.launch_task(client) await untracked_channels.launch_task(client)
asyncio.create_task(_study_tracker()) asyncio.create_task(_study_tracker())

View File

@@ -0,0 +1,180 @@
-- DROP TYPE IF EXISTS SessionChannelType CASCADE;
-- DROP TABLE IF EXISTS session_history CASCADE;
-- DROP TABLE IF EXISTS current_sessions CASCADE;
-- DROP FUNCTION IF EXISTS close_study_session(_guildid BIGINT, _userid BIGINT);
-- DROP FUNCTION IF EXISTS study_time_since(_guildid BIGINT, _userid BIGINT, _timestamp TIMESTAMPTZ)
-- DROP VIEW IF EXISTS current_sessions_totals CASCADE;
DROP VIEW IF EXISTS member_totals CASCADE;
DROP VIEW IF EXISTS member_ranks CASCADE;
DROP VIEW IF EXISTS current_study_badges CASCADE;
DROP VIEW IF EXISTS new_study_badges CASCADE;
CREATE TYPE SessionChannelType AS ENUM (
'STANDARD',
'ACCOUNTABILITY',
'RENTED',
'EXTERNAL'
);
CREATE TABLE session_history(
sessionid SERIAL PRIMARY KEY,
guildid BIGINT NOT NULL,
userid BIGINT NOT NULL,
channelid BIGINT,
channel_type SessionChannelType,
start_time TIMESTAMPTZ NOT NULL,
duration INTEGER NOT NULL,
coins_earned INTEGER NOT NULL,
live_duration INTEGER DEFAULT 0,
stream_duration INTEGER DEFAULT 0,
video_duration INTEGER DEFAULT 0,
FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE
);
CREATE INDEX session_history_members ON session_history (guildid, userid, start_time);
CREATE TABLE current_sessions(
guildid BIGINT NOT NULL,
userid BIGINT NOT NULL,
channelid BIGINT,
channel_type SessionChannelType,
start_time TIMESTAMPTZ DEFAULT now(),
live_duration INTEGER DEFAULT 0,
live_start TIMESTAMPTZ,
stream_duration INTEGER DEFAULT 0,
stream_start TIMESTAMPTZ,
video_duration INTEGER DEFAULT 0,
video_start TIMESTAMPTZ,
hourly_coins INTEGER NOT NULL,
hourly_live_coins INTEGER NOT NULL,
FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE
);
CREATE UNIQUE INDEX current_session_members ON current_sessions (guildid, userid);
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
), saved_sesh AS (
INSERT INTO session_history (
guildid, userid, channelid, channel_type, start_time,
duration, stream_duration, video_duration, live_duration,
coins_earned
) SELECT
guildid, userid, channelid, channel_type, start_time,
total_duration, total_stream_duration, total_video_duration, total_live_duration,
(total_duration * hourly_coins + live_duration * hourly_live_coins) / 3600
FROM current_sesh
RETURNING *
)
UPDATE members
SET
tracked_time=(tracked_time + saved_sesh.duration),
coins=(coins + saved_sesh.coins_earned)
FROM saved_sesh
WHERE members.guildid=saved_sesh.guildid AND members.userid=saved_sesh.userid
RETURNING members.*;
END;
$$ LANGUAGE PLPGSQL;
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;
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=_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=_guildid
AND userid=_userid
) AS sessions
);
END;
$$ LANGUAGE PLPGSQL;
ALTER TABLE guild_config ADD COLUMN daily_study_cap INTEGER;
INSERT INTO VersionHistory (version, author) VALUES (6, 'v5-v6 Migration');

View File

@@ -4,7 +4,7 @@ CREATE TABLE VersionHistory(
time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
author TEXT author TEXT
); );
INSERT INTO VersionHistory (version, author) VALUES (5, 'Initial Creation'); INSERT INTO VersionHistory (version, author) VALUES (6, 'Initial Creation');
CREATE OR REPLACE FUNCTION update_timestamp_column() CREATE OR REPLACE FUNCTION update_timestamp_column()
@@ -77,7 +77,8 @@ CREATE TABLE guild_config(
greeting_message TEXT, greeting_message TEXT,
returning_message TEXT, returning_message TEXT,
starting_funds INTEGER, starting_funds INTEGER,
persist_roles BOOLEAN persist_roles BOOLEAN,
daily_study_cap INTEGER
); );
CREATE TABLE ignored_members( CREATE TABLE ignored_members(
@@ -407,24 +408,165 @@ CREATE INDEX member_timestamps ON members (_timestamp);
CREATE TRIGGER update_members_timstamp BEFORE UPDATE CREATE TRIGGER update_members_timstamp BEFORE UPDATE
ON members FOR EACH ROW EXECUTE PROCEDURE ON members FOR EACH ROW EXECUTE PROCEDURE
update_timestamp_column(); update_timestamp_column();
-- }}}
-- Study Session Data {{{
CREATE TYPE SessionChannelType AS ENUM (
'STANDARD',
'ACCOUNTABILITY',
'RENTED',
'EXTERNAL',
);
CREATE TABLE session_history(
sessionid SERIAL PRIMARY KEY,
guildid BIGINT NOT NULL,
userid BIGINT NOT NULL,
channelid BIGINT,
channel_type SessionChannelType,
start_time TIMESTAMPTZ NOT NULL,
duration INTEGER NOT NULL,
coins_earned INTEGER NOT NULL,
live_duration INTEGER DEFAULT 0,
stream_duration INTEGER DEFAULT 0,
video_duration INTEGER DEFAULT 0,
FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE
);
CREATE INDEX session_history_members ON session_history (guildid, userid, start_time);
CREATE TABLE current_sessions(
guildid BIGINT NOT NULL,
userid BIGINT NOT NULL,
channelid BIGINT,
channel_type SessionChannelType,
start_time TIMESTAMPTZ DEFAULT now(),
live_duration INTEGER DEFAULT 0,
live_start TIMESTAMPTZ,
stream_duration INTEGER DEFAULT 0,
stream_start TIMESTAMPTZ,
video_duration INTEGER DEFAULT 0,
video_start TIMESTAMPTZ,
hourly_coins INTEGER NOT NULL,
hourly_live_coins INTEGER NOT NULL,
FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid) ON DELETE CASCADE
);
CREATE UNIQUE INDEX current_session_members ON current_sessions (guildid, userid);
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=_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=_guildid
AND userid=_userid
) AS sessions
);
END;
$$ LANGUAGE PLPGSQL;
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
), saved_sesh AS (
INSERT INTO session_history (
guildid, userid, channelid, channel_type, start_time,
duration, stream_duration, video_duration, live_duration,
coins_earned
) SELECT
guildid, userid, channelid, channel_type, start_time,
total_duration, total_stream_duration, total_video_duration, total_live_duration,
(total_duration * hourly_coins + live_duration * hourly_live_coins) / 3600
FROM current_sesh
RETURNING *
)
UPDATE members
SET
tracked_time=(tracked_time + saved_sesh.duration),
coins=(coins + saved_sesh.coins_earned)
FROM saved_sesh
WHERE members.guildid=saved_sesh.guildid AND members.userid=saved_sesh.userid
RETURNING members.*;
END;
$$ LANGUAGE PLPGSQL;
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 CREATE VIEW member_ranks AS
SELECT SELECT
*, *,
row_number() OVER (PARTITION BY guildid ORDER BY tracked_time DESC, userid ASC) AS time_rank, 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 coins DESC, userid ASC) AS coin_rank row_number() OVER (PARTITION BY guildid ORDER BY total_coins DESC, userid ASC) AS coin_rank
FROM members; FROM members_totals;
-- }}}
-- Study Badge Data {{{
CREATE VIEW current_study_badges AS CREATE VIEW current_study_badges AS
SELECT SELECT
*, *,
(SELECT r.badgeid (SELECT r.badgeid
FROM study_badges r FROM study_badges r
WHERE r.guildid = members.guildid AND members.tracked_time > r.required_time WHERE r.guildid = members_totals.guildid AND members_totals.total_tracked_time > r.required_time
ORDER BY r.required_time DESC ORDER BY r.required_time DESC
LIMIT 1) AS current_study_badgeid LIMIT 1) AS current_study_badgeid
FROM members; FROM members_totals;
CREATE VIEW new_study_badges AS CREATE VIEW new_study_badges AS
SELECT SELECT
@@ -527,6 +669,7 @@ CREATE TABLE reaction_role_expiring(
reactionid INTEGER REFERENCES reaction_role_reactions (reactionid) ON DELETE SET NULL reactionid INTEGER REFERENCES reaction_role_reactions (reactionid) ON DELETE SET NULL
); );
CREATE UNIQUE INDEX reaction_role_expiry_members ON reaction_role_expiring (guildid, userid, roleid); CREATE UNIQUE INDEX reaction_role_expiry_members ON reaction_role_expiring (guildid, userid, roleid);
-- }}}
-- Member Role Data {{{ -- Member Role Data {{{
CREATE TABLE past_member_roles( CREATE TABLE past_member_roles(
@@ -538,4 +681,5 @@ CREATE TABLE past_member_roles(
); );
CREATE INDEX member_role_persistence_members ON past_member_roles (guildid, userid); CREATE INDEX member_role_persistence_members ON past_member_roles (guildid, userid);
-- }}} -- }}}
-- vim: set fdm=marker: -- vim: set fdm=marker: