Profile schema and discord hook

This commit is contained in:
2025-08-25 23:23:41 +10:00
parent fc3bdcbb5c
commit 5fa0df66f5
8 changed files with 307 additions and 0 deletions

View File

@@ -0,0 +1 @@
from .profiles import setup

80
data/profiles.sql Normal file
View File

@@ -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;

View File

View File

@@ -0,0 +1,5 @@
import logging
logger = logging.getLogger(__name__)
from .discord import setup

81
profiles/data.py Normal file
View File

@@ -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

View File

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

89
profiles/discord/cog.py Normal file
View File

@@ -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

46
profiles/profiles.py Normal file
View File

@@ -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)