rewrite: Initial rewrite skeleton.

Remove modules that will no longer be required.
Move pending modules to pending-rewrite folders.
This commit is contained in:
2022-09-17 17:06:13 +10:00
parent a7f7dd6e7b
commit a5147323b5
162 changed files with 1 additions and 866 deletions

View File

@@ -1,5 +0,0 @@
from . import data # noqa
from .module import module
from .lion import Lion
from . import blacklists

View File

@@ -1,92 +0,0 @@
"""
Guild, user, and member blacklists.
"""
from collections import defaultdict
import cachetools.func
from data import tables
from meta import client
from .module import module
@cachetools.func.ttl_cache(ttl=300)
def guild_blacklist():
"""
Get the guild blacklist
"""
rows = tables.global_guild_blacklist.select_where()
return set(row['guildid'] for row in rows)
@cachetools.func.ttl_cache(ttl=300)
def user_blacklist():
"""
Get the global user blacklist.
"""
rows = tables.global_user_blacklist.select_where()
return set(row['userid'] for row in rows)
@module.init_task
def load_ignored_members(client):
"""
Load the ignored members.
"""
ignored = defaultdict(set)
rows = tables.ignored_members.select_where()
for row in rows:
ignored[row['guildid']].add(row['userid'])
client.objects['ignored_members'] = ignored
if rows:
client.log(
"Loaded {} ignored members across {} guilds.".format(
len(rows),
len(ignored)
),
context="MEMBER_BLACKLIST"
)
@module.init_task
def attach_client_blacklists(client):
client.guild_blacklist = guild_blacklist
client.user_blacklist = user_blacklist
@module.launch_task
async def leave_blacklisted_guilds(client):
"""
Launch task to leave any blacklisted guilds we are in.
"""
to_leave = [
guild for guild in client.guilds
if guild.id in guild_blacklist()
]
for guild in to_leave:
await guild.leave()
if to_leave:
client.log(
"Left {} blacklisted guilds!".format(len(to_leave)),
context="GUILD_BLACKLIST"
)
@client.add_after_event('guild_join')
async def check_guild_blacklist(client, guild):
"""
Guild join event handler to check whether the guild is blacklisted.
If so, leaves the guild.
"""
# First refresh the blacklist cache
if guild.id in guild_blacklist():
await guild.leave()
client.log(
"Automatically left blacklisted guild '{}' (gid:{}) upon join.".format(guild.name, guild.id),
context="GUILD_BLACKLIST"
)

View File

@@ -1,128 +0,0 @@
from psycopg2.extras import execute_values
from cachetools import TTLCache
from data import RowTable, Table
meta = RowTable(
'AppData',
('appid', 'last_study_badge_scan'),
'appid',
attach_as='meta',
)
# TODO: Consider converting to RowTable for per-shard config caching
app_config = Table('AppConfig')
user_config = RowTable(
'user_config',
('userid', 'timezone', 'topgg_vote_reminder', 'avatar_hash', 'gems'),
'userid',
cache=TTLCache(5000, ttl=60*5)
)
guild_config = RowTable(
'guild_config',
('guildid', 'admin_role', 'mod_role', 'event_log_channel', 'mod_log_channel', 'alert_channel',
'studyban_role', 'max_study_bans',
'min_workout_length', 'workout_reward',
'max_tasks', 'task_reward', 'task_reward_limit',
'study_hourly_reward', 'study_hourly_live_bonus', 'daily_study_cap',
'renting_price', 'renting_category', 'renting_cap', 'renting_role', 'renting_sync_perms',
'accountability_category', 'accountability_lobby', 'accountability_bonus',
'accountability_reward', 'accountability_price',
'video_studyban', 'video_grace_period',
'greeting_channel', 'greeting_message', 'returning_message',
'starting_funds', 'persist_roles',
'pomodoro_channel',
'name'),
'guildid',
cache=TTLCache(2500, ttl=60*5)
)
unranked_roles = Table('unranked_roles')
donator_roles = Table('donator_roles')
lions = RowTable(
'members',
('guildid', 'userid',
'tracked_time', 'coins',
'workout_count', 'last_workout_start',
'revision_mute_count',
'last_study_badgeid',
'video_warned',
'display_name',
'_timestamp'
),
('guildid', 'userid'),
cache=TTLCache(5000, ttl=60*5),
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 = LEAST(coins + t.coin_diff, 2147483647)
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')
@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_user_blacklist = Table('global_user_blacklist')
ignored_members = Table('ignored_members')

View File

@@ -1,347 +0,0 @@
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)

View File

@@ -1,80 +0,0 @@
import logging
import asyncio
from meta import client, conf
from settings import GuildSettings, UserSettings
from LionModule import LionModule
from .lion import Lion
module = LionModule("Core")
async def _lion_sync_loop():
while True:
while not client.is_ready():
await asyncio.sleep(1)
client.log(
"Running lion data sync.",
context="CORE",
level=logging.DEBUG,
post=False
)
Lion.sync()
await asyncio.sleep(conf.bot.getint("lion_sync_period"))
@module.init_task
def setting_initialisation(client):
"""
Execute all Setting initialisation tasks from GuildSettings and UserSettings.
"""
for setting in GuildSettings.settings.values():
setting.init_task(client)
for setting in UserSettings.settings.values():
setting.init_task(client)
@module.launch_task
async def preload_guild_configuration(client):
"""
Loads the plain guild configuration for all guilds the client is part of into data.
"""
guildids = [guild.id for guild in client.guilds]
if guildids:
rows = client.data.guild_config.fetch_rows_where(guildid=guildids)
client.log(
"Preloaded guild configuration for {} guilds.".format(len(rows)),
context="CORE_LOADING"
)
@module.launch_task
async def preload_studying_members(client):
"""
Loads the member data for all members who are currently in voice channels.
"""
userids = list(set(member.id for guild in client.guilds for ch in guild.voice_channels for member in ch.members))
if userids:
users = client.data.user_config.fetch_rows_where(userid=userids)
members = client.data.lions.fetch_rows_where(userid=userids)
client.log(
"Preloaded data for {} user with {} members.".format(len(users), len(members)),
context="CORE_LOADING"
)
# Removing the sync loop in favour of the studybadge sync.
# @module.launch_task
# async def launch_lion_sync_loop(client):
# asyncio.create_task(_lion_sync_loop())
@module.unload_task
async def final_lion_sync(client):
Lion.sync()