diff --git a/data/schema.sql b/data/schema.sql index 0ea3aff4..ec02d2b7 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -287,13 +287,14 @@ CREATE TABLE tasklist( deleted_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ, - last_updated_at TIMESTAMPTZ + last_updated_at TIMESTAMPTZ, + duration INTEGER ); CREATE INDEX tasklist_users ON tasklist (userid); ALTER TABLE tasklist ADD CONSTRAINT fk_tasklist_users FOREIGN KEY (userid) - REFERENCES user_config (userid) + REFERENCES user_profiles (profileid) ON DELETE CASCADE NOT VALID; ALTER TABLE tasklist diff --git a/src/meta/LionBot.py b/src/meta/LionBot.py index ebfc2875..04823601 100644 --- a/src/meta/LionBot.py +++ b/src/meta/LionBot.py @@ -92,6 +92,10 @@ class LionBot(Bot): def core(self): return self.get_cog('CoreCog') + @property + def profiles(self): + return self.get_cog('ProfileCog') + async def _handle_global_dispatch(self, event_name: str, *args, **kwargs): self.dispatch(event_name, *args, **kwargs) diff --git a/src/meta/LionContext.py b/src/meta/LionContext.py index 4aab2b19..6bcd11d7 100644 --- a/src/meta/LionContext.py +++ b/src/meta/LionContext.py @@ -13,6 +13,8 @@ if TYPE_CHECKING: from core.lion_member import LionMember from core.lion_user import LionUser from core.lion_guild import LionGuild + from modules.profiles.profile import UserProfile + from modules.profiles.community import Community logger = logging.getLogger(__name__) @@ -54,6 +56,8 @@ class LionContext(Context['LionBot']): lguild: 'LionGuild' lmember: 'LionMember' alion: 'LionUser | LionMember' + profile: 'UserProfile' + community: 'Community' def __repr__(self): parts = {} diff --git a/src/modules/profiles/cog.py b/src/modules/profiles/cog.py index d50314ae..1f33d4bb 100644 --- a/src/modules/profiles/cog.py +++ b/src/modules/profiles/cog.py @@ -40,6 +40,17 @@ class ProfileCog(LionCog): async def cog_check(self, ctx): return True + async def bot_check_once(self, ctx: LionContext): + """ + Inject the contextual UserProfile and Community into the LionContext. + + Creates the profile and community if they do not exist. + """ + if ctx.guild: + ctx.community = await self.fetch_community_discord(ctx.guild) + ctx.profile = await self.fetch_profile_discord(ctx.author) + return True + # Profile API def add_profile_migrator(self, migrator, name=None): name = name or migrator.__name__ diff --git a/src/modules/tasklist/cog.py b/src/modules/tasklist/cog.py index 739a226a..6d79b485 100644 --- a/src/modules/tasklist/cog.py +++ b/src/modules/tasklist/cog.py @@ -10,6 +10,7 @@ from discord.app_commands.transformers import AppCommandOptionType as cmdopt from meta import LionBot, LionCog, LionContext from meta.logger import log_wrap from meta.errors import UserInputError +from modules.profiles.profile import UserProfile from utils.lib import utc_now, error_embed from utils.ui import ChoicedEnum, Transformed, AButton @@ -141,7 +142,71 @@ class TasklistCog(LionCog): configcog = self.bot.get_cog('ConfigCog') self.crossload_group(self.configure_group, configcog.config_group) - @LionCog.listener('on_tasks_completed') + @log_wrap(action="Tasklist Profile Migration") + async def migrate_profiles(self, source_profile: UserProfile, target_profile: UserProfile): + """ + Re-assign all tasklist tasks from source profile to target profile. + TODO: Probably wants some elegant handling of the cached or running tasklists. + """ + results = ["(Tasklist)"] + sourceid = source_profile.profileid + targetid = target_profile.profileid + updated = await self.data.Task.table.update_where(userid=sourceid).set(userid=targetid) + if updated: + results.append( + f"Migrated {len(updated)} task row(s) from source profile." + ) + for channel_lists in self.live_tasklists.get(sourceid, []): + for tasklist in list(channel_lists.values()): + await tasklist.close() + self.bot.dispatch('tasklist_update', profileid=targetid, summon=False) + else: + results.append( + "No tasks found in source profile, nothing to migrate!" + ) + + return ' '.join(results) + + async def user_profile_migration(self): + """ + Manual one-shot migration method from old Discord userids to the new profileids. + """ + # First collect all the distinct userids from the tasklist + # Then create a map of userids to profileids, creating the profiles if required + # Then do updates, we can just inefficiently do updates on each distinct userid + # As long as the userids and profileids never overlap, this is fine. Fine for a one-shot + + # Extract all the userids that exist in the table + rows = await self.data.Task.table.select_where().select( + userid="DISTINCT(userid)" + ).with_no_adapter() + + # Fetch or create discord user profiles for them + profile_map = {} + for row in rows: + userid = row['userid'] + if userid > 100000: + # Assume a Discord snowflake + profile = await UserProfile.fetch_from_discordid(self.bot, userid) + + if not profile: + try: + user = self.bot.get_user(userid) + if user is None: + user = await self.bot.fetch_user(userid) + except discord.HTTPException: + logger.info(f"Skipping user {userid}") + continue + profile = await UserProfile.create_from_discord(self.bot, user) + profile_map[userid] = profile + + # Now iterate through + for userid, profile in profile_map.items(): + logger.info(f"Migrating userid {userid} to profile {profile}") + await self.data.Task.table.update_where(userid=userid).set(userid=profile.profileid) + + # Temporarily disabling integration with userid driven Economy + # @LionCog.listener('on_tasks_completed') @log_wrap(action="reward tasks completed") async def reward_tasks_completed(self, member: discord.Member, *taskids: int): async with self.bot.db.connection() as conn: @@ -170,6 +235,9 @@ class TasklistCog(LionCog): ) async def is_tasklist_channel(self, channel) -> bool: + """ + Check whether a given Discord channel is a tasklist channel + """ if not channel.guild: return True channels = (await self.settings.tasklist_channels.get(channel.guild.id)).value @@ -186,12 +254,16 @@ class TasklistCog(LionCog): return (channel in channels) or (channel.id in private_channels) or (channel.category in channels) async def call_tasklist(self, interaction: discord.Interaction): + """ + Given a Discord channel interaction, summon the interacting user's tasklist. + """ await interaction.response.defer(thinking=True, ephemeral=True) channel = interaction.channel guild = channel.guild - userid = interaction.user.id + profile = await self.bot.profiles.fetch_profile_discord(interaction.user) + profileid = profile.profileid - tasklist = await Tasklist.fetch(self.bot, self.data, userid) + tasklist = await Tasklist.fetch(self.bot, self.data, profileid) if await self.is_tasklist_channel(channel): # Check we have permissions to send a regular message here @@ -213,7 +285,7 @@ class TasklistCog(LionCog): ) await interaction.edit_original_response(embed=error) else: - tasklistui = TasklistUI.fetch(tasklist, channel, guild, timeout=None) + tasklistui = TasklistUI.fetch(tasklist, channel, guild, caller=interaction.user, timeout=None) await tasklistui.summon(force=True) await interaction.delete_original_response() else: @@ -222,14 +294,14 @@ class TasklistCog(LionCog): await tasklistui.run(interaction) @LionCog.listener('on_tasklist_update') - async def update_listening_tasklists(self, userid, channel=None, summon=True): + async def update_listening_tasklists(self, profileid, channel=None, summon=True): """ Propagate a tasklist update to all persistent tasklist UIs for this user. If channel is given, also summons the UI if the channel is a tasklist channel. """ # Do the given channel first, and summon if requested - if channel and (tui := TasklistUI._live_[userid].get(channel.id, None)) is not None: + if channel and (tui := TasklistUI._live_[profileid].get(channel.id, None)) is not None: try: if summon and await self.is_tasklist_channel(channel): await tui.summon() @@ -240,7 +312,7 @@ class TasklistCog(LionCog): await tui.close() # Now do the rest of the listening channels - listening = TasklistUI._live_[userid] + listening = TasklistUI._live_[profileid] for cid, ui in list(listening.items()): if channel and channel.id == cid: # We already did this channel @@ -275,7 +347,7 @@ class TasklistCog(LionCog): async def tasklist_group(self, ctx: LionContext): raise NotImplementedError - async def _task_acmpl(self, userid: int, partial: str, multi=False) -> list[appcmds.Choice]: + async def _task_acmpl(self, profileid: int, partial: str, multi=False) -> list[appcmds.Choice]: """ Generate a list of task Choices matching a given partial string. @@ -284,7 +356,7 @@ class TasklistCog(LionCog): t = self.bot.translator.t # Should usually be cached, so this won't trigger repetitive db access - tasklist = await Tasklist.fetch(self.bot, self.data, userid) + tasklist = await Tasklist.fetch(self.bot, self.data, profileid) # Special case for an empty tasklist if not tasklist.tasklist: @@ -392,13 +464,17 @@ class TasklistCog(LionCog): """ Shared autocomplete for single task parameters. """ - return await self._task_acmpl(interaction.user.id, partial, multi=False) + profile = await self.bot.profiles.fetch_profile_discord(interaction.user) + profileid = profile.profileid + return await self._task_acmpl(profileid, partial, multi=False) async def tasks_acmpl(self, interaction: discord.Interaction, partial: str) -> list[appcmds.Choice]: """ Shared autocomplete for multiple task parameters. """ - return await self._task_acmpl(interaction.user.id, partial, multi=True) + profile = await self.bot.profiles.fetch_profile_discord(interaction.user) + profileid = profile.profileid + return await self._task_acmpl(profileid, partial, multi=True) @tasklist_group.command( name=_p('cmd:tasks_new', "new"), @@ -422,7 +498,7 @@ class TasklistCog(LionCog): if not ctx.interaction: return - tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) + tasklist = await Tasklist.fetch(self.bot, self.data, ctx.profile.profileid) await ctx.interaction.response.defer(thinking=True, ephemeral=True) # Fetch parent task if required @@ -453,9 +529,9 @@ class TasklistCog(LionCog): ) await ctx.interaction.edit_original_response( embed=embed, - view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) + view=None if ctx.channel.id in TasklistUI._live_[ctx.profile.profileid] else TasklistCaller(self.bot) ) - self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) + self.bot.dispatch('tasklist_update', profileid=ctx.profile.profileid, channel=ctx.channel) tasklist_new_cmd.autocomplete('parent')(task_acmpl) @@ -523,7 +599,7 @@ class TasklistCog(LionCog): raise UserInputError(error) # Contents successfully parsed, update the tasklist. - tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) + tasklist = await Tasklist.fetch(self.bot, self.data, ctx.profile.profileid) taskinfo = tasklist.parse_tasklist(lines) @@ -572,9 +648,9 @@ class TasklistCog(LionCog): ) await ctx.interaction.edit_original_response( embed=embed, - view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) + view=None if ctx.channel.id in TasklistUI._live_[ctx.profile.profileid] else TasklistCaller(self.bot) ) - self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) + self.bot.dispatch('tasklist_update', profileid=ctx.profile.profileid, channel=ctx.channel) @tasklist_group.command( name=_p('cmd:tasks_edit', "edit"), @@ -600,7 +676,7 @@ class TasklistCog(LionCog): t = self.bot.translator.t if not ctx.interaction: return - tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) + tasklist = await Tasklist.fetch(self.bot, self.data, ctx.profile.profileid) # Fetch task to edit tid = tasklist.parse_label(taskstr) if taskstr else None @@ -651,12 +727,12 @@ class TasklistCog(LionCog): await interaction.response.send_message( embed=embed, view=( - discord.utils.MISSING if ctx.channel.id in TasklistUI._live_[ctx.author.id] + discord.utils.MISSING if ctx.channel.id in TasklistUI._live_[ctx.profile.profileid] else TasklistCaller(self.bot) ), ephemeral=True ) - self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) + self.bot.dispatch('tasklist_update', profileid=ctx.profile.profileid, channel=ctx.channel) if new_content or new_parent: # Manual edit route @@ -688,17 +764,17 @@ class TasklistCog(LionCog): async def tasklist_clear_cmd(self, ctx: LionContext): t = ctx.bot.translator.t - tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) + tasklist = await Tasklist.fetch(self.bot, self.data, ctx.profile.profileid) await tasklist.update_tasklist(deleted_at=utc_now()) await ctx.reply( t(_p( 'cmd:tasks_clear|resp:success', "Your tasklist has been cleared." )), - view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot), + view=None if ctx.channel.id in TasklistUI._live_[ctx.profile.profileid] else TasklistCaller(self.bot), ephemeral=True ) - self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) + self.bot.dispatch('tasklist_update', profileid=ctx.profile.profileid, channel=ctx.channel) @tasklist_group.command( name=_p('cmd:tasks_remove', "remove"), @@ -748,7 +824,7 @@ class TasklistCog(LionCog): await ctx.interaction.response.defer(thinking=True, ephemeral=True) - tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) + tasklist = await Tasklist.fetch(self.bot, self.data, ctx.profile.profileid) conditions = [] if taskidstr: @@ -784,7 +860,7 @@ class TasklistCog(LionCog): elif completed is False: conditions.append(self.data.Task.completed_at == NULL) - tasks = await self.data.Task.fetch_where(*conditions, userid=ctx.author.id) + tasks = await self.data.Task.fetch_where(*conditions, userid=ctx.profile.profileid) if not tasks: await ctx.interaction.edit_original_response( embed=error_embed(t(_p( @@ -813,9 +889,9 @@ class TasklistCog(LionCog): ) await ctx.interaction.edit_original_response( embed=embed, - view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) + view=None if ctx.channel.id in TasklistUI._live_[ctx.profile.profileid] else TasklistCaller(self.bot) ) - self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) + self.bot.dispatch('tasklist_update', profileid=ctx.profile.profileid, channel=ctx.channel) tasklist_remove_cmd.autocomplete('taskidstr')(tasks_acmpl) @@ -844,7 +920,7 @@ class TasklistCog(LionCog): await ctx.interaction.response.defer(thinking=True, ephemeral=True) - tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) + tasklist = await Tasklist.fetch(self.bot, self.data, ctx.profile.profileid) try: taskids = tasklist.parse_labels(taskidstr) @@ -889,9 +965,9 @@ class TasklistCog(LionCog): ) await ctx.interaction.edit_original_response( embed=embed, - view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) + view=None if ctx.channel.id in TasklistUI._live_[ctx.profile.profileid] else TasklistCaller(self.bot) ) - self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) + self.bot.dispatch('tasklist_update', profileid=ctx.profile.profileid, channel=ctx.channel) tasklist_tick_cmd.autocomplete('taskidstr')(tasks_acmpl) @@ -920,7 +996,7 @@ class TasklistCog(LionCog): await ctx.interaction.response.defer(thinking=True, ephemeral=True) - tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) + tasklist = await Tasklist.fetch(self.bot, self.data, ctx.profile.profileid) try: taskids = tasklist.parse_labels(taskidstr) @@ -962,9 +1038,9 @@ class TasklistCog(LionCog): ) await ctx.interaction.edit_original_response( embed=embed, - view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) + view=None if ctx.channel.id in TasklistUI._live_[ctx.profile.profileid] else TasklistCaller(self.bot) ) - self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) + self.bot.dispatch('tasklist_update', profileid=ctx.profile.profileid, channel=ctx.channel) tasklist_untick_cmd.autocomplete('taskidstr')(tasks_acmpl) diff --git a/src/modules/tasklist/data.py b/src/modules/tasklist/data.py index fb31de34..dd2cf8b6 100644 --- a/src/modules/tasklist/data.py +++ b/src/modules/tasklist/data.py @@ -14,12 +14,14 @@ class TasklistData(Registry): CREATE TABLE tasklist( taskid SERIAL PRIMARY KEY, userid BIGINT NOT NULL REFERENCES user_config ON DELETE CASCADE, + profileid INTEGER NOT NULL REFERENCES user_profiles ON DELETE CASCADE ON UPDATE CASCADE, parentid INTEGER REFERENCES tasklist (taskid) ON DELETE SET NULL, content TEXT NOT NULL, rewarded BOOL DEFAULT FALSE, deleted_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ, + duration INTEGER, last_updated_at TIMESTAMPTZ ); CREATE INDEX tasklist_users ON tasklist (userid); @@ -41,5 +43,6 @@ class TasklistData(Registry): created_at = Timestamp() deleted_at = Timestamp() last_updated_at = Timestamp() + duration = Integer() channels = Table('tasklist_channels') diff --git a/src/modules/tasklist/migration.sql b/src/modules/tasklist/migration.sql new file mode 100644 index 00000000..14a606ac --- /dev/null +++ b/src/modules/tasklist/migration.sql @@ -0,0 +1,12 @@ +ALTER TABLE tasklist + DROP CONSTRAINT fk_tasklist_users; + +ALTER TABLE tasklist + ADD CONSTRAINT fk_tasklist_users + FOREIGN KEY (userid) + REFERENCES user_profiles (profileid) + ON DELETE CASCADE + ON UPDATE CASCADE +NOT VALID; +ALTER TABLE tasklist + ADD COLUMN duration INTEGER; diff --git a/src/modules/tasklist/ui.py b/src/modules/tasklist/ui.py index 22049e56..d651bf60 100644 --- a/src/modules/tasklist/ui.py +++ b/src/modules/tasklist/ui.py @@ -232,13 +232,18 @@ class TasklistUI(BasePager): def __init__(self, tasklist: Tasklist, - channel: discord.abc.Messageable, guild: Optional[discord.Guild] = None, **kwargs): + channel: discord.abc.Messageable, + guild: Optional[discord.Guild] = None, + caller: Optional[discord.User | discord.Member] = None, + **kwargs): kwargs.setdefault('timeout', 600) super().__init__(**kwargs) self.bot = tasklist.bot self.tasklist = tasklist self.labelled = tasklist.labelled + self.caller = caller + # NOTE: This is now a profiled self.userid = tasklist.userid self.channel = channel self.guild = guild @@ -449,9 +454,10 @@ class TasklistUI(BasePager): cascade=True, completed_at=utc_now() ) - if self.guild: - if (member := self.guild.get_member(self.userid)): - self.bot.dispatch('tasks_completed', member, *(t.taskid for t in to_complete)) + # TODO: Removed economy integration + # if self.guild: + # if (member := self.guild.get_member(self.userid)): + # self.bot.dispatch('tasks_completed', member, *(t.taskid for t in to_complete)) if to_uncomplete: await self.tasklist.update_tasks( *(t.taskid for t in to_uncomplete), @@ -475,7 +481,7 @@ class TasklistUI(BasePager): if shared_root: self._subtree_root = labelled[shared_root].taskid - self.bot.dispatch('tasklist_update', userid=self.userid, channel=self.channel, summon=False) + self.bot.dispatch('tasklist_update', profileid=self.userid, channel=self.channel, summon=False) async def _delete_menu(self, interaction: discord.Interaction, selected: Select, subtree: bool): await interaction.response.defer() @@ -486,7 +492,7 @@ class TasklistUI(BasePager): cascade=True, deleted_at=utc_now() ) - self.bot.dispatch('tasklist_update', userid=self.userid, channel=self.channel, summon=False) + self.bot.dispatch('tasklist_update', profileid=self.userid, channel=self.channel, summon=False) async def _edit_menu(self, interaction: discord.Interaction, selected: Select, subtree: bool): if not selected.values: @@ -513,7 +519,7 @@ class TasklistUI(BasePager): self._last_parentid = new_parentid if not subtree: self._subtree_root = new_parentid - self.bot.dispatch('tasklist_update', userid=self.userid, channel=self.channel, summon=False) + self.bot.dispatch('tasklist_update', profileid=self.userid, channel=self.channel, summon=False) await interaction.response.send_modal(editor) @@ -606,7 +612,7 @@ class TasklistUI(BasePager): self._subtree_root = pid await interaction.response.defer() await self.tasklist.create_task(new_task, parentid=pid) - self.bot.dispatch('tasklist_update', userid=self.userid, channel=self.channel, summon=False) + self.bot.dispatch('tasklist_update', profileid=self.userid, channel=self.channel, summon=False) await press.response.send_modal(editor) @@ -667,7 +673,7 @@ class TasklistUI(BasePager): @editor.add_callback async def editor_callback(interaction: discord.Interaction): - self.bot.dispatch('tasklist_update', userid=self.userid, channel=self.channel, summon=False) + self.bot.dispatch('tasklist_update', profileid=self.userid, channel=self.channel, summon=False) if sum(len(line) for line in editor.lines.values()) + len(editor.lines) >= 4000: await press.response.send_message( @@ -698,7 +704,7 @@ class TasklistUI(BasePager): await self.tasklist.update_tasklist( deleted_at=utc_now(), ) - self.bot.dispatch('tasklist_update', userid=self.userid, channel=self.channel, summon=False) + self.bot.dispatch('tasklist_update', profileid=self.userid, channel=self.channel, summon=False) async def clear_button_refresh(self): self.clear_button.label = self.bot.translator.t(_p( @@ -771,11 +777,12 @@ class TasklistUI(BasePager): # ----- UI Flow ----- def access_check(self, userid): - return userid == self.userid + return userid in (self.userid, self.caller.id if self.caller else None) async def interaction_check(self, interaction: discord.Interaction): t = self.bot.translator.t - if not self.access_check(interaction.user.id): + interaction_profile = await self.bot.profiles.fetch_profile_discord(interaction.user) + if not self.access_check(interaction_profile.profileid): embed = discord.Embed( description=t(_p( 'ui:tasklist|error:wrong_user', @@ -812,10 +819,7 @@ class TasklistUI(BasePager): total = len(tasks) completed = sum(t.completed_at is not None for t in tasks) - if self.guild: - user = self.guild.get_member(self.userid) - else: - user = self.bot.get_user(self.userid) + user = self.caller user_name = user.name if user else str(self.userid) user_colour = user.colour if user else discord.Color.orange()