feat(profiles): Add community profiles.

This commit is contained in:
2024-10-06 21:38:09 +10:00
parent 92fee23afa
commit 72d52b6014
6 changed files with 326 additions and 18 deletions

View File

@@ -1535,6 +1535,7 @@ CREATE UNIQUE INDEX profiles_twitch_userid ON profiles_twitch (userid);
CREATE TABLE communities( CREATE TABLE communities(
communityid SERIAL PRIMARY KEY, communityid SERIAL PRIMARY KEY,
migrated INTEGER REFERENCES user_profiles (profileid) ON DELETE CASCADE ON UPDATE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );

View File

@@ -4,7 +4,9 @@ from typing import Optional, overload
from datetime import timedelta from datetime import timedelta
import discord import discord
from discord import app_commands as appcmds
from discord.ext import commands as cmds from discord.ext import commands as cmds
from twitchAPI.type import AuthScope
import twitchio import twitchio
from twitchio.ext import commands from twitchio.ext import commands
from twitchio import User from twitchio import User
@@ -18,6 +20,7 @@ from utils.lib import utc_now
from . import logger from . import logger
from .data import ProfileData from .data import ProfileData
from .profile import UserProfile from .profile import UserProfile
from .community import Community
class ProfileCog(LionCog): class ProfileCog(LionCog):
@@ -89,7 +92,7 @@ class ProfileCog(LionCog):
results.append(f"Migrated {len(twitch_rows)} attached twitch account(s).") results.append(f"Migrated {len(twitch_rows)} attached twitch account(s).")
# And then mark the old profile as migrated # And then mark the old profile as migrated
await source_profile.update(migrate=target_profile.profileid) await source_profile.update(migrated=target_profile.profileid)
results.append("Marking old profile as migrated.. finished!") results.append("Marking old profile as migrated.. finished!")
return results return results
@@ -97,37 +100,107 @@ class ProfileCog(LionCog):
""" """
Fetch a UserProfile by the given id. Fetch a UserProfile by the given id.
""" """
return await UserProfile.fetch_profile(self.bot, profile_id=profile_id) return await UserProfile.fetch(self.bot, profile_id=profile_id)
async def fetch_profile_discord(self, user: discord.Member | discord.User) -> UserProfile: async def fetch_profile_discord(self, user: discord.Member | discord.User) -> UserProfile:
""" """
Fetch or create a UserProfile from the provided discord account. Fetch or create a UserProfile from the provided discord account.
""" """
profile = await UserProfile.fetch_from_discordid(user.id) profile = await UserProfile.fetch_from_discordid(self.bot, user.id)
if profile is None: if profile is None:
profile = await UserProfile.create_from_discord(user) profile = await UserProfile.create_from_discord(self.bot, user)
return profile return profile
async def fetch_profile_twitch(self, user: twitchio.User) -> UserProfile: async def fetch_profile_twitch(self, user: twitchio.User) -> UserProfile:
""" """
Fetch or create a UserProfile from the provided twitch account. Fetch or create a UserProfile from the provided twitch account.
""" """
profile = await UserProfile.fetch_from_twitchid(user.id) profile = await UserProfile.fetch_from_twitchid(self.bot, user.id)
if profile is None: if profile is None:
profile = await UserProfile.create_from_twitch(user) profile = await UserProfile.create_from_twitch(self.bot, user)
return profile return profile
async def fetch_community_discord(self, guildid: int, create=True): # Community API
... def add_community_migrator(self, migrator, name=None):
name = name or migrator.__name__
self._comm_migrators[name or migrator.__name__] = migrator
async def fetch_community_twitch(self, guildid: int, create=True): logger.info(
... f"Added community migrator {name}: {migrator}"
)
return migrator
async def fetch_community(self, communityid: int): def del_community_migrator(self, name: str):
... migrator = self._comm_migrators.pop(name, None)
logger.info(
f"Removed community migrator {name}: {migrator}"
)
@log_wrap(action="community migration")
async def migrate_community(self, source_comm, target_comm) -> list[str]:
logger.info(
f"Beginning community migration from {source_comm!r} to {target_comm!r}"
)
results = []
# Wrap this in a transaction so if something goes wrong with migration,
# we roll back safely (although this may mess up caches)
async with self.bot.db.connection() as conn:
self.bot.db.conn = conn
async with conn.transaction():
for name, migrator in self._comm_migrators.items():
try:
result = await migrator(source_comm, target_comm)
if result:
results.append(result)
except Exception:
logger.exception(
f"Unexpected exception running community migrator {name} "
f"migrating {source_comm!r} to {target_comm!r}."
)
raise
# Move all Discord and Twitch community preferences over to the new profile
discord_rows = await self.data.DiscordCommunityRow.table.update_where(
profileid=source_comm.communityid
).set(communityid=target_comm.communityid)
results.append(f"Migrated {len(discord_rows)} attached discord guilds.")
twitch_rows = await self.data.TwitchCommunityRow.table.update_where(
communityid=source_comm.communityid
).set(communityid=target_comm.communityid)
results.append(f"Migrated {len(twitch_rows)} attached twitch channel(s).")
# And then mark the old community as migrated
await source_comm.update(migrated=target_comm.communityid)
results.append("Marking old community as migrated.. finished!")
return results
async def fetch_community_by_id(self, community_id: int) -> Community:
"""
Fetch a Community by the given id.
"""
return await Community.fetch(self.bot, community_id=community_id)
async def fetch_community_discord(self, guild: discord.Guild) -> Community:
"""
Fetch or create a Community from the provided discord guild.
"""
comm = await Community.fetch_from_discordid(self.bot, guild.id)
if comm is None:
comm = await Community.create_from_discord(self.bot, guild)
return comm
async def fetch_community_twitch(self, user: twitchio.User) -> Community:
"""
Fetch or create a Community from the provided twitch account.
"""
community = await Community.fetch_from_twitchid(self.bot, user.id)
if community is None:
community = await Community.create_from_twitch(self.bot, user)
return community
# ----- Profile Commands ----- # ----- Profile Commands -----
@cmds.hybrid_group( @cmds.hybrid_group(
name='profiles', name='profiles',
description="Base comand group for user profiles." description="Base comand group for user profiles."
@@ -170,6 +243,7 @@ class ProfileCog(LionCog):
f"User {authrow} obtained from Twitch authentication does not exist." f"User {authrow} obtained from Twitch authentication does not exist."
) )
await ctx.error_reply("Sorry, something went wrong. Please try again later!") await ctx.error_reply("Sorry, something went wrong. Please try again later!")
return
user = results[0] user = results[0]
@@ -189,7 +263,7 @@ class ProfileCog(LionCog):
# Attach the discord row to the profile # Attach the discord row to the profile
await source_profile.attach_discord(ctx.author) await source_profile.attach_discord(ctx.author)
await message.edit( await message.edit(
content=f"Successfully connect to Twitch profile **{user.name}**! There was no profile data to merge." content=f"Successfully connected to Twitch profile **{user.name}**! There was no profile data to merge."
) )
elif source_profile is None and author_profile is None: elif source_profile is None and author_profile is None:
profile = await UserProfile.create_from_discord(self.bot, ctx.author) profile = await UserProfile.create_from_discord(self.bot, ctx.author)
@@ -217,6 +291,114 @@ class ProfileCog(LionCog):
content = '\n'.join(( content = '\n'.join((
"## Connecting Twitch account and merging profiles...", "## Connecting Twitch account and merging profiles...",
*results, *results,
"**Successfully linked account and merge profile data!**" "**Successfully linked account and merged profile data!**"
))
await message.edit(content=content)
# ----- Community Commands -----
@cmds.hybrid_group(
name='community',
description="Base comand group for community profiles."
)
async def community_grp(self, ctx: LionContext):
...
@community_grp.group(
name='link',
description="Base command group for linking communities"
)
async def community_link_grp(self, ctx: LionContext):
...
@community_link_grp.command(
name='twitch',
description="Link a twitch account to this community."
)
@appcmds.guild_only()
@appcmds.default_permissions(manage_guild=True)
async def comm_link_twitch_cmd(self, ctx: LionContext):
if not ctx.interaction:
return
assert ctx.guild is not None
await ctx.interaction.response.defer(ephemeral=True)
if not ctx.author.guild_permissions.manage_guild:
await ctx.error_reply("You need the `MANAGE_GUILD` permission to link this guild to a community.")
return
# Ask the user to go through auth to get their userid
auth_cog = self.bot.get_cog('TwitchAuthCog')
flow = await auth_cog.start_auth(
scopes=[
AuthScope.CHAT_EDIT,
AuthScope.CHAT_READ,
AuthScope.MODERATION_READ,
AuthScope.CHANNEL_BOT,
]
)
message = await ctx.reply(
f"Please [click here]({flow.auth.return_auth_url()}) to link your Twitch channel to this server."
)
authrow = await flow.run()
await message.edit(
content="Authentication Complete! Beginning community profile merge..."
)
results = await self.crocbot.fetch_users(ids=[authrow.userid])
if not results:
logger.error(
f"User {authrow} obtained from Twitch authentication does not exist."
)
await ctx.error_reply("Sorry, something went wrong. Please try again later!")
return
user = results[0]
# Retrieve author's profile if it exists
guild_comm = await Community.fetch_from_discordid(self.bot, ctx.guild.id)
# Check if the twitch-side user has a profile
twitch_comm = await Community.fetch_from_twitchid(self.bot, user.id)
if guild_comm and twitch_comm is None:
# All we need to do is attach the twitch row
await guild_comm.attach_twitch(user)
await message.edit(
content=f"Successfully linked Twitch channel **{user.name}**! There was no community data to merge."
)
elif twitch_comm and guild_comm is None:
# Attach the discord row to the profile
await twitch_comm.attach_discord(ctx.guild)
await message.edit(
content=f"Successfully connected to Twitch channel **{user.name}**!"
)
elif twitch_comm is None and guild_comm is None:
profile = await Community.create_from_discord(self.bot, ctx.guild)
await profile.attach_twitch(user)
await message.edit(
content=f"Created a new community for this server and linked Twitch account **{user.name}**."
)
elif guild_comm.communityid == twitch_comm.communityid:
await message.edit(
content=f"This server is already linked to the Twitch channel **{user.name}**!"
)
else:
# Migrate the existing profile data to the new profiles
try:
results = await self.migrate_community(twitch_comm, guild_comm)
except Exception:
await ctx.error_reply(
"An issue was encountered while merging your community profiles!\n"
"Migration rolled back, no data has been lost.\n"
"The developer has been notified. Please try again later!"
)
raise
content = '\n'.join((
"## Connecting Twitch account and merging community profiles...",
*results,
"**Successfully linked account and merged community data!**"
)) ))
await message.edit(content=content) await message.edit(content=content)

View File

@@ -0,0 +1,123 @@
from typing import Optional, Self
import discord
import twitchio
from meta import LionBot
from utils.lib import utc_now
from . import logger
from .data import ProfileData
class Community:
def __init__(self, bot: LionBot, community_row):
self.bot = bot
self.row: ProfileData.CommunityRow = community_row
@property
def cog(self):
return self.bot.get_cog('ProfileCog')
@property
def data(self) -> ProfileData:
return self.cog.data
@property
def communityid(self):
return self.row.communityid
def __repr__(self):
return f"<Community communityid={self.communityid} row={self.row}>"
async def attach_discord(self, guild: discord.Guild):
"""
Attach a new discord guild to this community.
Assumes the discord guild is not already associated to a community.
"""
discord_row = await self.data.DiscordCommunityRow.create(
communityid=self.communityid,
guildid=guild.id
)
logger.info(
f"Attached discord guild {guild!r} to community {self!r}"
)
return discord_row
async def attach_twitch(self, user: twitchio.User):
"""
Attach a new Twitch user channel to this community.
"""
twitch_row = await self.data.TwitchCommunityRow.create(
communityid=self.communityid,
channelid=str(user.id)
)
logger.info(
f"Attached twitch channel {user!r} to community {self!r}"
)
return twitch_row
async def discord_guilds(self) -> list[ProfileData.DiscordCommunityRow]:
"""
Fetch the Discord guild rows associated to this community.
"""
return await self.data.DiscordCommunityRow.fetch_where(communityid=self.communityid)
async def twitch_channels(self) -> list[ProfileData.TwitchCommunityRow]:
"""
Fetch the Twitch user rows associated to this profile.
"""
return await self.data.TwitchCommunityRow.fetch_where(communityid=self.communityid)
@classmethod
async def fetch(cls, bot: LionBot, community_id: int) -> Self:
community_row = await bot.get_cog('ProfileCog').data.CommunityRow.fetch(community_id)
if community_row is None:
raise ValueError("Provided community_id does not exist.")
return cls(bot, community_row)
@classmethod
async def fetch_from_twitchid(cls, bot: LionBot, channelid: int | str) -> Optional[Self]:
data = bot.get_cog('ProfileCog').data
rows = await data.TwitchCommunityRow.fetch_where(channelid=str(channelid))
if rows:
return await cls.fetch(bot, rows[0].communityid)
@classmethod
async def fetch_from_discordid(cls, bot: LionBot, guildid: int) -> Optional[Self]:
data = bot.get_cog('ProfileCog').data
rows = await data.DiscordCommunityRow.fetch_where(guildid=guildid)
if rows:
return await cls.fetch(bot, rows[0].communityid)
@classmethod
async def create(cls, bot: LionBot, **kwargs) -> Self:
"""
Create a new empty community with the given initial arguments.
Communities should usually be created using `create_from_discord` or `create_from_twitch`
to correctly setup initial preferences (e.g. name, avatar).
"""
# Create a new community
data = bot.get_cog('ProfileCog').data
row = await data.CommunityRow.create(created_at=utc_now(), **kwargs)
return await cls.fetch(bot, row.communityid)
@classmethod
async def create_from_discord(cls, bot: LionBot, guild: discord.Guild, **kwargs) -> Self:
"""
Create a new community using the given Discord guild as a base.
"""
self = await cls.create(bot, **kwargs)
await self.attach_discord(guild)
return self
@classmethod
async def create_from_twitch(cls, bot: LionBot, user: twitchio.User, **kwargs) -> Self:
"""
Create a new profile using the given Twitch channel user as a base.
"""
self = await cls.create(bot, **kwargs)
await self.attach_twitch(user)
return self

View File

@@ -82,6 +82,7 @@ class ProfileData(Registry):
------ ------
CREATE TABLE communities( CREATE TABLE communities(
communityid SERIAL PRIMARY KEY, communityid SERIAL PRIMARY KEY,
migrated INTEGER REFERENCES user_profiles (profileid) ON DELETE CASCADE ON UPDATE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
""" """
@@ -89,6 +90,7 @@ class ProfileData(Registry):
_cache_ = {} _cache_ = {}
communityid = Integer(primary=True) communityid = Integer(primary=True)
migrated = Integer()
created_at = Timestamp() created_at = Timestamp()
class DiscordCommunityRow(RowModel): class DiscordCommunityRow(RowModel):

View File

@@ -64,7 +64,7 @@ class UserProfile:
""" """
return await self.data.DiscordProfileRow.fetch_where(profileid=self.profileid) return await self.data.DiscordProfileRow.fetch_where(profileid=self.profileid)
async def twitch_accounts(self) -> list[ProfileData.DiscordProfileRow]: async def twitch_accounts(self) -> list[ProfileData.TwitchProfileRow]:
""" """
Fetch the Twitch accounts associated to this profile. Fetch the Twitch accounts associated to this profile.
""" """
@@ -87,7 +87,7 @@ class UserProfile:
@classmethod @classmethod
async def fetch_from_discordid(cls, bot: LionBot, userid: int) -> Optional[Self]: async def fetch_from_discordid(cls, bot: LionBot, userid: int) -> Optional[Self]:
data = bot.get_cog('ProfileCog').data data = bot.get_cog('ProfileCog').data
rows = await data.DiscordProfileRow.fetch_where(userid=str(userid)) rows = await data.DiscordProfileRow.fetch_where(userid=(userid))
if rows: if rows:
return await cls.fetch(bot, rows[0].profileid) return await cls.fetch(bot, rows[0].profileid)

View File

@@ -76,4 +76,4 @@ class TwitchAuthData(Registry):
); );
CREATE INDEX twitch_user_scopes_userid ON twitch_user_scopes (userid); CREATE INDEX twitch_user_scopes_userid ON twitch_user_scopes (userid);
""" """
user_scopes = Table('twitch_token_scopes') user_scopes = Table('twitch_user_scopes')