rewrite: Restructure to include GUI.
This commit is contained in:
5
src/core/__init__.py
Normal file
5
src/core/__init__.py
Normal 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
89
src/core/cog.py
Normal 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
315
src/core/data.py
Normal 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)
|
||||
7
src/core/guild_settings.py
Normal file
7
src/core/guild_settings.py
Normal 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
153
src/core/lion.py
Normal 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
|
||||
7
src/core/user_settings.py
Normal file
7
src/core/user_settings.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from settings.groups import ModelSettings, SettingDotDict
|
||||
from .data import CoreData
|
||||
|
||||
|
||||
class UserSettings(ModelSettings):
|
||||
_settings = SettingDotDict()
|
||||
model = CoreData.User
|
||||
Reference in New Issue
Block a user