From 5fa0df66f5d2a8966e89d4085abc5dfc23b46791 Mon Sep 17 00:00:00 2001 From: Interitio Date: Mon, 25 Aug 2025 23:23:41 +1000 Subject: [PATCH] Profile schema and discord hook --- __init__.py | 1 + data/profiles.sql | 80 ++++++++++++++++++++++++++++++++ data/schema.sql | 0 profiles/__init__.py | 5 ++ profiles/data.py | 81 ++++++++++++++++++++++++++++++++ profiles/discord/__init__.py | 5 ++ profiles/discord/cog.py | 89 ++++++++++++++++++++++++++++++++++++ profiles/profiles.py | 46 +++++++++++++++++++ 8 files changed, 307 insertions(+) create mode 100644 data/profiles.sql delete mode 100644 data/schema.sql create mode 100644 profiles/data.py create mode 100644 profiles/discord/cog.py create mode 100644 profiles/profiles.py diff --git a/__init__.py b/__init__.py index e69de29..08f562e 100644 --- a/__init__.py +++ b/__init__.py @@ -0,0 +1 @@ +from .profiles import setup diff --git a/data/profiles.sql b/data/profiles.sql new file mode 100644 index 0000000..62b36de --- /dev/null +++ b/data/profiles.sql @@ -0,0 +1,80 @@ +BEGIN; + +-- User and Community Profiles {{{ + +INSERT INTO version_history (component, from_version, to_version, author) VALUES ('PROFILES', 0, 1, 'Initial Creation'); + + +CREATE TABLE user_profiles( + profileid INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + nickname TEXT, + timezone TEXT, + locale_hint TEXT, + locale TEXT, + avatar TEXT, + migrated INTEGER REFERENCES user_profiles (profileid) ON DELETE CASCADE ON UPDATE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + _timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TRIGGER user_profiles_timestamp BEFORE UPDATE ON user_profiles + FOR EACH ROW EXECUTE FUNCTION update_timestamp_column(); + +CREATE TABLE profiles_discord( + linkid INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + profileid INTEGER NOT NULL REFERENCES user_profiles (profileid) ON DELETE CASCADE ON UPDATE CASCADE, + userid BIGINT NOT NULL, + linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX profiles_discord_profileid ON profiles_discord (profileid); +CREATE UNIQUE INDEX profiles_discord_userid ON profiles_discord (userid); + +CREATE TABLE profiles_twitch( + linkid INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + profileid INTEGER NOT NULL REFERENCES user_profiles (profileid) ON DELETE CASCADE ON UPDATE CASCADE, + userid TEXT NOT NULL, + linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX profiles_twitch_profileid ON profiles_twitch (profileid); +CREATE UNIQUE INDEX profiles_twitch_userid ON profiles_twitch (userid); + + +CREATE TABLE communities( + communityid INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + migrated INTEGER REFERENCES user_profiles (profileid) ON DELETE CASCADE ON UPDATE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + _timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TRIGGER communities_timestamp BEFORE UPDATE ON communities + FOR EACH ROW EXECUTE FUNCTION update_timestamp_column(); + +CREATE TABLE communities_discord( + linkid INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + guildid BIGINT NOT NULL, + communityid INTEGER NOT NULL REFERENCES communities (communityid) ON DELETE CASCADE ON UPDATE CASCADE, + linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX communities_discord_communityid ON communities_discord (communityid); +CREATE UNIQUE INDEX communities_discord_guildid ON communities_discord (guildid); + +CREATE TABLE communities_twitch( + linkid INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + channelid TEXT NOT NULL, + communityid INTEGER NOT NULL REFERENCES communities (communityid) ON DELETE CASCADE ON UPDATE CASCADE, + linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX communities_twitch_communityid ON communities_twitch (communityid); +CREATE UNIQUE INDEX communities_twitch_channelid ON communities_twitch (channelid); + +CREATE TABLE community_members( + memberid INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + communityid INTEGER NOT NULL REFERENCES communities (communityid) ON DELETE CASCADE ON UPDATE CASCADE, + profileid INTEGER NOT NULL REFERENCES user_profiles (profileid) ON DELETE CASCADE ON UPDATE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX community_members_communityid_profileid ON community_members (communityid, profileid); + +-- TODO: Consider 'networks' of multiple communities. +-- }}} +COMMIT; diff --git a/data/schema.sql b/data/schema.sql deleted file mode 100644 index e69de29..0000000 diff --git a/profiles/__init__.py b/profiles/__init__.py index e69de29..7327446 100644 --- a/profiles/__init__.py +++ b/profiles/__init__.py @@ -0,0 +1,5 @@ +import logging + +logger = logging.getLogger(__name__) + +from .discord import setup diff --git a/profiles/data.py b/profiles/data.py new file mode 100644 index 0000000..295801e --- /dev/null +++ b/profiles/data.py @@ -0,0 +1,81 @@ +from data import Registry, Table, RowModel +from data.columns import String, Integer, Timestamp + + +class UserProfile(RowModel): + _tablename_ = 'user_profiles' + _cache_ = {} + + profileid = Integer(primary=True) + nickname = String() + timezone = String() + locale_hint = String() + locale = String() + avatar = String() + migrated = Integer() + created_at = Timestamp() + last_seen = Timestamp() + _timestamp = Timestamp() + + +class DiscordProfileLink(RowModel): + _tablename_ = 'profiles_discord' + _cache_ = {} + + linkid = Integer() + profileid = Integer() + userid = Integer(primary=True) + linked_at = Timestamp() + + +class TwitchProfileLink(RowModel): + _tablename_ = 'profiles_twitch' + _cache_ = {} + + linkid = Integer() + profileid = Integer() + userid = String(primary=True) + linked_at = Timestamp() + + +class Community(RowModel): + _tablename_ = 'communities' + _cache_ = {} + + communityid = Integer(primary=True) + migrated = Integer() + created_at = Timestamp() + last_seen = Timestamp() + _timestamp = Timestamp() + + +class DiscordCommunityLink(RowModel): + _tablename_ = 'communities_discord' + _cache_ = {} + + linkid = Integer() + guildid = Integer(primary=True) + communityid = Integer() + linked_at = Timestamp() + + +class TwitchCommunityLink(RowModel): + _tablename_ = 'communities_twitch' + _cache_ = {} + + linkid = Integer() + channelid = String(primary=True) + communityid = Integer() + linked_at = Timestamp() + + +class ProfilesData(Registry): + VERSION = ('PROFILES', 1) + + user_profiles = UserProfile.table + profiles_discord = DiscordProfileLink.table + profiles_twitch = TwitchProfileLink.table + + communities = Community.table + communities_discord = DiscordCommunityLink.table + communities_twitch = TwitchCommunityLink.table diff --git a/profiles/discord/__init__.py b/profiles/discord/__init__.py index e69de29..66c4213 100644 --- a/profiles/discord/__init__.py +++ b/profiles/discord/__init__.py @@ -0,0 +1,5 @@ +from .. import logger + +async def setup(bot): + from .cog import ProfilesCog + await bot.add_cog(ProfilesCog(bot)) diff --git a/profiles/discord/cog.py b/profiles/discord/cog.py new file mode 100644 index 0000000..dd8ab7b --- /dev/null +++ b/profiles/discord/cog.py @@ -0,0 +1,89 @@ +from typing import Optional +import asyncio + +import discord +from discord.ext import commands as cmds +from discord import app_commands as appcmds + +from meta import LionBot, LionCog, LionContext +from meta.logger import log_wrap +from utils.lib import utc_now + +from ..data import ProfilesData, UserProfile, DiscordProfileLink, Community, DiscordCommunityLink +from ..profiles import ProfilesRegistry + +class ProfilesCog(LionCog): + def __init__(self, bot: LionBot): + self.bot = bot + + self.data = bot.db.load_registry(ProfilesData()) + self.profiles = ProfilesRegistry(self.data) + + async def cog_load(self): + await self.data.init() + await self.bot.version_check(*self.data.VERSION) + await self.profiles.init() + + async def bot_check_once(self, ctx: LionContext): + profile = await self.fetch_profile(ctx.author, interaction=ctx.interaction, touch=True) + ctx.profile = profile + if ctx.guild: + community = await self.fetch_community(ctx.guild, interaction=ctx.interaction, touch=True) + ctx.community = community + else: + ctx.community = None + return True + + @log_wrap(isolate=True, action="Fetch Profile") + async def fetch_profile( + self, + user: discord.User | discord.Member, + interaction: Optional[discord.Interaction] = None, + touch: bool = False, + ) -> UserProfile: + """ + Fetch or create the profile for the given user. + """ + async with self.bot.db.connection() as conn: + self.bot.db.conn = conn + async with conn.transaction(): + profile = await self.profiles.get_profile_discord(user.id) + if profile is None: + # Create a new profile + # Then link with discord + args = { + 'nickname': user.display_name, + } + if interaction: + if interaction.locale: + args['locale_hint'] = interaction.locale.language_code + if user.avatar: + args['avatar'] = user.avatar.url + profile = await UserProfile.create(**args) + await DiscordProfileLink.create(profileid=profile.profileid, userid=user.id) + elif touch: + await profile.update(last_seen=utc_now()) + + return profile + + @log_wrap(isolate=True, action="Fetch Community") + async def fetch_community( + self, + guild: discord.Guild, + interaction: Optional[discord.Interaction] = None, + touch: bool = False, + ) -> Community: + """ + Fetch or create the community for the given guild. + """ + async with self.bot.db.connection() as conn: + self.bot.db.conn = conn + async with conn.transaction(): + comm = await self.profiles.get_community_discord(guild.id) + if comm is None: + # Create a new Community and link to Discord + comm = await Community.create() + await DiscordCommunityLink.create(guildid=guild.id, communityid=comm.communityid) + elif touch: + await comm.update(last_seen=utc_now()) + return comm diff --git a/profiles/profiles.py b/profiles/profiles.py new file mode 100644 index 0000000..b1f43c0 --- /dev/null +++ b/profiles/profiles.py @@ -0,0 +1,46 @@ +from typing import Optional +from .data import ( + ProfilesData, + UserProfile, + DiscordProfileLink, + TwitchProfileLink, + Community, + DiscordCommunityLink, + TwitchCommunityLink, +) + + +class ProfilesRegistry: + VERSION = ProfilesData.VERSION + + def __init__(self, data: ProfilesData): + self.data = data + + async def init(self): + await self.data.init() + + async def get_profile(self, profileid: int) -> Optional[UserProfile]: + return await UserProfile.fetch(profileid) + + async def get_profile_discord(self, userid: int) -> Optional[UserProfile]: + link = await DiscordProfileLink.fetch(userid) + if link: + return await UserProfile.fetch(link.profileid) + + async def get_profile_twitch(self, userid: str) -> Optional[UserProfile]: + link = await TwitchProfileLink.fetch(userid) + if link: + return await UserProfile.fetch(link.profileid) + + async def get_community(self, communityid: int) -> Optional[Community]: + return await Community.fetch(communityid) + + async def get_community_discord(self, guildid: int) -> Optional[Community]: + link = await DiscordCommunityLink.fetch(guildid) + if link: + return await Community.fetch(link.communityid) + + async def get_community_twitch(self, channelid: str) -> Optional[Community]: + link = await TwitchCommunityLink.fetch(channelid) + if link: + return await Community.fetch(link.communityid)