rewrite: Restructure to include GUI.

This commit is contained in:
2022-12-23 06:44:32 +02:00
parent 2b93354248
commit f328324747
224 changed files with 8 additions and 0 deletions

5
src/core/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from .cog import CoreCog
async def setup(bot):
await bot.add_cog(CoreCog(bot))

89
src/core/cog.py Normal file
View File

@@ -0,0 +1,89 @@
from typing import Optional
import discord
from meta import LionBot, LionCog, LionContext
from meta.app import shardname, appname
from meta.logger import log_wrap
from utils.lib import utc_now
from settings.groups import SettingGroup
from .data import CoreData
from .lion import Lions
from .guild_settings import GuildSettings
from .user_settings import UserSettings
class CoreCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.data = CoreData()
bot.db.load_registry(self.data)
self.lions = Lions(bot)
self.app_config: Optional[CoreData.AppConfig] = None
self.bot_config: Optional[CoreData.BotConfig] = None
self.shard_data: Optional[CoreData.Shard] = None
# Some global setting registries
# Do not use these for direct setting access
# Instead, import the setting directly or use the cog API
self.bot_setting_groups: list[SettingGroup] = []
self.guild_setting_groups: list[SettingGroup] = []
self.user_setting_groups: list[SettingGroup] = []
# Some ModelSetting registries
# These are for more convenient direct access
self.guild_settings = GuildSettings
self.user_settings = UserSettings
self.app_cmd_cache: list[discord.app_commands.AppCommand] = []
self.cmd_name_cache: dict[str, discord.app_commands.AppCommand] = {}
async def bot_check_once(self, ctx: LionContext): # type: ignore
lion = await self.lions.fetch(ctx.guild.id if ctx.guild else 0, ctx.author.id)
if ctx.guild:
await lion.touch_discord_models(ctx.author) # type: ignore # Type checker doesn't recognise guard
ctx.alion = lion
return True
async def cog_load(self):
# Fetch (and possibly create) core data rows.
conn = await self.bot.db.get_connection()
async with conn.transaction():
self.app_config = await self.data.AppConfig.fetch_or_create(appname)
self.bot_config = await self.data.BotConfig.fetch_or_create(appname)
self.shard_data = await self.data.Shard.fetch_or_create(
shardname,
appname=appname,
shard_id=self.bot.shard_id,
shard_count=self.bot.shard_count
)
self.bot.add_listener(self.shard_update_guilds, name='on_guild_join')
self.bot.add_listener(self.shard_update_guilds, name='on_guild_remove')
self.bot.core = self
await self.bot.add_cog(self.lions)
# Load the app command cache
for guildid in self.bot.testing_guilds:
self.app_cmd_cache += await self.bot.tree.fetch_commands(guild=discord.Object(guildid))
self.app_cmd_cache += await self.bot.tree.fetch_commands()
self.cmd_name_cache = {cmd.name: cmd for cmd in self.app_cmd_cache}
async def cog_unload(self):
await self.bot.remove_cog(self.lions.qualified_name)
self.bot.remove_listener(self.shard_update_guilds, name='on_guild_join')
self.bot.remove_listener(self.shard_update_guilds, name='on_guild_leave')
self.bot.core = None
@LionCog.listener('on_ready')
@log_wrap(action='Touch shard data')
async def touch_shard_data(self):
# Update the last login and guild count for this shard
await self.shard_data.update(last_login=utc_now(), guild_count=len(self.bot.guilds))
@log_wrap(action='Update shard guilds')
async def shard_update_guilds(self, guild):
await self.shard_data.update(guild_count=len(self.bot.guilds))

315
src/core/data.py Normal file
View File

@@ -0,0 +1,315 @@
from itertools import chain
from psycopg import sql
from cachetools import TTLCache
from data import Table, Registry, Column, RowModel
from data.models import WeakCache
from data.columns import Integer, String, Bool, Timestamp
class CoreData(Registry, name="core"):
class AppConfig(RowModel):
"""
Schema
------
CREATE TABLE app_config(
appname TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
"""
_tablename_ = 'app_config'
appname = String(primary=True)
created_at = Timestamp()
class BotConfig(RowModel):
"""
Schema
------
CREATE TABLE bot_config(
appname TEXT PRIMARY KEY REFERENCES app_config(appname) ON DELETE CASCADE,
default_skin TEXT
);
"""
_tablename_ = 'bot_config'
appname = String(primary=True)
default_skin = String()
class Shard(RowModel):
"""
Schema
------
CREATE TABLE shard_data(
shardname TEXT PRIMARY KEY,
appname TEXT REFERENCES bot_config(appname) ON DELETE CASCADE,
shard_id INTEGER NOT NULL,
shard_count INTEGER NOT NULL,
last_login TIMESTAMPTZ,
guild_count INTEGER
);
"""
_tablename_ = 'shard_data'
shardname = String(primary=True)
appname = String()
shard_id = Integer()
shard_count = Integer()
last_login = Timestamp()
guild_count = Integer()
class User(RowModel):
"""
User model, representing configuration data for a single user.
Schema
------
CREATE TABLE user_config(
userid BIGINT PRIMARY KEY,
timezone TEXT,
topgg_vote_reminder BOOLEAN,
avatar_hash TEXT,
name TEXT,
API_timestamp BIGINT,
gems INTEGER DEFAULT 0,
first_seen TIMESTAMPTZ DEFAULT now(),
last_seen TIMESTAMPTZ,
locale TEXT,
locale_hint TEXT
);
"""
_tablename_ = "user_config"
_cache_: WeakCache[tuple[int], 'CoreData.User'] = WeakCache(TTLCache(1000, ttl=60*5))
userid = Integer(primary=True)
timezone = String()
topgg_vote_reminder = Bool()
avatar_hash = String()
name = String()
API_timestamp = Integer()
gems = Integer()
first_seen = Timestamp()
last_seen = Timestamp()
locale = String()
locale_hint = String()
class Guild(RowModel):
"""
Guild model, representing configuration data for a single guild.
Schema
------
CREATE TABLE guild_config(
guildid BIGINT PRIMARY KEY,
admin_role BIGINT,
mod_role BIGINT,
event_log_channel BIGINT,
mod_log_channel BIGINT,
alert_channel BIGINT,
studyban_role BIGINT,
min_workout_length INTEGER,
workout_reward INTEGER,
max_tasks INTEGER,
task_reward INTEGER,
task_reward_limit INTEGER,
study_hourly_reward INTEGER,
study_hourly_live_bonus INTEGER,
renting_price INTEGER,
renting_category BIGINT,
renting_cap INTEGER,
renting_role BIGINT,
renting_sync_perms BOOLEAN,
accountability_category BIGINT,
accountability_lobby BIGINT,
accountability_bonus INTEGER,
accountability_reward INTEGER,
accountability_price INTEGER,
video_studyban BOOLEAN,
video_grace_period INTEGER,
greeting_channel BIGINT,
greeting_message TEXT,
returning_message TEXT,
starting_funds INTEGER,
persist_roles BOOLEAN,
daily_study_cap INTEGER,
pomodoro_channel BIGINT,
name TEXT,
first_joined_at TIMESTAMPTZ DEFAULT now(),
left_at TIMESTAMPTZ,
locale TEXT,
force_locale BOOLEAN
);
"""
_tablename_ = "guild_config"
_cache_: WeakCache[tuple[int], 'CoreData.Guild'] = WeakCache(TTLCache(1000, ttl=60*5))
guildid = Integer(primary=True)
admin_role = Integer()
mod_role = Integer()
event_log_channel = Integer()
mod_log_channel = Integer()
alert_channel = Integer()
studyban_role = Integer()
max_study_bans = Integer()
min_workout_length = Integer()
workout_reward = Integer()
max_tasks = Integer()
task_reward = Integer()
task_reward_limit = Integer()
study_hourly_reward = Integer()
study_hourly_live_bonus = Integer()
daily_study_cap = Integer()
renting_price = Integer()
renting_category = Integer()
renting_cap = Integer()
renting_role = Integer()
renting_sync_perms = Bool()
accountability_category = Integer()
accountability_lobby = Integer()
accountability_bonus = Integer()
accountability_reward = Integer()
accountability_price = Integer()
video_studyban = Bool()
video_grace_period = Integer()
greeting_channel = Integer()
greeting_message = String()
returning_message = String()
starting_funds = Integer()
persist_roles = Bool()
pomodoro_channel = Integer()
name = String()
first_joined_at = Timestamp()
left_at = Timestamp()
locale = String()
force_locale = Bool()
unranked_rows = Table('unranked_rows')
donator_roles = Table('donator_roles')
member_ranks = Table('member_ranks')
class Member(RowModel):
"""
Member model, representing configuration data for a single member.
Schema
------
CREATE TABLE members(
guildid BIGINT,
userid BIGINT,
tracked_time INTEGER DEFAULT 0,
coins INTEGER DEFAULT 0,
workout_count INTEGER DEFAULT 0,
revision_mute_count INTEGER DEFAULT 0,
last_workout_start TIMESTAMP,
last_study_badgeid INTEGER REFERENCES study_badges ON DELETE SET NULL,
video_warned BOOLEAN DEFAULT FALSE,
display_name TEXT,
first_joined TIMESTAMPTZ DEFAULT now(),
last_left TIMESTAMPTZ,
_timestamp TIMESTAMP DEFAULT (now() at time zone 'utc'),
PRIMARY KEY(guildid, userid)
);
CREATE INDEX member_timestamps ON members (_timestamp);
"""
_tablename_ = 'members'
_cache_: WeakCache[tuple[int, int], 'CoreData.Member'] = WeakCache(TTLCache(5000, ttl=60*5))
guildid = Integer(primary=True)
userid = Integer(primary=True)
tracked_time = Integer()
coins = Integer()
workout_count = Integer()
revision_mute_count = Integer()
last_workout_start = Timestamp()
last_study_badgeid = Integer()
video_warned = Bool()
display_name = String()
first_joined = Timestamp()
last_left = Timestamp()
_timestamp = Timestamp()
@classmethod
async def add_pending(cls, pending: list[tuple[int, int, int]]) -> list['CoreData.Member']:
"""
Safely add pending coins to a list of members.
Arguments
---------
pending:
List of tuples of the form `(guildid, userid, pending_coins)`.
"""
query = sql.SQL("""
UPDATE members
SET
coins = LEAST(coins + t.coin_diff, 2147483647)
FROM
(VALUES {})
AS
t (guildid, userid, coin_diff)
WHERE
members.guildid = t.guildid
AND
members.userid = t.userid
RETURNING *
""").format(
sql.SQL(', ').join(
sql.SQL("({}, {}, {})").format(sql.Placeholder(), sql.Placeholder(), sql.Placeholder())
for _ in pending
)
)
# TODO: Replace with copy syntax/query?
conn = await cls.table.connector.get_connection()
async with conn.cursor() as cursor:
await cursor.execute(
query,
tuple(chain(*pending))
)
rows = await cursor.fetchall()
return cls._make_rows(*rows)
@classmethod
async def get_member_rank(cls, guildid, userid, untracked):
"""
Get the time and coin ranking for the given member, ignoring the provided untracked members.
"""
conn = await cls.table.connector.get_connection()
async with conn.cursor() as curs:
await 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 (await curs.fetchone()) or (None, None)

View File

@@ -0,0 +1,7 @@
from settings.groups import ModelSettings, SettingDotDict
from .data import CoreData
class GuildSettings(ModelSettings):
_settings = SettingDotDict()
model = CoreData.Guild

153
src/core/lion.py Normal file
View File

@@ -0,0 +1,153 @@
from typing import Optional
from cachetools import LRUCache
import discord
from meta import LionCog, LionBot, LionContext
from settings import InteractiveSetting
from utils.lib import utc_now
from data import WeakCache
from .data import CoreData
from .user_settings import UserSettings
from .guild_settings import GuildSettings
class Lion:
"""
A Lion is a high level representation of a Member in the LionBot paradigm.
All members interacted with by the application should be available as Lions.
It primarily provides an interface to the User and Member data.
Lion also provides centralised access to various Member properties and methods,
that would normally be served by other cogs.
Many Lion methods may only be used when the required cogs and extensions are loaded.
A Lion may exist without a Bot instance or a Member in cache,
although the functionality available will be more limited.
There is no guarantee that a corresponding discord Member actually exists.
"""
__slots__ = ('bot', 'data', 'user_data', 'guild_data', '_member', '__weakref__')
def __init__(self, bot: LionBot, data: CoreData.Member, user_data: CoreData.User, guild_data: CoreData.Guild):
self.bot = bot
self.data = data
self.user_data = user_data
self.guild_data = guild_data
self._member: Optional[discord.Member] = None
# Data properties
@property
def key(self):
return (self.data.guildid, self.data.userid)
@property
def guildid(self):
return self.data.guildid
@property
def userid(self):
return self.data.userid
@classmethod
def get(cls, guildid, userid):
return cls._cache_.get((guildid, userid), None)
# ModelSettings interfaces
@property
def guild_settings(self):
return GuildSettings(self.guildid, self.guild_data, bot=self.bot)
@property
def user_settings(self):
return UserSettings(self.userid, self.user_data, bot=self.bot)
# Setting interfaces
# Each of these return an initialised member setting
@property
def timezone(self):
pass
@property
def locale(self):
pass
# Time utilities
@property
def now(self):
"""
Returns current time-zone aware time for the member.
"""
pass
# Discord data cache
async def touch_discord_models(self, member: discord.Member):
"""
Update the stored discord data from the given user or member object.
Intended to be used when we get member data from events that may not be available in cache.
"""
# Can we do these in one query?
if member.guild and (self.guild_data.name != member.guild.name):
await self.guild_data.update(name=member.guild.name)
avatar_key = member.avatar.key if member.avatar else None
await self.user_data.update(avatar_hash=avatar_key, name=member.name, last_seen=utc_now())
if member.display_name != self.data.display_name:
await self.data.update(display_name=member.display_name)
async def get_member(self) -> Optional[discord.Member]:
"""
Retrieve the member object for this Lion, if possible.
If the guild or member cannot be retrieved, returns None.
"""
guild = self.bot.get_guild(self.guildid)
if guild is not None:
member = guild.get_member(self.userid)
if member is None:
try:
member = await guild.fetch_member(self.userid)
except discord.HTTPException:
pass
return member
class Lions(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
# Full Lions cache
# Don't expire Lions with strong references
self._cache_: WeakCache[tuple[int, int], 'Lion'] = WeakCache(LRUCache(5000))
self._settings_: dict[str, InteractiveSetting] = {}
async def fetch(self, guildid, userid) -> Lion:
"""
Fetch or create the given Member.
If the guild or user row doesn't exist, also creates it.
Relies on the core cog existing, to retrieve the core data.
"""
# TODO: Find a way to reduce this to one query, while preserving cache
lion = self._cache_.get((guildid, userid))
if lion is None:
if self.bot.core:
data = self.bot.core.data
else:
raise ValueError("Cannot fetch Lion before core module is attached.")
guild = await data.Guild.fetch_or_create(guildid)
user = await data.User.fetch_or_create(userid)
member = await data.Member.fetch_or_create(guildid, userid)
lion = Lion(self.bot, member, user, guild)
self._cache_[(guildid, userid)] = lion
return lion
def add_model_setting(self, setting: InteractiveSetting):
self._settings_[setting.__class__.__name__] = setting
return setting

View File

@@ -0,0 +1,7 @@
from settings.groups import ModelSettings, SettingDotDict
from .data import CoreData
class UserSettings(ModelSettings):
_settings = SettingDotDict()
model = CoreData.User