Merge branch 'feature-session-tracker' into staging
This commit is contained in:
@@ -1,2 +1,2 @@
|
|||||||
CONFIG_FILE = "config/bot.conf"
|
CONFIG_FILE = "config/bot.conf"
|
||||||
DATA_VERSION = 5
|
DATA_VERSION = 6
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
107
bot/core/lion.py
107
bot/core/lion.py
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
2
bot/modules/study/badges/__init__.py
Normal file
2
bot/modules/study/badges/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from . import badge_tracker
|
||||||
|
from . import studybadge_cmd
|
||||||
@@ -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())
|
||||||
@@ -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'),
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
embed.add_field(
|
if time_rank is None:
|
||||||
name="🦁 LionCoins",
|
rank_str = None
|
||||||
value=coins
|
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 "."
|
||||||
)
|
)
|
||||||
embed.add_field(
|
else:
|
||||||
name="🏆 Leaderboard Position",
|
embed.description = "{} hasn't studied in this server yet!".format(target.mention)
|
||||||
value="Time: {}\n LC: {}".format(time_lb_pos, coin_lb_pos)
|
|
||||||
|
# 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="💪 Workouts",
|
stats['Workouts'] = "**{}** sessions".format(workout_total)
|
||||||
value=workouts
|
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 ''
|
||||||
|
)
|
||||||
|
|
||||||
|
stats_table = prop_tabulate(*zip(*stats.items()))
|
||||||
|
|
||||||
|
# Build the time table
|
||||||
|
time_table = prop_tabulate(
|
||||||
|
('Daily', 'Weekly', 'Monthly', 'All Time'),
|
||||||
|
["{:02}:{:02}".format(t // 3600, (t // 60) % 60) for t in reversed(study_times)]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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(
|
embed.add_field(
|
||||||
name="📋 Attendence",
|
name="Study League",
|
||||||
value="TBD"
|
value="{}\n{}".format(current_str, next_str),
|
||||||
|
inline=False
|
||||||
)
|
)
|
||||||
await ctx.reply(embed=embed)
|
await ctx.reply(embed=embed)
|
||||||
|
|||||||
0
bot/modules/study/timers/__init__.py
Normal file
0
bot/modules/study/timers/__init__.py
Normal 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:
|
||||||
|
|||||||
3
bot/modules/study/tracking/__init__.py
Normal file
3
bot/modules/study/tracking/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from . import data
|
||||||
|
from . import settings
|
||||||
|
from . import session_tracker
|
||||||
62
bot/modules/study/tracking/data.py
Normal file
62
bot/modules/study/tracking/data.py
Normal 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')
|
||||||
499
bot/modules/study/tracking/session_tracker.py
Normal file
499
bot/modules/study/tracking/session_tracker.py
Normal 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))
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
|
||||||
180
data/migration/v5-v6/migration.sql
Normal file
180
data/migration/v5-v6/migration.sql
Normal 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');
|
||||||
160
data/schema.sql
160
data/schema.sql
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user