From 377a849c8b559a0ce6ebb49efe647b36e13a0aa8 Mon Sep 17 00:00:00 2001 From: Nathaniel Landau Date: Sun, 6 Oct 2024 21:35:08 -0400 Subject: [PATCH 1/2] fix(webui): sync channel changes from web to discord --- .pre-commit-config.yaml | 10 +- src/valentina/constants.py | 26 +- src/valentina/discord/bot.py | 82 +-- .../discord/characters/add_from_sheet.py | 8 +- src/valentina/discord/characters/chargen.py | 9 +- src/valentina/discord/cogs/admin.py | 23 +- src/valentina/discord/cogs/campaign.py | 34 +- src/valentina/discord/cogs/characters.py | 21 +- src/valentina/discord/cogs/developer.py | 5 +- src/valentina/discord/cogs/storyteller.py | 4 +- src/valentina/discord/models/__init__.py | 6 + src/valentina/discord/models/channel_mngr.py | 513 ++++++++++++++++++ src/valentina/discord/models/webui_hook.py | 57 ++ src/valentina/models/__init__.py | 2 + src/valentina/models/campaign.py | 353 +----------- src/valentina/models/character.py | 173 +----- src/valentina/models/web_discord_sync.py | 55 ++ src/valentina/utils/database.py | 2 + .../webui/blueprints/character_view/forms.py | 3 - .../webui/blueprints/character_view/route.py | 79 +-- .../templates/character_view/Main.jinja | 6 +- uv.lock | 6 +- 22 files changed, 821 insertions(+), 656 deletions(-) create mode 100644 src/valentina/discord/models/__init__.py create mode 100644 src/valentina/discord/models/channel_mngr.py create mode 100644 src/valentina/discord/models/webui_hook.py create mode 100644 src/valentina/models/web_discord_sync.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 681278b5..404d8167 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ --- # https://pre-commit.com default_install_hook_types: [commit-msg, pre-commit] -default_stages: [commit, manual] +default_stages: [pre-commit, manual] fail_fast: true repos: - repo: "https://github.com/commitizen-tools/commitizen" @@ -76,6 +76,14 @@ repos: - id: djlint args: ["--configuration", "pyproject.toml"] + # - repo: "https://github.com/pre-commit/mirrors-mypy" + # rev: v1.11.2 + # hooks: + # - id: mypy + # name: "check type hints" + # args: [--ignore-missing-imports] + # additional_dependencies: [types-aiofiles] + - repo: local hooks: # This calls a custom pre-commit script. diff --git a/src/valentina/constants.py b/src/valentina/constants.py index 876ca89c..1f5864ca 100644 --- a/src/valentina/constants.py +++ b/src/valentina/constants.py @@ -92,6 +92,10 @@ class Emoji(Enum): WARNING = "⚠️" WEREWOLF = "🐺" YES = "✅" + CHANNEL_PLAYER = "👤" + CHANNEL_PRIVATE = "🔒" + CHANNEL_GENERAL = "✨" + CHANNEL_PLAYER_DEAD = "💀" class LogLevel(StrEnum): @@ -106,6 +110,24 @@ class LogLevel(StrEnum): CRITICAL = "CRITICAL" +class DBSyncUpdateType(str, Enum): + """Type of update to sync between web and discord.""" + + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + + +class DBSyncModelType(str, Enum): + """Model type to sync between web and discord.""" + + CAMPAIGN = "campaign" + CHARACTER = "character" + GUILD = "guild" + ROLE = "role" + USER = "user" + + class ChannelPermission(Enum): """Enum for permissions when creating a character. Default is UNRESTRICTED.""" @@ -119,8 +141,8 @@ class ChannelPermission(Enum): class CampaignChannelName(Enum): """Enum for common campaign channel names.""" - GENERAL = f"{Emoji.SPARKLES.value}-general" - STORYTELLER = f"{Emoji.LOCK.value}-storyteller" + GENERAL = f"{Emoji.CHANNEL_GENERAL.value}-general" + STORYTELLER = f"{Emoji.CHANNEL_PRIVATE.value}-storyteller" class DiceType(Enum): diff --git a/src/valentina/discord/bot.py b/src/valentina/discord/bot.py index ff60ab01..11bf1483 100644 --- a/src/valentina/discord/bot.py +++ b/src/valentina/discord/bot.py @@ -11,7 +11,7 @@ import semver from beanie import UpdateResponse from beanie.operators import Set -from discord.ext import commands +from discord.ext import commands, tasks from loguru import logger from valentina.constants import ( @@ -24,6 +24,7 @@ PermissionsKillCharacter, PermissionsManageTraits, ) +from valentina.discord.models import ChannelManager, SyncDiscordFromWebManager from valentina.models import ( Campaign, ChangelogPoster, @@ -35,8 +36,6 @@ from valentina.utils import ValentinaConfig, errors from valentina.utils.database import init_database -from valentina.discord.utils.discord_utils import set_channel_perms # isort:skip - # Subclass discord.ApplicationContext to create custom application context class ValentinaContext(discord.ApplicationContext): @@ -391,64 +390,16 @@ async def channel_update_or_add( Returns: discord.TextChannel: The newly created or updated text channel. """ - # Fetch roles - player_role = discord.utils.get(self.guild.roles, name="Player") - storyteller_role = discord.utils.get(self.guild.roles, name="Storyteller") - - # Initialize permission overwrites - overwrites = { - self.guild.default_role: set_channel_perms(permissions[0]), - player_role: set_channel_perms(permissions[1]), - storyteller_role: set_channel_perms(permissions[2]), - **{ - user: set_channel_perms(ChannelPermission.MANAGE) - for user in self.guild.members - if user.bot - }, - } - - if permissions_user_post: - overwrites[permissions_user_post] = set_channel_perms(ChannelPermission.POST) - - formatted_name = name.lower().strip().replace(" ", "-") if name else None - - if name and not channel: - for existing_channel in self.guild.text_channels: - # If channel already exists in a specified category, edit it - if ( - category - and existing_channel.category == category - and existing_channel.name == formatted_name - ) or (not category and existing_channel.name == formatted_name): - logger.debug(f"GUILD: Update channel '{channel.name}' on '{self.guild.name}'") - await existing_channel.edit( - name=formatted_name or channel.name, - overwrites=overwrites, - topic=topic or channel.topic, - category=category or channel.category, - ) - return existing_channel - - # Create the channel if it doesn't exist - logger.debug(f"GUILD: Create channel '{name}' on '{self.guild.name}'") - return await self.guild.create_text_channel( - name=formatted_name, - overwrites=overwrites, - topic=topic, - category=category, - ) - - # Update existing channel - logger.debug(f"GUILD: Update channel '{channel.name}' on '{self.guild.name}'") - await channel.edit( - name=name or channel.name, - overwrites=overwrites, - topic=topic or channel.topic, - category=category or channel.category, + channel_manager = ChannelManager(guild=self.guild, user=self.author) + return await channel_manager.channel_update_or_add( + permissions=permissions, + channel=channel, + name=name, + topic=topic, + category=category, + permissions_user_post=permissions_user_post, ) - return channel - class Valentina(commands.Bot): """Extend the discord.Bot class to create a custom bot implementation. @@ -465,6 +416,7 @@ def __init__(self, version: str, *args: Any, **kwargs: Any): self.welcomed = False self.version = version self.owner_channels = [int(x) for x in ValentinaConfig().owner_channels.split(",")] + self.sync_from_web.start() # Load Cogs # ####################### @@ -689,3 +641,15 @@ async def get_application_context( # type: ignore ValentinaContext: A custom application context for Valentina bot interactions. """ return await super().get_application_context(interaction, cls=cls) + + @tasks.loop(minutes=2) + async def sync_from_web(self) -> None: + """Sync objects from the webui to Discord.""" + logger.debug("SYNC: Running sync_from_web task") + sync_discord = SyncDiscordFromWebManager(self) + await sync_discord.run() + + @sync_from_web.before_loop + async def before_sync_from_web(self) -> None: + """Wait for the bot to be ready before starting the sync_from_web task.""" + await self.wait_until_ready() diff --git a/src/valentina/discord/characters/add_from_sheet.py b/src/valentina/discord/characters/add_from_sheet.py index 76a34b44..c9acba4a 100644 --- a/src/valentina/discord/characters/add_from_sheet.py +++ b/src/valentina/discord/characters/add_from_sheet.py @@ -11,6 +11,7 @@ from valentina.constants import MAX_BUTTONS_PER_ROW, EmbedColor, TraitCategory from valentina.discord.bot import ValentinaContext +from valentina.discord.models import ChannelManager from valentina.models import Campaign, Character, CharacterTrait, User from valentina.utils.helpers import get_max_trait_value @@ -200,8 +201,11 @@ async def __finalize_character( # Create channel if self.campaign: - await self.character.confirm_channel(self.ctx, self.campaign) - await self.campaign.sort_channels(self.ctx) + channel_manager = ChannelManager(guild=self.ctx.guild, user=self.ctx.author) + await channel_manager.confirm_character_channel( + character=self.character, campaign=self.campaign + ) + await channel_manager.sort_campaign_channels(self.campaign) # Respond to the user embed = discord.Embed( diff --git a/src/valentina/discord/characters/chargen.py b/src/valentina/discord/characters/chargen.py index 137eeb6f..d2321fa3 100644 --- a/src/valentina/discord/characters/chargen.py +++ b/src/valentina/discord/characters/chargen.py @@ -23,6 +23,7 @@ VampireClan, ) from valentina.discord.bot import Valentina, ValentinaContext +from valentina.discord.models import ChannelManager from valentina.discord.views import ChangeNameModal, sheet_embed from valentina.models import Campaign, Character, CharacterSheetSection, CharacterTrait, User from valentina.utils import random_num @@ -1402,8 +1403,12 @@ async def finalize_character_selection(self, character: Character) -> None: if self.campaign: character.campaign = str(self.campaign.id) await character.save() - await character.confirm_channel(self.ctx, self.campaign) - await self.campaign.sort_channels(self.ctx) + + channel_manager = ChannelManager(guild=self.ctx.guild, user=self.ctx.author) + await channel_manager.confirm_character_channel( + character=character, campaign=self.campaign + ) + await channel_manager.sort_campaign_channels(self.campaign) async def spend_freebie_points(self, character: Character) -> Character: """Spend freebie points on a character. diff --git a/src/valentina/discord/cogs/admin.py b/src/valentina/discord/cogs/admin.py index 362dc406..9edf1f80 100644 --- a/src/valentina/discord/cogs/admin.py +++ b/src/valentina/discord/cogs/admin.py @@ -12,6 +12,7 @@ from valentina.constants import VALID_IMAGE_EXTENSIONS, RollResultType from valentina.discord.bot import Valentina, ValentinaContext +from valentina.discord.models import ChannelManager from valentina.discord.utils.autocomplete import select_any_player_character, select_campaign from valentina.discord.utils.converters import ValidCampaign, ValidCharacterObject, ValidImageURL from valentina.discord.utils.discord_utils import assert_permissions @@ -75,9 +76,11 @@ async def rebuild_campaign_channels( return guild = await Guild.get(ctx.guild.id, fetch_links=True) + + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) for campaign in guild.campaigns: - await campaign.delete_channels(ctx) - await campaign.create_channels(ctx) + await channel_manager.delete_campaign_channels(campaign) + await channel_manager.confirm_campaign_channels(campaign) await msg.edit_original_response(embed=confirmation_embed, view=None) @@ -123,7 +126,12 @@ async def associate_campaign( if not is_confirmed: return - await character.associate_with_campaign(ctx, campaign) + await character.associate_with_campaign(campaign) + + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) + await channel_manager.delete_character_channel(character) + await channel_manager.confirm_character_channel(character=character, campaign=campaign) + await channel_manager.sort_campaign_channels(campaign) await interaction.edit_original_response(embed=confirmation_embed, view=None) @@ -151,8 +159,9 @@ async def delete_champaign_channels( if not is_confirmed: return - for c in guild.campaigns: - await c.delete_channels(ctx) + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) + for campaign in guild.campaigns: + await channel_manager.delete_campaign_channels(campaign) await interaction.edit_original_response(embed=confirmation_embed, view=None) @@ -187,7 +196,9 @@ async def character_delete( await user.remove_character(character) await user.save() - await character.delete_channel(ctx) + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) + await channel_manager.delete_character_channel(character) + await character.delete(link_rule=DeleteRules.DELETE_LINKS) await interaction.edit_original_response(embed=confirmation_embed, view=None) diff --git a/src/valentina/discord/cogs/campaign.py b/src/valentina/discord/cogs/campaign.py index 639509f0..83e9edc7 100644 --- a/src/valentina/discord/cogs/campaign.py +++ b/src/valentina/discord/cogs/campaign.py @@ -11,6 +11,7 @@ from valentina.constants import MAX_FIELD_COUNT, EmbedColor from valentina.discord.bot import Valentina, ValentinaContext +from valentina.discord.models import ChannelManager from valentina.discord.utils.autocomplete import ( select_book, select_campaign, @@ -171,7 +172,8 @@ async def create_campaign( await guild.save() - await campaign.create_channels(ctx) + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) + await channel_manager.confirm_campaign_channels(campaign) await interaction.edit_original_response(embed=confirmation_embed, view=None) @@ -230,7 +232,9 @@ async def delete_campaign( guild = await Guild.get(ctx.guild.id, fetch_links=True) await guild.delete_campaign(campaign) - await campaign.delete_channels(ctx) + + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) + await channel_manager.delete_campaign_channels(campaign) await interaction.edit_original_response(embed=confirmation_embed, view=None) @@ -522,7 +526,9 @@ async def create_book( await book.insert() campaign.books.append(book) await campaign.save() - await campaign.create_channels(ctx) + + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) + await channel_manager.confirm_campaign_channels(campaign) await ctx.post_to_audit_log( f"Create book: `{book.number}. {book.name}` in `{campaign.name}`", @@ -615,7 +621,9 @@ async def edit_book( await book.save() if original_name != name: - await campaign.create_channels(ctx) + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) + await channel_manager.confirm_book_channel(book=book, campaign=campaign) + await channel_manager.sort_campaign_channels(campaign) await ctx.post_to_audit_log(f"Update book: `{book.name}` in `{campaign.name}`") @@ -660,7 +668,10 @@ async def delete_book( return original_number = book.number - await book.delete_channel(ctx) + + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) + await channel_manager.delete_book_channel(book) + await book.delete() campaign.books = [x for x in campaign.books if x.id != book.id] # type: ignore [attr-defined] await campaign.save() @@ -669,7 +680,7 @@ async def delete_book( for b in [x for x in await campaign.fetch_books() if x.number > original_number]: b.number -= 1 await b.save() - await b.confirm_channel(ctx, campaign) + await channel_manager.confirm_book_channel(book=b, campaign=campaign) await asyncio.sleep(1) await interaction.edit_original_response(embed=confirmation_embed, view=None) @@ -731,6 +742,8 @@ async def renumber_books( # Update the number of the selected book book.number = new_number + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) + # Adjust the numbers of the other books if new_number > original_number: # Shift books down if the new number is higher @@ -738,7 +751,7 @@ async def renumber_books( if original_number < b.number <= new_number: b.number -= 1 await b.save() - await b.confirm_channel(ctx, campaign) + await channel_manager.confirm_book_channel(book=b, campaign=campaign) await asyncio.sleep(1) else: # Shift books up if the new number is lower @@ -746,13 +759,14 @@ async def renumber_books( if new_number <= b.number < original_number: b.number += 1 await b.save() - await b.confirm_channel(ctx, campaign) + await channel_manager.confirm_book_channel(book=b, campaign=campaign) await asyncio.sleep(1) # Save the selected book with its new number await book.save() - await book.confirm_channel(ctx, campaign) - await campaign.sort_channels(ctx) + + await channel_manager.confirm_book_channel(book=book, campaign=campaign) + await channel_manager.sort_campaign_channels(campaign) await interaction.edit_original_response(embed=confirmation_embed, view=None) diff --git a/src/valentina/discord/cogs/characters.py b/src/valentina/discord/cogs/characters.py index be42a77a..43d3e603 100644 --- a/src/valentina/discord/cogs/characters.py +++ b/src/valentina/discord/cogs/characters.py @@ -17,6 +17,7 @@ ) from valentina.discord.bot import Valentina, ValentinaContext from valentina.discord.characters import AddFromSheetWizard, CharGenWizard +from valentina.discord.models import ChannelManager from valentina.discord.utils.autocomplete import ( select_any_player_character, select_campaign, @@ -271,8 +272,9 @@ async def kill_character( await character.save() if campaign: - await character.confirm_channel(ctx, campaign) - await campaign.sort_channels(ctx) + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) + await channel_manager.confirm_character_channel(character=character, campaign=campaign) + await channel_manager.sort_campaign_channels(campaign) await interaction.edit_original_response(embed=confirmation_embed, view=None) @@ -309,8 +311,9 @@ async def rename( await character.save() if campaign: - await character.confirm_channel(ctx, campaign) - await campaign.sort_channels(ctx) + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) + await channel_manager.confirm_character_channel(character=character, campaign=campaign) + await channel_manager.sort_campaign_channels(campaign) await interaction.edit_original_response(embed=confirmation_embed, view=None) @@ -838,7 +841,12 @@ async def associate_campaign( if not is_confirmed: return - await character.associate_with_campaign(ctx, campaign) + await character.associate_with_campaign(campaign) + + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) + await channel_manager.delete_character_channel(character) + await channel_manager.confirm_character_channel(character=character, campaign=campaign) + await channel_manager.sort_campaign_channels(campaign) await interaction.edit_original_response(embed=confirmation_embed, view=None) @@ -885,7 +893,8 @@ async def transfer_character( character.user_owner = new_owner.id await character.save() - await character.update_channel_permissions(ctx, campaign) + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) + await channel_manager.confirm_character_channel(character=character, campaign=campaign) await interaction.edit_original_response(embed=confirmation_embed, view=None) diff --git a/src/valentina/discord/cogs/developer.py b/src/valentina/discord/cogs/developer.py index 05fa8eb2..836eb923 100644 --- a/src/valentina/discord/cogs/developer.py +++ b/src/valentina/discord/cogs/developer.py @@ -23,6 +23,7 @@ ) from valentina.discord.bot import Valentina, ValentinaContext from valentina.discord.characters import RNGCharGen +from valentina.discord.models import ChannelManager from valentina.discord.utils.autocomplete import ( select_aws_object_from_guild, select_changelog_version_1, @@ -225,8 +226,10 @@ async def create_dummy_data(self, ctx: ValentinaContext) -> None: # noqa: C901 # Create discord channels and add campaigns to the guild guild = await Guild.get(ctx.guild.id, fetch_links=True) + + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) for campaign in created_campaigns: - await campaign.create_channels(ctx) + await channel_manager.confirm_campaign_channels(campaign) guild.campaigns.append(campaign) await guild.save() diff --git a/src/valentina/discord/cogs/storyteller.py b/src/valentina/discord/cogs/storyteller.py index 31deca3a..b16ae4d6 100644 --- a/src/valentina/discord/cogs/storyteller.py +++ b/src/valentina/discord/cogs/storyteller.py @@ -19,6 +19,7 @@ ) from valentina.discord.bot import Valentina, ValentinaContext from valentina.discord.characters import AddFromSheetWizard, RNGCharGen +from valentina.discord.models import ChannelManager from valentina.discord.utils.autocomplete import ( select_any_player_character, select_char_class, @@ -624,7 +625,8 @@ async def transfer_character( character.user_owner = new_owner.id await character.save() - await character.update_channel_permissions(ctx, campaign) + channel_manager = ChannelManager(guild=ctx.guild, user=ctx.author) + await channel_manager.confirm_character_channel(character=character, campaign=campaign) await interaction.edit_original_response(embed=confirmation_embed, view=None) diff --git a/src/valentina/discord/models/__init__.py b/src/valentina/discord/models/__init__.py new file mode 100644 index 00000000..7f461759 --- /dev/null +++ b/src/valentina/discord/models/__init__.py @@ -0,0 +1,6 @@ +"""Models for the Discord API.""" + +from .channel_mngr import ChannelManager +from .webui_hook import SyncDiscordFromWebManager + +__all__ = ["SyncDiscordFromWebManager", "ChannelManager"] diff --git a/src/valentina/discord/models/channel_mngr.py b/src/valentina/discord/models/channel_mngr.py new file mode 100644 index 00000000..e46b6fc1 --- /dev/null +++ b/src/valentina/discord/models/channel_mngr.py @@ -0,0 +1,513 @@ +"""Manage channels within a Guild.""" + +import asyncio +from typing import Optional + +import discord +from loguru import logger + +from valentina.constants import CHANNEL_PERMISSIONS, CampaignChannelName, ChannelPermission, Emoji +from valentina.models import Campaign, CampaignBook, Character + +from valentina.discord.utils.discord_utils import set_channel_perms # isort:skip + +CAMPAIGN_COMMON_CHANNELS = { # channel_db_key: channel_name + "channel_storyteller": CampaignChannelName.STORYTELLER.value, + "channel_general": CampaignChannelName.GENERAL.value, +} + + +class ChannelManager: + """Manage channels within a Guild.""" + + def __init__(self, guild: discord.Guild, user: discord.User | discord.Member): + self.guild = guild + self.user = user + + @staticmethod + def _channel_sort_order(channel: discord.TextChannel) -> tuple[int, str]: # pragma: no cover + """Generate a custom sorting key for campaign channels. + + Prioritize channels based on their names, assigning a numeric value for sorting order. + + Args: + channel (discord.TextChannel): The Discord text channel to generate the sort key for. + + Returns: + tuple[int, str]: A tuple containing the sort priority (int) and the channel name (str). + """ + if channel.name.startswith(Emoji.CHANNEL_GENERAL.value): + return (0, channel.name) + + if channel.name.startswith(Emoji.BOOK.value): + return (1, channel.name) + + if channel.name.startswith(Emoji.CHANNEL_PRIVATE.value): + return (2, channel.name) + + if channel.name.startswith(Emoji.CHANNEL_PLAYER.value): + return (3, channel.name) + + if channel.name.startswith(Emoji.CHANNEL_PLAYER_DEAD.value): + return (4, channel.name) + + return (5, channel.name) + + def _determine_channel_permissions( + self, channel_name: str + ) -> tuple[ChannelPermission, ChannelPermission, ChannelPermission]: + """Determine the permissions for the specified channel based on its name. + + Args: + channel_name (str): The name of the channel to determine permissions for. + + Returns: + tuple[ChannelPermission, ChannelPermission, ChannelPermission]: A tuple containing: + - The default role permissions (ChannelPermission) + - The player role permissions (ChannelPermission) + - The storyteller role permissions (ChannelPermission) + """ + if channel_name.startswith(Emoji.CHANNEL_PRIVATE.value): + return CHANNEL_PERMISSIONS["storyteller_channel"] + + if channel_name.startswith((Emoji.CHANNEL_PLAYER.value, Emoji.CHANNEL_PLAYER_DEAD.value)): + return CHANNEL_PERMISSIONS["campaign_character_channel"] + + return CHANNEL_PERMISSIONS["default"] + + async def confirm_channel_in_category( + self, + existing_category: discord.CategoryChannel, + existing_channels: list[discord.TextChannel], + channel_name: str, + channel_db_id: int | None = None, + owned_by_user: discord.User | discord.Member | None = None, + topic: str | None = None, + ) -> discord.TextChannel: + """Confirm the channel exists in the category. + + Confirm that the channel exists within the category. If the channel does not exist, create it. + + Args: + existing_category (discord.CategoryChannel): The category to check for the channel in. + existing_channels (list[discord.TextChannel]): The list of channels existing in the category. + channel_name (str): The name of the channel to check for. + channel_db_id (optional, int): The ID of the channel in the database. + owned_by_user (discord.User | discord.Member, optional): The user who owns the channel. Defaults to None. + topic (str, optional): The topic description for the channel. Defaults to None. + + Returns: + discord.TextChannel: The channel object. + """ + channel_name_is_in_category = any( + channel_name == channel.name for channel in existing_channels + ) + channel_db_id_is_in_category = ( + any(channel_db_id == channel.id for channel in existing_channels) + if channel_db_id + else False + ) + + # If the channel exists in the category, return it + if channel_name_is_in_category: + logger.info( + f"Channel {channel_name} exists in {existing_category} but not in database. Add channel id to database." + ) + await asyncio.sleep(1) # Keep the rate limit happy + preexisting_channel = next( + (channel for channel in existing_channels if channel.name == channel_name), + None, + ) + # update channel permissions + await asyncio.sleep(1) # Keep the rate limit happy + return await self.channel_update_or_add( + channel=preexisting_channel, + name=channel_name, + category=existing_category, + permissions=self._determine_channel_permissions(channel_name), + permissions_user_post=owned_by_user, + topic=topic, + ) + + # If the channel id exists but the name is different, rename the existing channel + if channel_db_id and channel_db_id_is_in_category and not channel_name_is_in_category: + existing_channel_object = next( + (channel for channel in existing_channels if channel_db_id == channel.id), None + ) + logger.info( + f"Channel {channel_name} exists in database and {existing_category} but name is different. Renamed channel." + ) + + await asyncio.sleep(1) # Keep the rate limit happy + return await self.channel_update_or_add( + channel=existing_channel_object, + name=channel_name, + category=existing_category, + permissions=self._determine_channel_permissions(channel_name), + permissions_user_post=owned_by_user, + topic=topic, + ) + + # Finally, if the channel does not exist in the category, create it + + await asyncio.sleep(1) # Keep the rate limit happy + logger.info( + f"Channel {channel_name} does not exist in {existing_category}. Create channel." + ) + await asyncio.sleep(1) # Keep the rate limit happy + return await self.channel_update_or_add( + name=channel_name, + category=existing_category, + permissions=self._determine_channel_permissions(channel_name), + permissions_user_post=owned_by_user, + topic=topic, + ) + + async def channel_update_or_add( + self, + permissions: tuple[ChannelPermission, ChannelPermission, ChannelPermission], + channel: discord.TextChannel | None = None, + name: str | None = None, + topic: str | None = None, + category: discord.CategoryChannel | None = None, + permissions_user_post: discord.User | discord.Member | None = None, + ) -> discord.TextChannel: # pragma: no cover + """Create or update a channel in the guild with specified permissions and attributes. + + Create a new text channel or update an existing one based on the provided name. Set permissions for default role, player role, and storyteller role. Automatically grant manage permissions to bot members. If specified, set posting permissions for a specific user. + + Args: + permissions (tuple[ChannelPermission, ChannelPermission, ChannelPermission]): Permissions for default role, player role, and storyteller role respectively. + channel (discord.TextChannel, optional): Existing channel to update. Defaults to None. + name (str, optional): Name for the channel. Defaults to None. + topic (str, optional): Topic description for the channel. Defaults to None. + category (discord.CategoryChannel, optional): Category to place the channel in. Defaults to None. + permissions_user_post (discord.User | discord.Member, optional): User to grant posting permissions. Defaults to None. + + Returns: + discord.TextChannel: The newly created or updated text channel. + """ + # Fetch roles from the guild + player_role = discord.utils.get(self.guild.roles, name="Player") + storyteller_role = discord.utils.get(self.guild.roles, name="Storyteller") + + # Initialize permission overwrites. Always grant manage permissions to bots. + overwrites = { # type: ignore[misc] + self.guild.default_role: set_channel_perms(permissions[0]), + player_role: set_channel_perms(permissions[1]), + storyteller_role: set_channel_perms(permissions[2]), + **{ + user: set_channel_perms(ChannelPermission.MANAGE) + for user in self.guild.members + if user.bot + }, + } + + if permissions_user_post: + overwrites[permissions_user_post] = set_channel_perms(ChannelPermission.POST) + + formatted_name = name.lower().strip().replace(" ", "-") if name else None + + if name and not channel: + for existing_channel in self.guild.text_channels: + # If channel already exists in a specified category, edit it + if ( + category + and existing_channel.category == category + and existing_channel.name == formatted_name + ) or (not category and existing_channel.name == formatted_name): + logger.debug(f"GUILD: Update channel '{channel.name}' on '{self.guild.name}'") + await existing_channel.edit( + name=formatted_name or channel.name, + overwrites=overwrites, + topic=topic or channel.topic, + category=category or channel.category, + ) + return existing_channel + + # Create the channel if it doesn't exist + logger.debug(f"GUILD: Create channel '{name}' on '{self.guild.name}'") + return await self.guild.create_text_channel( + name=formatted_name, + overwrites=overwrites, + topic=topic, + category=category, + ) + + # Update existing channel + logger.debug(f"GUILD: Update channel '{channel.name}' on '{self.guild.name}'") + await channel.edit( + name=name or channel.name, + overwrites=overwrites, + topic=topic or channel.topic, + category=category or channel.category, + ) + + return channel + + async def confirm_book_channel( + self, book: CampaignBook, campaign: Optional[Campaign] + ) -> discord.TextChannel | None: + """TKTK.""" + if not campaign: + campaign = await Campaign.get(book.campaign) + + category, channels = await self.fetch_campaign_category_channels(campaign=campaign) + + # If the campaign category channel does not exist, return None + if not category: + return None + + channel_db_id = book.channel + + channel = await self.confirm_channel_in_category( + existing_category=category, + existing_channels=channels, + channel_name=book.channel_name, + channel_db_id=channel_db_id, + topic=f"Channel for book {book.number}. {book.name}", + ) + await book.update_channel_id(channel) + await asyncio.sleep(1) # Keep the rate limit happy + return channel + + async def confirm_character_channel( + self, character: Character, campaign: Optional[Campaign] + ) -> discord.TextChannel | None: + """TKTK.""" + logger.debug(f"Confirming channel for character {character.name}") + + if not campaign: + return None + + category, channels = await self.fetch_campaign_category_channels(campaign=campaign) + + # If the campaign category channel does not exist, return None + if not category: + return None + + owned_by_user = discord.utils.get(self.guild.members, id=character.user_owner) + channel_name = character.channel_name + channel_db_id = character.channel + + channel = await self.confirm_channel_in_category( + existing_category=category, + existing_channels=channels, + channel_name=channel_name, + channel_db_id=channel_db_id, + owned_by_user=owned_by_user, + topic=f"Character channel for {character.name}", + ) + await character.update_channel_id(channel) + + await asyncio.sleep(1) # Keep the rate limit happy + return channel + + async def delete_book_channel(self, book: CampaignBook) -> None: + """Delete the channel associated with the book.""" + if not book.channel: + return + + channel = self.guild.get_channel(book.channel) + if channel: + await self.delete_channel(channel) + + book.channel = None + await book.save() + + async def delete_character_channel(self, character: Character) -> None: + """Delete the channel associated with the character.""" + if not character.channel: + return + + channel = self.guild.get_channel(character.channel) + if channel: + await self.delete_channel(channel) + + character.channel = None + await character.save() + + async def fetch_campaign_category_channels( + self, campaign: Campaign + ) -> tuple[discord.CategoryChannel, list[discord.TextChannel]]: + """Fetch the campaign's channels in the guild. + + Retrieve the category channel and its child text channels for the current campaign + from the Discord guild. + + Args: + campaign (Campaign): The campaign to fetch the channels for. + + Returns: + tuple[discord.CategoryChannel, list[discord.TextChannel]]: A tuple containing: + - The campaign category channel (discord.CategoryChannel or None if not found) + - A list of text channels within that category (empty list if category not found) + + """ + for category, channels in self.guild.by_category(): + if category and category.id == campaign.channel_campaign_category: + return category, [x for x in channels if isinstance(x, discord.TextChannel)] + + return None, [] + + async def sort_campaign_channels(self, campaign: Campaign) -> None: + """TKTK.""" + for category, channels in self.guild.by_category(): + if category and category.id == campaign.channel_campaign_category: + sorted_channels = sorted(channels, key=self._channel_sort_order) # type: ignore[arg-type] + for i, channel in enumerate(sorted_channels): + if channel.position and channel.position == i: + continue + await channel.edit(position=i) + await asyncio.sleep(2) # Keep the rate limit happy + + logger.debug(f"Sorted channels: {[channel.name for channel in sorted_channels]}") + break + + logger.info(f"Channels sorted for campaign '{campaign.name}' in '{self.guild.name}'") + + async def _confirm_campaign_common_channels( + self, + campaign: Campaign, + category: discord.CategoryChannel, + channels: list[discord.TextChannel], + ) -> None: + """TKTK.""" + for channel_db_key, channel_name in CAMPAIGN_COMMON_CHANNELS.items(): + await asyncio.sleep(1) # Keep the rate limit happy + channel_db_id = getattr(campaign, channel_db_key, None) + channel = await self.confirm_channel_in_category( + existing_category=category, + existing_channels=channels, + channel_name=channel_name, + channel_db_id=channel_db_id, + ) + + if not channel_db_id or channel_db_id != channel.id: + setattr(campaign, channel_db_key, channel.id) + await campaign.save() + + async def confirm_campaign_channels(self, campaign: Campaign) -> None: + """TKTK.""" + # Confirm the campaign category channel exists and is recorded in the database + campaign_category_channel_name = ( + f"{Emoji.BOOKS.value}-{campaign.name.lower().replace(' ', '-')}" + ) + if campaign.channel_campaign_category: + existing_campaign_channel_object = self.guild.get_channel( + campaign.channel_campaign_category + ) + + if not existing_campaign_channel_object: + category = await self.guild.create_category(campaign_category_channel_name) + campaign.channel_campaign_category = category.id + await campaign.save() + logger.debug( + f"Campaign category '{campaign_category_channel_name}' created in '{self.guild.name}'" + ) + + elif existing_campaign_channel_object.name != campaign_category_channel_name: + await existing_campaign_channel_object.edit(name=campaign_category_channel_name) + logger.debug( + f"Campaign category '{campaign_category_channel_name}' renamed in '{self.guild.name}'" + ) + + else: + logger.debug( + f"Category {campaign_category_channel_name} already exists in {self.guild.name}" + ) + else: + category = await self.guild.create_category(campaign_category_channel_name) + campaign.channel_campaign_category = category.id + await campaign.save() + logger.debug( + f"Campaign category '{campaign_category_channel_name}' created in '{self.guild.name}'" + ) + + category, channels = await self.fetch_campaign_category_channels(campaign=campaign) + + # Confirm common channels exist + await self._confirm_campaign_common_channels( + campaign=campaign, category=category, channels=channels + ) + + for book in await campaign.fetch_books(): + await self.confirm_book_channel(book=book, campaign=campaign) + await asyncio.sleep(1) + + for character in await campaign.fetch_characters(): + await self.confirm_character_channel(character=character, campaign=campaign) + await asyncio.sleep(1) + + # Remove any channels that should not exist + for channel in channels: + if channel.name.startswith(Emoji.BOOK.value) and not any( + book.channel == channel.id for book in await campaign.fetch_books() + ): + await self.delete_channel(channel) + await asyncio.sleep(1) + + if ( + channel.name.startswith(Emoji.CHANNEL_PLAYER.value) + or channel.name.startswith(Emoji.CHANNEL_PLAYER_DEAD.value) + ) and not any( + character.channel == channel.id for character in await campaign.fetch_characters() + ): + await self.delete_channel(channel) + await asyncio.sleep(1) + + if ( + channel.name.startswith(Emoji.CHANNEL_PRIVATE.value) + or channel.name.startswith(Emoji.CHANNEL_GENERAL.value) + and not ( + campaign.channel_storyteller != channel.id + or campaign.channel_general != channel.id + ) + ): + await self.delete_channel(channel) + await asyncio.sleep(1) + + await self.sort_campaign_channels(campaign) + + logger.info(f"All channels confirmed for campaign '{campaign.name}' in '{self.guild.name}'") + + async def delete_channel( + self, + channel: discord.TextChannel + | discord.CategoryChannel + | discord.VoiceChannel + | discord.ForumChannel + | discord.StageChannel + | int, + ) -> None: + """TKTK.""" + if isinstance(channel, int): + channel = self.guild.get_channel(channel) + + if not channel: + return + + logger.debug(f"GUILD: Delete channel '{channel.name}' on '{self.guild.name}'") + await channel.delete() + await asyncio.sleep(1) # Keep the rate limit happy + + async def delete_campaign_channels(self, campaign: Campaign) -> None: + """Delete all channels associated with the campaign.""" + for book in await campaign.fetch_books(): + await self.delete_book_channel(book) + + for character in await campaign.fetch_characters(): + await self.delete_character_channel(character) + + for channel_db_key in CAMPAIGN_COMMON_CHANNELS: + if getattr(campaign, channel_db_key, None): + await self.delete_channel(getattr(campaign, channel_db_key)) + setattr(campaign, channel_db_key, None) + await campaign.save() + await asyncio.sleep(1) # Keep the rate limit happy + + if campaign.channel_campaign_category: + await self.delete_channel(campaign.channel_campaign_category) + campaign.channel_campaign_category = None + await campaign.save() + await asyncio.sleep(1) diff --git a/src/valentina/discord/models/webui_hook.py b/src/valentina/discord/models/webui_hook.py new file mode 100644 index 00000000..1120efa3 --- /dev/null +++ b/src/valentina/discord/models/webui_hook.py @@ -0,0 +1,57 @@ +"""Process changes from the webui. + +The webui has no idea of a discord ctx object which is required for creating, renaming, and deleting channels or permissions on Discord. We need to poll the database for changes and process them. +""" + +from typing import TYPE_CHECKING + +import discord +from loguru import logger + +from valentina.constants import DBSyncModelType, DBSyncUpdateType +from valentina.models import Campaign, Character, WebDiscordSync + +from .channel_mngr import ChannelManager + +if TYPE_CHECKING: + from valentina.discord.bot import Valentina + + +class SyncDiscordFromWebManager: + """Manage syncing changes from the webui to Discord.""" + + def __init__(self, bot: "Valentina"): + self.bot = bot + + async def _process_character_change(self, sync: WebDiscordSync) -> None: + """Process a character change.""" + # Grab items from the database + guild = await discord.utils.get_or_fetch(self.bot, "guild", sync.guild_id) + member = discord.utils.get(guild.members, id=sync.user_id) + + # Process the change + channel_manager = ChannelManager(guild=guild, user=member) + + if sync.update_type in (DBSyncUpdateType.CREATE, DBSyncUpdateType.UPDATE): + character = await Character.get(sync.object_id) + campaign = await Campaign.get(character.campaign) + + await channel_manager.confirm_character_channel(character=character, campaign=campaign) + await channel_manager.sort_campaign_channels(campaign=campaign) + + elif sync.update_type == DBSyncUpdateType.DELETE: + async for campaign in Campaign.find(Campaign.guild == sync.guild_id): + await channel_manager.confirm_campaign_channels(campaign=campaign) + + async def run(self) -> None: + """Run the sync process.""" + # Process character changes + async for sync in WebDiscordSync.find( + WebDiscordSync.target == "discord", + WebDiscordSync.processed == False, # noqa: E712 + WebDiscordSync.object_type == DBSyncModelType("character"), + ): + await self._process_character_change(sync) + + await sync.mark_processed() + logger.info(f"Synced character change from webui: {sync.object_id}") diff --git a/src/valentina/models/__init__.py b/src/valentina/models/__init__.py index 1e8cfdfb..7b0aa55a 100644 --- a/src/valentina/models/__init__.py +++ b/src/valentina/models/__init__.py @@ -19,6 +19,7 @@ from .guild import Guild, GuildChannels, GuildPermissions, GuildRollResultThumbnail from .note import Note from .user import CampaignExperience, User, UserMacro +from .web_discord_sync import WebDiscordSync from .aws import AWSService # isort: skip from .statistics import Statistics, RollStatistic # isort: skip @@ -53,4 +54,5 @@ "Statistics", "User", "UserMacro", + "WebDiscordSync", ] diff --git a/src/valentina/models/campaign.py b/src/valentina/models/campaign.py index 1ea886d1..32ff8431 100644 --- a/src/valentina/models/campaign.py +++ b/src/valentina/models/campaign.py @@ -1,8 +1,7 @@ """Campaign models for Valentina.""" -import asyncio from datetime import datetime -from typing import TYPE_CHECKING, Optional +from typing import Optional import discord from beanie import ( @@ -16,18 +15,14 @@ Update, before_event, ) -from loguru import logger from pydantic import BaseModel, Field -from valentina.constants import CHANNEL_PERMISSIONS, CampaignChannelName, Emoji +from valentina.constants import Emoji from valentina.utils.helpers import time_now from .character import Character from .note import Note -if TYPE_CHECKING: - from valentina.discord.bot import ValentinaContext - class CampaignChapter(BaseModel): """Represents a chapter as a subdocument within Campaign. @@ -101,94 +96,15 @@ async def fetch_chapters(self) -> list[CampaignBookChapter]: key=lambda x: x.number, ) - async def delete_channel(self, ctx: "ValentinaContext") -> None: # pragma: no cover - """Delete the channel associated with the book. - - This method removes the channel linked to the book from the guild and updates the book's channel information. + async def update_channel_id(self, channel: discord.TextChannel) -> None: + """Update the book's channel ID in the database. Args: - ctx (ValentinaContext): The context object containing guild information. - - Returns: - None + channel (discord.TextChannel): The book's channel. """ - if not self.channel: - return - - channel = ctx.guild.get_channel(self.channel) - - if not channel: - return - - await channel.delete() - self.channel = None - await self.save() - - async def confirm_channel( - self, ctx: "ValentinaContext", campaign: Optional["Campaign"] - ) -> discord.TextChannel | None: - """Confirm or create the channel for the book within the campaign. - - This method ensures the book's channel exists within the campaign's category. It updates the channel information in the database if necessary, renames a channel if it has the wrong name, or creates a new one if it doesn't exist. - - Args: - ctx (ValentinaContext): The context object containing guild information. - campaign (Optional[Campaign]): The campaign object. If not provided, it will be fetched using the book's campaign ID. - - Returns: - discord.TextChannel | None: The channel object if found or created, otherwise None. - """ - campaign = campaign or await Campaign.get(self.campaign) - if not campaign: - return None - - category, channels = await campaign.fetch_campaign_category_channels(ctx) - - if not category: - return None - - is_channel_name_in_category = any(self.channel_name == channel.name for channel in channels) - is_channel_id_in_category = ( - any(self.channel == channel.id for channel in channels) if self.channel else False - ) - - # If channel name exists in category but not in database, add channel id to self - if is_channel_name_in_category and not self.channel: - await asyncio.sleep(1) # Keep the rate limit happy - for channel in channels: - if channel.name == self.channel_name: - self.channel = channel.id - await self.save() - return channel - - # If channel.id exists but has wrong name, rename it - elif self.channel and is_channel_id_in_category and not is_channel_name_in_category: - channel_object = next( - (channel for channel in channels if self.channel == channel.id), None - ) - return await ctx.channel_update_or_add( - channel=channel_object, - name=self.channel_name, - category=category, - permissions=CHANNEL_PERMISSIONS["default"], - topic=f"Channel for book {self.number}. {self.name}", - ) - - # If channel does not exist, create it - elif not is_channel_name_in_category: - await asyncio.sleep(1) # Keep the rate limit happy - book_channel = await ctx.channel_update_or_add( - name=self.channel_name, - category=category, - permissions=CHANNEL_PERMISSIONS["default"], - topic=f"Channel for Chapter {self.number}. {self.name}", - ) - self.channel = book_channel.id + if not self.channel or self.channel != channel.id: + self.channel = channel.id await self.save() - return book_channel - - await asyncio.sleep(1) # Keep the rate limit happy - return discord.utils.get(channels, name=self.channel_name) class Campaign(Document): @@ -215,236 +131,6 @@ async def update_modified_date(self) -> None: """Update the date_modified field.""" self.date_modified = time_now() - async def _confirm_common_channels( - self, - ctx: "ValentinaContext", - category: discord.CategoryChannel, - channels: list[discord.TextChannel], - ) -> None: # pragma: no cover - """Create or update common campaign channels in the guild. - - This method ensures that the common channels (e.g., storyteller and general channels) - are created or updated as necessary in the specified category. It checks existing - channels, updates database references, and creates new channels if necessary. - - Args: - ctx (ValentinaContext): The context of the command invocation. - category (discord.CategoryChannel): The category where common channels are managed. - channels (list[discord.TextChannel]): The list of existing text channels in the category. - - Returns: - None: This function does not return a value. - """ - # Static channels - common_channel_list = { # channel_db_key: channel_name - "channel_storyteller": CampaignChannelName.STORYTELLER.value, - "channel_general": CampaignChannelName.GENERAL.value, - } - for channel_db_key, channel_name in common_channel_list.items(): - # Set permissions - if channel_name.startswith(Emoji.LOCK.value): - permissions = CHANNEL_PERMISSIONS["storyteller_channel"] - else: - permissions = CHANNEL_PERMISSIONS["default"] - - channel_db_id = getattr(self, channel_db_key, None) - - channel_name_in_category = any(channel_name == channel.name for channel in channels) - channel_id_in_category = ( - any(channel_db_id == channel.id for channel in channels) if channel_db_id else False - ) - - if channel_name_in_category and not channel_db_id: - await asyncio.sleep(1) # Keep the rate limit happy - for channel in channels: - if channel.name == channel_name: - setattr(self, channel_db_key, channel.id) - await self.save() - - logger.info( - f"Channel {channel_name} exists in {category} but not in database. Add channel id to database." - ) - - elif channel_db_id and channel_id_in_category and not channel_name_in_category: - channel_object = next( - (channel for channel in channels if channel_db_id == channel.id), None - ) - await asyncio.sleep(1) # Keep the rate limit happy - await ctx.channel_update_or_add( - channel=channel_object, - name=channel_name, - category=category, - permissions=permissions, - ) - - logger.info( - f"Channel {channel_name} exists in database and {category} but name is different. Renamed channel." - ) - - elif not channel_name_in_category: - await asyncio.sleep(1) # Keep the rate limit happy - created_channel = await ctx.channel_update_or_add( - name=channel_name, - category=category, - permissions=permissions, - ) - setattr(self, channel_db_key, created_channel.id) - await self.save() - logger.info( - f"Channel {channel_name} does not exist in {category}. Create new channel and add to database" - ) - - async def fetch_campaign_category_channels( - self, ctx: "ValentinaContext" - ) -> tuple[discord.CategoryChannel, list[discord.TextChannel]]: - """Fetch the campaign category channels in the guild. - - Retrieve the category channel and its associated text channels for the current campaign - from the guild. Iterate through all categories in the guild to find the one matching - the campaign's category ID. - - Args: - ctx (ValentinaContext): The context object containing guild information. - - Returns: - tuple[discord.CategoryChannel, list[discord.TextChannel]]: A tuple containing: - - The campaign category channel (discord.CategoryChannel or None if not found) - - A list of text channels within that category (empty list if category not found) - - """ - for category, channels in ctx.guild.by_category(): - if category and category.id == self.channel_campaign_category: - return category, channels - - return None, [] - - @staticmethod - def _custom_channel_sort(channel: discord.TextChannel) -> tuple[int, str]: # pragma: no cover - """Generate a custom sorting key for campaign channels. - - Prioritize channels based on their names, assigning a numeric value - for sorting order. - - Args: - channel (discord.TextChannel): The Discord text channel to generate the sort key for. - - Returns: - tuple[int, str]: A tuple containing the sort priority (int) and the channel name (str). - """ - if channel.name.startswith(Emoji.SPARKLES.value): - return (0, channel.name) - - if channel.name.startswith(Emoji.BOOK.value): - return (1, channel.name) - - if channel.name.startswith(Emoji.LOCK.value): - return (2, channel.name) - - if channel.name.startswith(Emoji.SILHOUETTE.value): - return (3, channel.name) - - if channel.name.startswith(Emoji.DEAD.value): - return (4, channel.name) - - return (5, channel.name) - - async def create_channels(self, ctx: "ValentinaContext") -> None: # pragma: no cover - """Create and organize campaign channels in the guild. - - Create a campaign category if it doesn't exist, or rename it if necessary. - Ensure all required channels (books, characters, etc.) are created and properly named. - Respect Discord rate limits during channel creation and modification. - - Args: - ctx (ValentinaContext): The context object containing guild information. - - Returns: - None - """ - category_name = f"{Emoji.BOOKS.value}-{self.name.lower().replace(' ', '-')}" - - if self.channel_campaign_category: - channel_object = ctx.guild.get_channel(self.channel_campaign_category) - - if not channel_object: - category = await ctx.guild.create_category(category_name) - self.channel_campaign_category = category.id - logger.debug(f"Campaign category '{category_name}' created in '{ctx.guild.name}'") - await self.save() - - elif channel_object.name != category_name: - await channel_object.edit(name=category_name) - logger.debug(f"Campaign category '{category_name}' renamed in '{ctx.guild.name}'") - - else: - logger.debug(f"Category {category_name} already exists in {ctx.guild.name}") - else: - category = await ctx.guild.create_category(category_name) - self.channel_campaign_category = category.id - await self.save() - logger.debug(f"Campaign category '{category_name}' created in '{ctx.guild.name}'") - - # Create the channels - for category, channels in ctx.guild.by_category(): - if category and category.id == self.channel_campaign_category: - await self._confirm_common_channels(ctx, category=category, channels=channels) - await asyncio.sleep(1) # Keep the rate limit happy - - for book in await self.fetch_books(): - await book.confirm_channel(ctx, campaign=self) - await asyncio.sleep(1) # Keep the rate limit happy - - for character in await self.fetch_characters(): - await character.confirm_channel(ctx, campaign=self) - await asyncio.sleep(1) # Keep the rate limit happy - break - - await self.sort_channels(ctx) - - logger.info(f"All channels confirmed for campaign '{self.name}' in '{ctx.guild.name}'") - - async def delete_channels(self, ctx: "ValentinaContext") -> None: # pragma: no cover - """Delete all channels associated with the campaign. - - Remove book channels, character channels, storyteller channel, general channel, - and the campaign category channel. Update the campaign object to reflect the - deleted channels. - - Args: - ctx (ValentinaContext): The context object containing guild information. - - Returns: - None - """ - for book in await self.fetch_books(): - await book.delete_channel(ctx) - - for character in await self.fetch_characters(): - await character.delete_channel(ctx) - - if self.channel_storyteller: - channel = ctx.guild.get_channel(self.channel_storyteller) - - if channel: - await channel.delete() - self.channel_storyteller = None - - if self.channel_general: - channel = ctx.guild.get_channel(self.channel_general) - - if channel: - await channel.delete() - self.channel_general = None - - if self.channel_campaign_category: - category = ctx.guild.get_channel(self.channel_campaign_category) - - if category: - await category.delete() - self.channel_campaign_category = None - - await self.save() - async def fetch_characters(self) -> list[Character]: """Fetch all player characters in the campaign. @@ -471,28 +157,3 @@ async def fetch_books(self) -> list[CampaignBook]: self.books, # type: ignore [arg-type] key=lambda x: x.number, ) - - async def sort_channels(self, ctx: "ValentinaContext") -> None: - """Sort the campaign channels in the guild. - - This method sorts the campaign channels within their category according to a custom sorting key. - - Args: - ctx (ValentinaContext): The context object containing guild information. - - Returns: - None - """ - for category, channels in ctx.guild.by_category(): - if category and category.id == self.channel_campaign_category: - sorted_channels = sorted(channels, key=self._custom_channel_sort) - for i, channel in enumerate(sorted_channels): - if channel.position and channel.position == i: - continue - await channel.edit(position=i) - await asyncio.sleep(2) # Keep the rate limit happy - - logger.debug(f"Sorted channels: {[channel.name for channel in sorted_channels]}") - break - - logger.info(f"Channels sorted for campaign '{self.name}' in '{ctx.guild.name}'") diff --git a/src/valentina/models/character.py b/src/valentina/models/character.py index ee7076a1..1bc143ac 100644 --- a/src/valentina/models/character.py +++ b/src/valentina/models/character.py @@ -1,6 +1,5 @@ """Character models for Valentina.""" -import asyncio from datetime import datetime from typing import TYPE_CHECKING, Optional, Union, cast @@ -20,7 +19,6 @@ from pydantic import BaseModel, Field from valentina.constants import ( - CHANNEL_PERMISSIONS, CharacterConcept, CharClass, Emoji, @@ -35,8 +33,7 @@ from .note import Note if TYPE_CHECKING: - from valentina.discord.bot import ValentinaContext - from valentina.models import Campaign # noqa: TCH004 + from valentina.models import Campaign class CharacterSheetSection(BaseModel): @@ -185,7 +182,7 @@ def full_name(self) -> str: @property def channel_name(self) -> str: """Channel name for the book.""" - emoji = Emoji.SILHOUETTE.value if self.is_alive else Emoji.DEAD.value + emoji = Emoji.CHANNEL_PLAYER.value if self.is_alive else Emoji.CHANNEL_PLAYER_DEAD.value return f"{emoji}-{self.name.lower().replace(' ', '-')}" @@ -316,9 +313,7 @@ async def add_trait( return new_trait - async def associate_with_campaign( # pragma: no cover - self, ctx: "ValentinaContext", new_campaign: "Campaign" - ) -> bool: + async def associate_with_campaign(self, new_campaign: "Campaign") -> bool: """Associate a character with a campaign. Associate the character with the specified campaign, update the database, @@ -334,9 +329,6 @@ async def associate_with_campaign( # pragma: no cover bool: True if the character was successfully associated with the new campaign, False if the character was already associated with the campaign. - - Raises: - None, but may propagate exceptions from called methods. """ if self.campaign == str(new_campaign.id): logger.debug(f"Character {self.name} is already associated with {new_campaign.name}") @@ -345,115 +337,8 @@ async def associate_with_campaign( # pragma: no cover self.campaign = str(new_campaign.id) await self.save() - await self.confirm_channel(ctx, new_campaign) - await new_campaign.sort_channels(ctx) return True - async def confirm_channel( - self, ctx: "ValentinaContext", campaign: Optional["Campaign"] - ) -> discord.TextChannel | None: - """Confirm or create the character's channel within the campaign. - - Ensure the character's channel exists within the campaign's category. Update the channel - information in the database if necessary. Rename the channel if it has the wrong name. - Create a new channel if it doesn't exist. - - Follow these steps: - 1. Fetch the campaign if not provided. - 2. Retrieve the campaign's category and channels. - 3. Check if the channel name or ID exists in the category. - 4. Update the database with the channel ID if the name exists but ID is missing. - 5. Rename the channel if it exists with the wrong name. - 6. Create a new channel if it doesn't exist. - - Args: - ctx (ValentinaContext): The context object containing guild information and bot instance. - campaign (Optional[Campaign]): The campaign object. If not provided, fetch it using the character's campaign ID. - - Returns: - discord.TextChannel | None: The channel object if found or created, None if the campaign category doesn't exist. - """ - campaign = campaign or await Campaign.get(self.campaign) - if not campaign: - return None - - category, channels = await campaign.fetch_campaign_category_channels(ctx) - - if not category: - return None - - is_channel_name_in_category = any(self.channel_name == channel.name for channel in channels) - is_channel_id_in_category = ( - any(self.channel == channel.id for channel in channels) if self.channel else False - ) - owned_by_user = discord.utils.get(ctx.bot.users, id=self.user_owner) - - # If channel name exists in category but not in database, add channel id to self - if is_channel_name_in_category and not self.channel: - await asyncio.sleep(1) # Keep the rate limit happy - for channel in channels: - if channel.name == self.channel_name: - self.channel = channel.id - await self.save() - return channel - - # If channel.id exists but has wrong name, rename it - elif self.channel and is_channel_id_in_category and not is_channel_name_in_category: - channel_object = next( - (channel for channel in channels if self.channel == channel.id), None - ) - return await ctx.channel_update_or_add( - channel=channel_object, - name=self.channel_name, - category=category, - permissions=CHANNEL_PERMISSIONS["campaign_character_channel"], - permissions_user_post=owned_by_user, - topic=f"Character channel for {self.name}", - ) - - # If channel does not exist, create it - elif not is_channel_name_in_category: - await asyncio.sleep(1) # Keep the rate limit happy - book_channel = await ctx.channel_update_or_add( - name=self.channel_name, - category=category, - permissions=CHANNEL_PERMISSIONS["campaign_character_channel"], - permissions_user_post=owned_by_user, - topic=f"Character channel for {self.name}", - ) - self.channel = book_channel.id - await self.save() - return book_channel - - await asyncio.sleep(1) # Keep the rate limit happy - return discord.utils.get(channels, name=self.channel_name) - - async def delete_channel(self, ctx: "ValentinaContext") -> None: # pragma: no cover - """Delete the channel associated with the character and update the character's information. - - Remove the Discord channel linked to this character from the guild. - Update the character's channel information to reflect the deletion. - If no channel is associated or the channel doesn't exist, do nothing. - - Args: - ctx (ValentinaContext): The context object containing guild information - and other relevant data for the operation. - - Returns: - None - """ - if not self.channel: - return - - channel = ctx.guild.get_channel(self.channel) - - if not channel: - return - - await channel.delete() - self.channel = None - await self.save() - async def delete_image(self, key: str) -> None: # pragma: no cover """Delete a character's image from both the character data and Amazon S3. @@ -487,48 +372,6 @@ async def fetch_trait_by_name(self, name: str) -> Union["CharacterTrait", None]: return None - async def update_channel_permissions( - self, ctx: "ValentinaContext", campaign: "Campaign" - ) -> discord.TextChannel | None: # pragma: no cover - """Update the permissions and settings for the character's Discord channel. - - Update the permissions, name, category, and topic for a character's Discord channel. - This method should be called after updating the character's user_owner. - - Perform the following actions: - 1. Retrieve the channel using the stored channel ID. - 2. Generate a new channel name based on the character's name. - 3. Fetch the user object for the character's owner. - 4. Locate the appropriate category for the campaign. - 5. Update the channel's name, category, permissions, and topic. - - Args: - ctx (ValentinaContext): The context object containing guild and bot information. - campaign (Campaign): The campaign object to which the character belongs. - - Returns: - discord.TextChannel | None: The updated channel object if successful, or None if the channel does not exist. - """ - if not self.channel: - return None - - channel = ctx.guild.get_channel(self.channel) - channel_name = f"{Emoji.SILHOUETTE.value}-{self.name.lower().replace(' ', '-')}" - owned_by_user = discord.utils.get(ctx.bot.users, id=self.user_owner) - category = discord.utils.get(ctx.guild.categories, id=campaign.channel_campaign_category) - - if not channel: - return None - - return await ctx.channel_update_or_add( - channel=channel, - name=channel_name, - category=category, - permissions=CHANNEL_PERMISSIONS["campaign_character_channel"], - permissions_user_post=owned_by_user, - topic=f"Character channel for {self.name}", - ) - def sheet_section_top_items(self) -> dict[str, str]: """Generate a dictionary of key attributes for the top section of a character sheet. @@ -617,3 +460,13 @@ def sheet_section_top_items(self) -> dict[str, str]: for name, value in attributes if value and value != "None" } + + async def update_channel_id(self, channel: discord.TextChannel) -> None: + """Update the character's channel ID in the database. + + Args: + channel (discord.TextChannel): The character's channel. + """ + if self.channel != channel.id: + self.channel = channel.id + await self.save() diff --git a/src/valentina/models/web_discord_sync.py b/src/valentina/models/web_discord_sync.py new file mode 100644 index 00000000..d9975f9a --- /dev/null +++ b/src/valentina/models/web_discord_sync.py @@ -0,0 +1,55 @@ +"""Database model to store objects which need to be synced between the webui and Discord. + +Discord has no concept of a session object and the webui has no concept of a discord ctx object. This model stores objects which need to be synced between the two. +""" + +from datetime import datetime +from typing import Literal + +from beanie import Document +from pydantic import Field, field_validator + +from valentina.constants import DBSyncModelType, DBSyncUpdateType +from valentina.utils.helpers import time_now + + +class WebDiscordSync(Document): + """Model to store objects which need to be synced between the webui and Discord. + + Attributes: + date_created (datetime): The date the object was created. + object_id (str): The ID of the object to sync. + object_type (DBSyncModelType): The type of object to sync. + update_type (DBSyncUpdateType): The type of update to perform. + target (Literal["web", "discord"]): The target to sync the object to. + date_processed (datetime | None): The date the object was processed. + processed (bool): Whether the object has been processed. + """ + + date_created: datetime = Field(default_factory=time_now) + date_processed: datetime | None = None + guild_id: int + object_id: str + object_type: DBSyncModelType + processed: bool = False + target: Literal["web", "discord"] + update_type: DBSyncUpdateType + user_id: int + + @field_validator("guild_id") + @classmethod + def guild_id_to_int(cls, v: str | int) -> int: + """Validate that the guiid id is an integer.""" + return int(v) + + @field_validator("user_id") + @classmethod + def user_id_to_int(cls, v: str | int) -> int: + """Validate that the user id is an integer.""" + return int(v) + + async def mark_processed(self) -> None: + """Mark the object as processed.""" + self.processed = True + self.date_processed = time_now() + await self.save() diff --git a/src/valentina/utils/database.py b/src/valentina/utils/database.py index 00f34f6d..faf615a6 100644 --- a/src/valentina/utils/database.py +++ b/src/valentina/utils/database.py @@ -18,6 +18,7 @@ RollProbability, RollStatistic, User, + WebDiscordSync, ) from valentina.utils import ValentinaConfig @@ -79,6 +80,7 @@ async def init_database(client=None, database=None) -> None: # type: ignore [no RollProbability, RollStatistic, User, + WebDiscordSync, ], ) diff --git a/src/valentina/webui/blueprints/character_view/forms.py b/src/valentina/webui/blueprints/character_view/forms.py index 2d62eb6e..d48e4596 100644 --- a/src/valentina/webui/blueprints/character_view/forms.py +++ b/src/valentina/webui/blueprints/character_view/forms.py @@ -6,7 +6,6 @@ from wtforms import DateField, HiddenField, StringField, SubmitField, ValidationError from wtforms.validators import DataRequired, Length, Optional -from valentina.utils import console from valentina.webui.utils.forms import validate_unique_character_name @@ -18,7 +17,6 @@ class EmptyForm(QuartForm): async def async_validators_name_last(self, name_last: StringField) -> None: """Check if the first + lastname are unique in the database.""" - console.log(f"{self.character_id.data=}") if not await validate_unique_character_name( name_first=self.name_first.data, name_last=name_last.data, @@ -29,7 +27,6 @@ async def async_validators_name_last(self, name_last: StringField) -> None: async def async_validators_name_first(self, name_first: StringField) -> None: """Check if the first + lastname are unique in the database.""" - console.log(f"{self.character_id.data=}") if not await validate_unique_character_name( name_first=name_first.data, name_last=self.name_last.data, diff --git a/src/valentina/webui/blueprints/character_view/route.py b/src/valentina/webui/blueprints/character_view/route.py index 410ef2aa..38cf8214 100644 --- a/src/valentina/webui/blueprints/character_view/route.py +++ b/src/valentina/webui/blueprints/character_view/route.py @@ -3,15 +3,26 @@ from typing import ClassVar from flask_discord import requires_authorization -from loguru import logger -from markupsafe import escape -from quart import abort, render_template, request, session, url_for +from quart import abort, request, session, url_for from quart.views import MethodView from quart_wtf import QuartForm from werkzeug.wrappers.response import Response -from valentina.constants import CharSheetSection, InventoryItemType, TraitCategory -from valentina.models import AWSService, Character, CharacterTrait, InventoryItem, Statistics +from valentina.constants import ( + CharSheetSection, + DBSyncModelType, + DBSyncUpdateType, + InventoryItemType, + TraitCategory, +) +from valentina.models import ( + AWSService, + Character, + CharacterTrait, + InventoryItem, + Statistics, + WebDiscordSync, +) from valentina.webui import catalog from valentina.webui.utils.discord import post_to_audit_log from valentina.webui.utils.helpers import update_session @@ -116,35 +127,6 @@ async def get_character_inventory(self, character: Character) -> dict: # remove all empty dictionary entries return {k: v for k, v in inventory.items() if v} - async def process_form_data(self, character: Character) -> None: - """Process form data and update character attributes accordingly. - - Iterate over the form data, updating the character's attributes if they exist - and the value is not "None". Escape any non-empty values to prevent injection. - Log a warning if an attribute in the form does not exist on the character object. - - Args: - character (Character): The character object to be updated with the form data. - - Returns: - None - """ - form = await request.form - - # Iterate over all form fields and update character attributes if they exist and are not "None" - for key, value in form.items(): - if hasattr(character, key): - v = value if value != "" else None - if getattr(character, key) != v: - setattr(character, key, escape(v) if v else None) - - if not hasattr(character, key): - logger.warning(f"Character attribute {key} not found.") - - # TODO: Implement channel renaming - - await character.save() - async def get_character_image_urls(self, character: Character) -> list[str]: """Retrieve and return a list of image URLs for the specified character. @@ -225,25 +207,6 @@ async def get(self, character_id: str = "") -> str: success_msg=success_msg, ) - async def post(self, character_id: str = "") -> str: - """Handle POST requests.""" - character = await self.get_character_object(character_id) - traits = await self.get_character_sheet_traits(character) - inventory = await self.get_character_inventory(character) - stats_engine = Statistics(guild_id=session["GUILD_ID"]) - statistics = await stats_engine.character_statistics(character, as_json=True) - - await self.process_form_data(character) - - return await render_template( - "character.html", - character=character, - traits=traits, - inventory_item_types=inventory, - args=request.args, - statistics=statistics, - ) - class CharacterEdit(MethodView): """View to handle character field edits. Serves individual field forms for editing character attributes.""" @@ -338,6 +301,16 @@ async def post(self, character_id: str) -> str | Response: setattr(character, key, form_data[key]) if has_updates: + sync_object = WebDiscordSync( + object_id=str(character.id), + object_type=DBSyncModelType.CHARACTER, + update_type=DBSyncUpdateType.UPDATE, + target="discord", + guild_id=str(session["GUILD_ID"]), + user_id=str(session["DISCORD_USER_ID"]), + ) + await sync_object.save() + await character.save() await post_to_audit_log( msg=f"Character {character.name} edited", diff --git a/src/valentina/webui/blueprints/character_view/templates/character_view/Main.jinja b/src/valentina/webui/blueprints/character_view/templates/character_view/Main.jinja index 5f6402e6..4c4f8401 100644 --- a/src/valentina/webui/blueprints/character_view/templates/character_view/Main.jinja +++ b/src/valentina/webui/blueprints/character_view/templates/character_view/Main.jinja @@ -7,7 +7,11 @@ {% if success_msg %}{% endif %} {% set link = url_for('character_view.edit', character_id=character.id) %}
- {{ character.full_name }} + {{ character.full_name }} + {# + add a second button with right-button-two + right-button-two="true" right-button-two-url="{{ link }}" right-button-two-text="New button" + #}
{% include "character_view/tabs.html" %}
diff --git a/uv.lock b/uv.lock index b5b210dd..1e05e187 100644 --- a/uv.lock +++ b/uv.lock @@ -1766,11 +1766,11 @@ wheels = [ [[package]] name = "termcolor" -version = "2.4.0" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/56/d7d66a84f96d804155f6ff2873d065368b25a07222a6fd51c4f24ef6d764/termcolor-2.4.0.tar.gz", hash = "sha256:aab9e56047c8ac41ed798fa36d892a37aca6b3e9159f3e0c24bc64a9b3ac7b7a", size = 12664 } +sdist = { url = "https://files.pythonhosted.org/packages/37/72/88311445fd44c455c7d553e61f95412cf89054308a1aa2434ab835075fc5/termcolor-2.5.0.tar.gz", hash = "sha256:998d8d27da6d48442e8e1f016119076b690d962507531df4890fcd2db2ef8a6f", size = 13057 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/5f/8c716e47b3a50cbd7c146f45881e11d9414def768b7cd9c5e6650ec2a80a/termcolor-2.4.0-py3-none-any.whl", hash = "sha256:9297c0df9c99445c2412e832e882a7884038a25617c60cea2ad69488d4040d63", size = 7719 }, + { url = "https://files.pythonhosted.org/packages/7f/be/df630c387a0a054815d60be6a97eb4e8f17385d5d6fe660e1c02750062b4/termcolor-2.5.0-py3-none-any.whl", hash = "sha256:37b17b5fc1e604945c2642c872a3764b5d547a48009871aea3edd3afa180afb8", size = 7755 }, ] [[package]] From 7e47ca907113703864c4338c890bf90cce50c204 Mon Sep 17 00:00:00 2001 From: Nathaniel Landau Date: Mon, 7 Oct 2024 10:55:15 -0400 Subject: [PATCH 2/2] fix: update settings to use ChannelManager --- README.md | 7 + docs/discord.md | 29 ++ docs/webui.md | 3 + src/valentina/discord/bot.py | 39 +- src/valentina/discord/cogs/developer.py | 40 ++- src/valentina/discord/models/channel_mngr.py | 332 +++++++++++------- src/valentina/discord/views/settings.py | 9 +- .../webui/blueprints/character_view/route.py | 4 +- 8 files changed, 286 insertions(+), 177 deletions(-) create mode 100644 docs/discord.md create mode 100644 docs/webui.md diff --git a/README.md b/README.md index 685b17c2..fd06061a 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,13 @@ This project uses [uv](https://docs.astral.sh/uv/) to manage Python requirements - Run `uv add {package}` from within the development environment to install a run time dependency and add it to `pyproject.toml` and `uv.lock`. - Run `uv remove {package}` from within the development environment to uninstall a run time dependency and remove it from `pyproject.toml` and `uv.lock`. +## Common Patterns + +Documentation for common patterns used in development are available here: + +- [Developing the Discord bot](docs/discord.md) +- [Developing the WebUI](docs/webui.md) + ## Third Party Package Documentation Many Python packages are used in this project. Here are some of the most important ones with links to their documentation: diff --git a/docs/discord.md b/docs/discord.md new file mode 100644 index 00000000..e91c9604 --- /dev/null +++ b/docs/discord.md @@ -0,0 +1,29 @@ +# Development Notes for Discord + +Code snippets and notes to help you develop the Discord bot with pycord. + +## Cogs + +### Confirming actions + +Use this pattern in any cog to ask for user confirmation before performing an action. + +- `hidden` makes the confirmation message ephemeral and viewable only by the author. +- `audit` logs the confirmation in the audit log. + +```python +from valentina.discord.views import confirm_action + +@something.command() +async def do_something(self, ctx: ValentinaContext) -> None: + + is_confirmed, interaction, confirmation_embed = await confirm_action( + ctx, title="Do something", hidden=True, audit=True + ) + if not is_confirmed: + return + + # Do something ... + + await interaction.edit_original_response(embed=confirmation_embed, view=None) +``` diff --git a/docs/webui.md b/docs/webui.md new file mode 100644 index 00000000..54748e68 --- /dev/null +++ b/docs/webui.md @@ -0,0 +1,3 @@ +# Development Notes for the Web UI + +Code snippets and notes to help you develop the Web UI. diff --git a/src/valentina/discord/bot.py b/src/valentina/discord/bot.py index 11bf1483..da44dca7 100644 --- a/src/valentina/discord/bot.py +++ b/src/valentina/discord/bot.py @@ -16,7 +16,6 @@ from valentina.constants import ( COGS_PATH, - ChannelPermission, EmbedColor, LogLevel, PermissionManageCampaign, @@ -24,7 +23,7 @@ PermissionsKillCharacter, PermissionsManageTraits, ) -from valentina.discord.models import ChannelManager, SyncDiscordFromWebManager +from valentina.discord.models import SyncDiscordFromWebManager from valentina.models import ( Campaign, ChangelogPoster, @@ -366,40 +365,6 @@ async def can_manage_campaign(self) -> bool: return True - async def channel_update_or_add( - self, - permissions: tuple[ChannelPermission, ChannelPermission, ChannelPermission], - channel: discord.TextChannel | None = None, - name: str | None = None, - topic: str | None = None, - category: discord.CategoryChannel | None = None, - permissions_user_post: discord.User | None = None, - ) -> discord.TextChannel: # pragma: no cover - """Create or update a channel in the guild with specified permissions and attributes. - - Create a new text channel or update an existing one based on the provided name. Set permissions for default role, player role, and storyteller role. Automatically grant manage permissions to bot members. If specified, set posting permissions for a specific user. - - Args: - permissions (tuple[ChannelPermission, ChannelPermission, ChannelPermission]): Permissions for default role, player role, and storyteller role respectively. - channel (discord.TextChannel, optional): Existing channel to update. Defaults to None. - name (str, optional): Name for the channel. Defaults to None. - topic (str, optional): Topic description for the channel. Defaults to None. - category (discord.CategoryChannel, optional): Category to place the channel in. Defaults to None. - permissions_user_post (discord.User, optional): User to grant posting permissions. Defaults to None. - - Returns: - discord.TextChannel: The newly created or updated text channel. - """ - channel_manager = ChannelManager(guild=self.guild, user=self.author) - return await channel_manager.channel_update_or_add( - permissions=permissions, - channel=channel, - name=name, - topic=topic, - category=category, - permissions_user_post=permissions_user_post, - ) - class Valentina(commands.Bot): """Extend the discord.Bot class to create a custom bot implementation. @@ -642,7 +607,7 @@ async def get_application_context( # type: ignore """ return await super().get_application_context(interaction, cls=cls) - @tasks.loop(minutes=2) + @tasks.loop(minutes=5) async def sync_from_web(self) -> None: """Sync objects from the webui to Discord.""" logger.debug("SYNC: Running sync_from_web task") diff --git a/src/valentina/discord/cogs/developer.py b/src/valentina/discord/cogs/developer.py index 836eb923..675a5cf1 100644 --- a/src/valentina/discord/cogs/developer.py +++ b/src/valentina/discord/cogs/developer.py @@ -45,6 +45,7 @@ InventoryItem, RollProbability, User, + WebDiscordSync, ) from valentina.utils import ValentinaConfig, instantiate_logger @@ -58,8 +59,6 @@ def __init__(self, bot: Valentina) -> None: self.bot: Valentina = bot self.aws_svc = AWSService() - ### BOT ADMINISTRATION COMMANDS ################################################################ - developer = discord.SlashCommandGroup( "developer", "Valentina developer commands. Beware, these can be destructive.", @@ -90,6 +89,43 @@ def __init__(self, bot: Valentina) -> None: "View bot statistics", default_member_permissions=discord.Permissions(administrator=True), ) + maintenance = developer.create_subgroup( + "maintenance", + "maintenance commands", + default_member_permissions=discord.Permissions(administrator=True), + ) + + ## MAINTENANCE COMMANDS ################################################################ + @maintenance.command( + name="clear_sync_cache", description="Clear processed web/discord sync data from the DB" + ) + @commands.is_owner() + async def clear_sync_cache( + self, + ctx: ValentinaContext, + hidden: Option( + bool, + description="Make the interaction only visible to you (default true).", + default=True, + ), + ) -> None: + """Clears processed web/discord sync data from the database.""" + processed_syncs = await WebDiscordSync.find(WebDiscordSync.processed == True).to_list() # noqa: E712 + + is_confirmed, interaction, confirmation_embed = await confirm_action( + ctx, + title=f"Delete {len(processed_syncs)} processed syncs from the database", + hidden=hidden, + audit=False, + ) + + if not is_confirmed: + return + + for sync in processed_syncs: + await sync.delete() + + await interaction.edit_original_response(embed=confirmation_embed, view=None) ### S3 COMMANDS ################################################################ @s3.command( diff --git a/src/valentina/discord/models/channel_mngr.py b/src/valentina/discord/models/channel_mngr.py index e46b6fc1..6c139032 100644 --- a/src/valentina/discord/models/channel_mngr.py +++ b/src/valentina/discord/models/channel_mngr.py @@ -53,6 +53,37 @@ def _channel_sort_order(channel: discord.TextChannel) -> tuple[int, str]: # pra return (5, channel.name) + async def _confirm_campaign_common_channels( + self, + campaign: Campaign, + category: discord.CategoryChannel, + channels: list[discord.TextChannel], + ) -> None: + """Ensure common campaign channels exist and are up-to-date. + + This method checks for the existence of common campaign channels within the specified category. + If a channel does not exist, it creates it. If a channel exists but its ID does not match the + database, it updates the database with the correct ID. + + Args: + campaign (Campaign): The campaign object containing channel information. + category (discord.CategoryChannel): The category under which the channels should exist. + channels (list[discord.TextChannel]): The list of existing channels in the category. + """ + for channel_db_key, channel_name in CAMPAIGN_COMMON_CHANNELS.items(): + await asyncio.sleep(1) # Keep the rate limit happy + channel_db_id = getattr(campaign, channel_db_key, None) + channel = await self.confirm_channel_in_category( + existing_category=category, + existing_channels=channels, + channel_name=channel_name, + channel_db_id=channel_db_id, + ) + + if not channel_db_id or channel_db_id != channel.id: + setattr(campaign, channel_db_key, channel.id) + await campaign.save() + def _determine_channel_permissions( self, channel_name: str ) -> tuple[ChannelPermission, ChannelPermission, ChannelPermission]: @@ -248,7 +279,22 @@ async def channel_update_or_add( async def confirm_book_channel( self, book: CampaignBook, campaign: Optional[Campaign] ) -> discord.TextChannel | None: - """TKTK.""" + """Confirm and retrieve the Discord text channel associated with a given campaign book. + + This method ensures that the specified campaign book has an associated text channel + within the campaign's category. If the campaign is not provided, it fetches the campaign + using the book's campaign ID. It then verifies the existence of the campaign's category + and channels, creating or confirming the required text channel for the book. + + Args: + book (CampaignBook): The campaign book for which the text channel is to be confirmed. + campaign (Optional[Campaign]): The campaign associated with the book. If not provided, + it will be fetched using the book's campaign ID. + + Returns: + discord.TextChannel | None: The confirmed or newly created Discord text channel for the book, or None if the campaign category does not exist. + """ + logger.debug(f"Confirming channel for book {book.number}. {book.name}") if not campaign: campaign = await Campaign.get(book.campaign) @@ -271,124 +317,15 @@ async def confirm_book_channel( await asyncio.sleep(1) # Keep the rate limit happy return channel - async def confirm_character_channel( - self, character: Character, campaign: Optional[Campaign] - ) -> discord.TextChannel | None: - """TKTK.""" - logger.debug(f"Confirming channel for character {character.name}") - - if not campaign: - return None - - category, channels = await self.fetch_campaign_category_channels(campaign=campaign) - - # If the campaign category channel does not exist, return None - if not category: - return None - - owned_by_user = discord.utils.get(self.guild.members, id=character.user_owner) - channel_name = character.channel_name - channel_db_id = character.channel - - channel = await self.confirm_channel_in_category( - existing_category=category, - existing_channels=channels, - channel_name=channel_name, - channel_db_id=channel_db_id, - owned_by_user=owned_by_user, - topic=f"Character channel for {character.name}", - ) - await character.update_channel_id(channel) - - await asyncio.sleep(1) # Keep the rate limit happy - return channel - - async def delete_book_channel(self, book: CampaignBook) -> None: - """Delete the channel associated with the book.""" - if not book.channel: - return - - channel = self.guild.get_channel(book.channel) - if channel: - await self.delete_channel(channel) - - book.channel = None - await book.save() - - async def delete_character_channel(self, character: Character) -> None: - """Delete the channel associated with the character.""" - if not character.channel: - return - - channel = self.guild.get_channel(character.channel) - if channel: - await self.delete_channel(channel) - - character.channel = None - await character.save() + async def confirm_campaign_channels(self, campaign: Campaign) -> None: + """Confirm and manage the channels for a given campaign. - async def fetch_campaign_category_channels( - self, campaign: Campaign - ) -> tuple[discord.CategoryChannel, list[discord.TextChannel]]: - """Fetch the campaign's channels in the guild. - - Retrieve the category channel and its child text channels for the current campaign - from the Discord guild. + This method ensures that the necessary category and channels for the campaign exist, + are correctly named, and are recorded in the database. Args: - campaign (Campaign): The campaign to fetch the channels for. - - Returns: - tuple[discord.CategoryChannel, list[discord.TextChannel]]: A tuple containing: - - The campaign category channel (discord.CategoryChannel or None if not found) - - A list of text channels within that category (empty list if category not found) - + campaign (Campaign): The campaign object containing details about the campaign. """ - for category, channels in self.guild.by_category(): - if category and category.id == campaign.channel_campaign_category: - return category, [x for x in channels if isinstance(x, discord.TextChannel)] - - return None, [] - - async def sort_campaign_channels(self, campaign: Campaign) -> None: - """TKTK.""" - for category, channels in self.guild.by_category(): - if category and category.id == campaign.channel_campaign_category: - sorted_channels = sorted(channels, key=self._channel_sort_order) # type: ignore[arg-type] - for i, channel in enumerate(sorted_channels): - if channel.position and channel.position == i: - continue - await channel.edit(position=i) - await asyncio.sleep(2) # Keep the rate limit happy - - logger.debug(f"Sorted channels: {[channel.name for channel in sorted_channels]}") - break - - logger.info(f"Channels sorted for campaign '{campaign.name}' in '{self.guild.name}'") - - async def _confirm_campaign_common_channels( - self, - campaign: Campaign, - category: discord.CategoryChannel, - channels: list[discord.TextChannel], - ) -> None: - """TKTK.""" - for channel_db_key, channel_name in CAMPAIGN_COMMON_CHANNELS.items(): - await asyncio.sleep(1) # Keep the rate limit happy - channel_db_id = getattr(campaign, channel_db_key, None) - channel = await self.confirm_channel_in_category( - existing_category=category, - existing_channels=channels, - channel_name=channel_name, - channel_db_id=channel_db_id, - ) - - if not channel_db_id or channel_db_id != channel.id: - setattr(campaign, channel_db_key, channel.id) - await campaign.save() - - async def confirm_campaign_channels(self, campaign: Campaign) -> None: - """TKTK.""" # Confirm the campaign category channel exists and is recorded in the database campaign_category_channel_name = ( f"{Emoji.BOOKS.value}-{campaign.name.lower().replace(' ', '-')}" @@ -471,28 +408,70 @@ async def confirm_campaign_channels(self, campaign: Campaign) -> None: logger.info(f"All channels confirmed for campaign '{campaign.name}' in '{self.guild.name}'") - async def delete_channel( - self, - channel: discord.TextChannel - | discord.CategoryChannel - | discord.VoiceChannel - | discord.ForumChannel - | discord.StageChannel - | int, - ) -> None: - """TKTK.""" - if isinstance(channel, int): - channel = self.guild.get_channel(channel) + async def confirm_character_channel( + self, character: Character, campaign: Optional[Campaign] + ) -> discord.TextChannel | None: + """Confirm the existence of a character-specific text channel within a campaign category. - if not channel: - return + This method checks if a text channel for a given character exists within the specified campaign's category. If the campaign or category does not exist, it returns None. Otherwise, it ensures the channel exists, updates the character's channel ID, and returns the channel. + + Args: + character (Character): The character for whom the channel is being confirmed. + campaign (Optional[Campaign]): The campaign within which to confirm the character's channel. + + Returns: + discord.TextChannel | None: The confirmed text channel for the character, or None if the campaign or category does not exist. + """ + logger.debug(f"Confirming channel for character {character.name}") + + if not campaign: + return None + + category, channels = await self.fetch_campaign_category_channels(campaign=campaign) + + # If the campaign category channel does not exist, return None + if not category: + return None + + owned_by_user = discord.utils.get(self.guild.members, id=character.user_owner) + channel_name = character.channel_name + channel_db_id = character.channel + + channel = await self.confirm_channel_in_category( + existing_category=category, + existing_channels=channels, + channel_name=channel_name, + channel_db_id=channel_db_id, + owned_by_user=owned_by_user, + topic=f"Character channel for {character.name}", + ) + await character.update_channel_id(channel) - logger.debug(f"GUILD: Delete channel '{channel.name}' on '{self.guild.name}'") - await channel.delete() await asyncio.sleep(1) # Keep the rate limit happy + return channel + + async def delete_book_channel(self, book: CampaignBook) -> None: + """Delete the Discord channel associated with the given book. + + Args: + book (CampaignBook): The book object containing the channel information. + """ + if not book.channel: + return + + channel = self.guild.get_channel(book.channel) + if channel: + await self.delete_channel(channel) + + book.channel = None + await book.save() async def delete_campaign_channels(self, campaign: Campaign) -> None: - """Delete all channels associated with the campaign.""" + """Delete all Discord channels associated with the given campaign. + + Args: + campaign (Campaign): The campaign object whose channels are to be deleted. + """ for book in await campaign.fetch_books(): await self.delete_book_channel(book) @@ -511,3 +490,92 @@ async def delete_campaign_channels(self, campaign: Campaign) -> None: campaign.channel_campaign_category = None await campaign.save() await asyncio.sleep(1) + + async def delete_channel( + self, + channel: discord.TextChannel + | discord.CategoryChannel + | discord.VoiceChannel + | discord.ForumChannel + | discord.StageChannel + | int, + ) -> None: + """Delete a specified channel from the guild. + + This method deletes a given channel from the guild. The channel can be specified + as a discord.TextChannel, discord.CategoryChannel, discord.VoiceChannel, + discord.ForumChannel, discord.StageChannel, or an integer representing the channel ID. + + Args: + channel (discord.TextChannel | int): The channel to delete. + """ + if isinstance(channel, int): + channel = self.guild.get_channel(channel) + + if not channel: + return + + logger.debug(f"GUILD: Delete channel '{channel.name}' on '{self.guild.name}'") + await channel.delete() + await asyncio.sleep(1) # Keep the rate limit happy + + async def delete_character_channel(self, character: Character) -> None: + """Delete the channel associated with the character. + + Args: + character (Character): The character object containing the channel information. + """ + if not character.channel: + return + + channel = self.guild.get_channel(character.channel) + if channel: + await self.delete_channel(channel) + + character.channel = None + await character.save() + + async def fetch_campaign_category_channels( + self, campaign: Campaign + ) -> tuple[discord.CategoryChannel, list[discord.TextChannel]]: + """Fetch the campaign's channels in the guild. + + Retrieve the category channel and its child text channels for the current campaign + from the Discord guild. + + Args: + campaign (Campaign): The campaign to fetch the channels for. + + Returns: + tuple[discord.CategoryChannel, list[discord.TextChannel]]: A tuple containing: + - The campaign category channel (discord.CategoryChannel or None if not found) + - A list of text channels within that category (empty list if category not found) + """ + for category, channels in self.guild.by_category(): + if category and category.id == campaign.channel_campaign_category: + return category, [x for x in channels if isinstance(x, discord.TextChannel)] + + return None, [] + + async def sort_campaign_channels(self, campaign: Campaign) -> None: + """Sort the campaign's channels within its category. + + This method sorts the channels within the campaign's category based on a custom sorting order. + It ensures that the channels are positioned correctly according to the defined sort order. + + Args: + campaign (Campaign): The campaign object containing details about the campaign. + """ + for category, channels in self.guild.by_category(): + if category and category.id == campaign.channel_campaign_category: + sorted_channels = sorted(channels, key=self._channel_sort_order) # type: ignore[arg-type] + for i, channel in enumerate(sorted_channels): + if channel.position and channel.position == i: + continue + await channel.edit(position=i) + await asyncio.sleep(2) # Keep the rate limit happy + + logger.debug(f"Sorted channels: {[channel.name for channel in sorted_channels]}") + break + + logger.info(f"Channels sorted for campaign '{campaign.name}' in '{self.guild.name}'") diff --git a/src/valentina/discord/views/settings.py b/src/valentina/discord/views/settings.py index d10f403b..7ae01269 100644 --- a/src/valentina/discord/views/settings.py +++ b/src/valentina/discord/views/settings.py @@ -18,6 +18,7 @@ PermissionsManageTraits, ) from valentina.discord.bot import Valentina, ValentinaContext +from valentina.discord.models import ChannelManager from valentina.discord.views import CancelButton from valentina.models import Guild @@ -166,11 +167,9 @@ async def _update_guild_and_channel( channel (discord.TextChannel): The selected Discord text channel. """ if enable and channel is not None: - # Ensure the channel exists and has the right permissions - await self.ctx.channel_update_or_add( - channel=channel, - topic=self.channel_topic, - permissions=self.permissions, + channel_manager = ChannelManager(guild=self.ctx.guild, user=self.ctx.author) + await channel_manager.channel_update_or_add( + channel=channel, topic=self.channel_topic, permissions=self.permissions ) # Update the settings in the database diff --git a/src/valentina/webui/blueprints/character_view/route.py b/src/valentina/webui/blueprints/character_view/route.py index 38cf8214..a561cc52 100644 --- a/src/valentina/webui/blueprints/character_view/route.py +++ b/src/valentina/webui/blueprints/character_view/route.py @@ -325,7 +325,9 @@ async def post(self, character_id: str) -> str | Response: response.headers["hx-redirect"] = url_for( "character_view.view", character_id=character_id, - success_msg="Character updated!" if has_updates else "No changes made.", + success_msg="Character updated!
Changes will be reflected in Discord within ten minutes." + if has_updates + else "No changes made.", ) return response