rewrite: Initial rewrite skeleton.
Remove modules that will no longer be required. Move pending modules to pending-rewrite folders.
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
from . import data # noqa
|
||||
|
||||
from .module import module
|
||||
from .lion import Lion
|
||||
from . import blacklists
|
||||
@@ -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"
|
||||
)
|
||||
128
bot/core/data.py
128
bot/core/data.py
@@ -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')
|
||||
347
bot/core/lion.py
347
bot/core/lion.py
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user