348 lines
11 KiB
Python
348 lines
11 KiB
Python
import pytz
|
|
import discord
|
|
from functools import reduce
|
|
from datetime import datetime, timedelta
|
|
|
|
from meta import client
|
|
from data import tables as tb
|
|
from settings import UserSettings, GuildSettings
|
|
|
|
from LionContext import LionContext
|
|
|
|
|
|
class Lion:
|
|
"""
|
|
Class representing a guild Member.
|
|
Mostly acts as a transparent interface to the corresponding Row,
|
|
but also adds some transaction caching logic to `coins` and `tracked_time`.
|
|
"""
|
|
__slots__ = ('guildid', 'userid', '_pending_coins', '_member')
|
|
|
|
# Members with pending transactions
|
|
_pending = {} # userid -> User
|
|
|
|
# Lion cache. Currently lions don't expire
|
|
_lions = {} # (guildid, userid) -> Lion
|
|
|
|
# Extra methods supplying an economy bonus
|
|
_economy_bonuses = []
|
|
|
|
def __init__(self, guildid, userid):
|
|
self.guildid = guildid
|
|
self.userid = userid
|
|
|
|
self._pending_coins = 0
|
|
|
|
self._member = None
|
|
|
|
self._lions[self.key] = self
|
|
|
|
@classmethod
|
|
def fetch(cls, guildid, userid):
|
|
"""
|
|
Fetch a Lion with the given member.
|
|
If they don't exist, creates them.
|
|
If possible, retrieves the user from the user cache.
|
|
"""
|
|
key = (guildid, userid)
|
|
if key in cls._lions:
|
|
return cls._lions[key]
|
|
else:
|
|
# TODO: Debug log
|
|
lion = tb.lions.fetch(key)
|
|
if not lion:
|
|
tb.user_config.fetch_or_create(userid)
|
|
tb.guild_config.fetch_or_create(guildid)
|
|
tb.lions.create_row(
|
|
guildid=guildid,
|
|
userid=userid,
|
|
coins=GuildSettings(guildid).starting_funds.value
|
|
)
|
|
return cls(guildid, userid)
|
|
|
|
@property
|
|
def key(self):
|
|
return (self.guildid, self.userid)
|
|
|
|
@property
|
|
def guild(self) -> discord.Guild:
|
|
return client.get_guild(self.guildid)
|
|
|
|
@property
|
|
def member(self) -> discord.Member:
|
|
"""
|
|
The discord `Member` corresponding to this user.
|
|
May be `None` if the member is no longer in the guild or the caches aren't populated.
|
|
Not guaranteed to be `None` if the member is not in the guild.
|
|
"""
|
|
if self._member is None:
|
|
guild = client.get_guild(self.guildid)
|
|
if guild:
|
|
self._member = guild.get_member(self.userid)
|
|
return self._member
|
|
|
|
@property
|
|
def data(self):
|
|
"""
|
|
The Row corresponding to this member.
|
|
"""
|
|
return tb.lions.fetch(self.key)
|
|
|
|
@property
|
|
def user_data(self):
|
|
"""
|
|
The Row corresponding to this user.
|
|
"""
|
|
return tb.user_config.fetch_or_create(self.userid)
|
|
|
|
@property
|
|
def guild_data(self):
|
|
"""
|
|
The Row corresponding to this guild.
|
|
"""
|
|
return tb.guild_config.fetch_or_create(self.guildid)
|
|
|
|
@property
|
|
def settings(self):
|
|
"""
|
|
The UserSettings interface for this member.
|
|
"""
|
|
return UserSettings(self.userid)
|
|
|
|
@property
|
|
def guild_settings(self):
|
|
"""
|
|
The GuildSettings interface for this member.
|
|
"""
|
|
return GuildSettings(self.guildid)
|
|
|
|
@property
|
|
def ctx(self) -> LionContext:
|
|
"""
|
|
Manufacture a `LionContext` with the lion member as an author.
|
|
Useful for accessing member context utilities.
|
|
Be aware that `author` may be `None` if the member was not cached.
|
|
"""
|
|
return LionContext(client, guild=self.guild, author=self.member)
|
|
|
|
@property
|
|
def time(self):
|
|
"""
|
|
Amount of time the user has spent studying, accounting for a current session.
|
|
"""
|
|
# 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
|
|
def coins(self):
|
|
"""
|
|
Number of coins the user has, accounting for the pending value and current session.
|
|
"""
|
|
# 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 economy_bonus(self):
|
|
"""
|
|
Economy multiplier
|
|
"""
|
|
return reduce(
|
|
lambda x, y: x * y,
|
|
[func(self) for func in self._economy_bonuses]
|
|
)
|
|
|
|
@classmethod
|
|
def register_economy_bonus(cls, func):
|
|
cls._economy_bonuses.append(func)
|
|
|
|
@classmethod
|
|
def unregister_economy_bonus(cls, func):
|
|
cls._economy_bonuses.remove(func)
|
|
|
|
@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 day_timestamp(self):
|
|
"""
|
|
EPOCH timestamp representing the current day for the user.
|
|
NOTE: This is the timestamp of the start of the current UTC day with the same date as the user's day.
|
|
This is *not* the start of the current user's day, either in UTC or their own timezone.
|
|
This may also not be the start of the current day in UTC (consider 23:00 for a user in UTC-2).
|
|
"""
|
|
now = datetime.now(tz=self.timezone)
|
|
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
return int(day_start.replace(tzinfo=pytz.utc).timestamp())
|
|
|
|
@property
|
|
def week_timestamp(self):
|
|
"""
|
|
EPOCH timestamp representing the current week for the user.
|
|
"""
|
|
now = datetime.now(tz=self.timezone)
|
|
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
week_start = day_start - timedelta(days=day_start.weekday())
|
|
return int(week_start.replace(tzinfo=pytz.utc).timestamp())
|
|
|
|
@property
|
|
def month_timestamp(self):
|
|
"""
|
|
EPOCH timestamp representing the current month for the user.
|
|
"""
|
|
now = datetime.now(tz=self.timezone)
|
|
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
return int(month_start.replace(tzinfo=pytz.utc).timestamp())
|
|
|
|
@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
|
|
|
|
@property
|
|
def profile_tags(self):
|
|
"""
|
|
Returns a list of profile tags, or the default tags.
|
|
"""
|
|
tags = tb.profile_tags.queries.get_tags_for(self.guildid, self.userid)
|
|
prefix = self.ctx.best_prefix
|
|
return tags or [
|
|
f"Use {prefix}setprofile",
|
|
"and add your tags",
|
|
"to this section",
|
|
f"See {prefix}help setprofile for more"
|
|
]
|
|
|
|
@property
|
|
def name(self):
|
|
"""
|
|
Returns the best local name possible.
|
|
"""
|
|
if self.member:
|
|
name = self.member.display_name
|
|
elif self.data.display_name:
|
|
name = self.data.display_name
|
|
else:
|
|
name = str(self.userid)
|
|
|
|
return name
|
|
|
|
def update_saved_data(self, member: discord.Member):
|
|
"""
|
|
Update the stored discord data from the givem member.
|
|
Intended to be used when we get member data from events that may not be available in cache.
|
|
"""
|
|
if self.guild_data.name != member.guild.name:
|
|
self.guild_data.name = member.guild.name
|
|
if self.user_data.avatar_hash != member.avatar:
|
|
self.user_data.avatar_hash = member.avatar
|
|
if self.data.display_name != member.display_name:
|
|
self.data.display_name = member.display_name
|
|
|
|
def localize(self, naive_utc_dt):
|
|
"""
|
|
Localise the provided naive UTC datetime into the user's timezone.
|
|
"""
|
|
timezone = self.settings.timezone.value
|
|
return naive_utc_dt.replace(tzinfo=pytz.UTC).astimezone(timezone)
|
|
|
|
def addCoins(self, amount, flush=True, bonus=False):
|
|
"""
|
|
Add coins to the user, optionally store the transaction in pending.
|
|
"""
|
|
self._pending_coins += amount * (self.economy_bonus if bonus else 1)
|
|
self._pending[self.key] = self
|
|
if flush:
|
|
self.flush()
|
|
|
|
def flush(self):
|
|
"""
|
|
Flush any pending transactions to the database.
|
|
"""
|
|
self.sync(self)
|
|
|
|
@classmethod
|
|
def sync(cls, *lions):
|
|
"""
|
|
Flush pending transactions to the database.
|
|
Also refreshes the Row cache for updated lions.
|
|
"""
|
|
lions = lions or list(cls._pending.values())
|
|
|
|
if lions:
|
|
# Build userid to pending coin map
|
|
pending = [
|
|
(lion.guildid, lion.userid, int(lion._pending_coins))
|
|
for lion in lions
|
|
]
|
|
|
|
# Write to database
|
|
tb.lions.queries.add_pending(pending)
|
|
|
|
# Cleanup pending users
|
|
for lion in lions:
|
|
lion._pending_coins -= int(lion._pending_coins)
|
|
cls._pending.pop(lion.key, None)
|