diff --git a/__main__.py b/__main__.py index c76cce7..a07c698 100644 --- a/__main__.py +++ b/__main__.py @@ -15,6 +15,7 @@ TimezoneStorage, ConfigStorage, BetaTestersStorage, + TestFlightConfigStorage, ) from botto.storage.enablement_storage import EnablementStorage from botto.tld_botto import TLDBotto @@ -63,7 +64,7 @@ config["authentication"]["snailed_it"]["airtable_key"], ) -testflight_config_storage = ConfigStorage( +testflight_config_storage = TestFlightConfigStorage( config["authentication"]["snailed_it"]["airtable_base"], config["authentication"]["snailed_it"]["airtable_key"], ) @@ -101,6 +102,7 @@ reminder_manager, timezone_storage, testflight_storage, + testflight_config_storage, app_store_connect_client, app_store_server_client, ) diff --git a/botto/commands/__init__.py b/botto/commands/__init__.py new file mode 100644 index 0000000..ddeb5b5 --- /dev/null +++ b/botto/commands/__init__.py @@ -0,0 +1,3 @@ +from . import app_store + +AppStoreCommands = app_store.AppStoreCommands diff --git a/botto/commands/app_store.py b/botto/commands/app_store.py new file mode 100644 index 0000000..6199526 --- /dev/null +++ b/botto/commands/app_store.py @@ -0,0 +1,298 @@ +import enum +import itertools +import logging + +import discord +from discord import app_commands, Interaction + +from botto.clients import AppStoreConnectClient +from botto.storage import BetaTestersStorage +from botto.storage.beta_testers.model import ( + TestingRequest, + RequestStatus, + AppStoreConnectError, + ApiKeyNotSetError, + BetaGroupNotSetError, + InvalidAttributeError, + Tester, + App, +) +from botto.storage.testflight_config_storage import TestFlightConfigStorage +from botto.tld_botto import TLDBotto + +log = logging.getLogger(__name__) + + +class CommandApp(enum.Enum): + Pushcut = enum.auto() + ToolboxPro = enum.auto() + ToolboxPro2 = enum.auto() + MenuBox = enum.auto() + + @property + def record_id(self) -> str: + """ + Get the record ID for the app in Airtable + """ + match self: + case CommandApp.Pushcut: + return "recczpU4YLc2ZJOsd" + case CommandApp.ToolboxPro: + return "recxoKsI2Yvxrh0zM" + case CommandApp.ToolboxPro2: + return "recVGXp2JWosd04z9" + case CommandApp.MenuBox: + return "recnl6sEm15vMf4H6" + + +class AppStoreCommands: + def __init__( + self, + client: TLDBotto, + testflight_storage: BetaTestersStorage, + testflight_config_storage: TestFlightConfigStorage, + app_store_connect_client: AppStoreConnectClient, + ): + self.client = client + self.testflight_storage = testflight_storage + self.testflight_config_storage = testflight_config_storage + self.app_store_connect_client = app_store_connect_client + self.command_group = app_commands.Group( + name="appstore", + description="Commands for the App Store", + guild_ids=[client.snailed_it_beta_guild.id], + default_permissions=discord.Permissions(administrator=True), + ) + self.setup_group() + # self.command_group.add_command(discord.app_commands.Command()) + + async def add_tester( + self, + ctx: Interaction, + tester: Tester, + member: discord.Member, + app: App, + ) -> bool: + try: + testers_with_email = await self.app_store_connect_client.find_beta_tester( + tester.email, app + ) + groups_for_testers = list( + itertools.chain.from_iterable( + [tester.beta_group_ids for tester in testers_with_email] + ) + ) + if app.beta_group_id in groups_for_testers: + log.info(f"{tester.email} already in group {app.beta_group_id}") + return False + await self.app_store_connect_client.create_beta_tester( + app, tester.email, tester.given_name, tester.family_name + ) + log.info(f"Added {tester} to Beta Testers") + return True + except AppStoreConnectError as error: + match error: + case ApiKeyNotSetError(): + log.error( + f"App Store Api Key not set for {app}", + exc_info=True, + ) + await ctx.followup.send( + f"{member.mention} No Api Key is set for {app.name}, unable to add tester automatically)", + mention_author=False, + ephemeral=True, + ) + case BetaGroupNotSetError(): + log.error( + f"Beta group not set for {app}", + exc_info=True, + ) + await ctx.followup.send( + f"{member.mention} No Beta Group is set for {app.name}, " + f"unable to add tester automatically)", + mention_author=False, + ephemeral=True, + ) + case InvalidAttributeError(details=details): + log.error( + f"Invalid tester attribute {details}", + exc_info=True, + ) + await ctx.followup.send( + f"{member.mention} Tester has an attribute considered invalid by App Store Connect: " + f"`{details}`. Unable to add tester automatically)", + mention_author=False, + ephemeral=True, + ) + raise + + async def handle_add_tester_existing_request( + self, + ctx: Interaction, + member: discord.Member, + testing_request: TestingRequest, + ) -> bool: + approval_channel = None + if request_approval_channel_id := testing_request.approval_channel_id: + approval_channel = self.client.get_channel(int(request_approval_channel_id)) + elif ( + guild_approvals_channel_id := await self.testflight_config_storage.get_default_approvals_channel_id( + testing_request.server_id + ) + ) and ( + guild_approvals_channel := self.client.get_channel( + int(guild_approvals_channel_id) + ) + ): + approval_channel = guild_approvals_channel + if not approval_channel or not testing_request.notification_message_id: + await ctx.followup.send( + embed=discord.Embed( + url=self.testflight_storage.url_for_request(testing_request), + title="Access already requested but request message could not be found", + ) + .add_field(name="Member", value=member.mention) + .add_field(name="App", value=testing_request.app_name) + .add_field( + name="Request Status", value=testing_request.status, inline=False + ), + allowed_mentions=discord.AllowedMentions.none(), + ) + return testing_request.status != RequestStatus.APPROVED + notification_message = approval_channel.get_partial_message( + int(testing_request.notification_message_id) + ) + await ctx.followup.send( + embed=discord.Embed( + url=self.testflight_storage.url_for_request(testing_request), + title="Access already requested", + ) + .add_field(name="Latest request", value=notification_message.jump_url) + .add_field(name="Member", value=member.mention) + .add_field(name="App", value=testing_request.app_name) + .add_field( + name="Request Status", value=testing_request.status, inline=False + ), + allowed_mentions=discord.AllowedMentions.none(), + ) + return False + + def setup_group(self): + @self.command_group.command( + name="add_tester", + description="Add tester to an app", + ) + @app_commands.describe( + member="The member to add as a tester.", + app="The app to which to add the tester.", + ) + @app_commands.checks.has_role("Snailed It") + async def lookup_order_id( + ctx: Interaction, + member: discord.Member, + app: CommandApp, + ): + app_record_id: str = app.record_id + if not app_record_id: + await ctx.response.send_message("Invalid app specified", ephemeral=True) + return + tester = await self.testflight_storage.find_tester( + discord_id=str(member.id) + ) + if not tester: + await ctx.response.send_message( + embed=discord.Embed( + title=f"{member.name} is not registered", + description=f"{member.mention} has no entry in Airtable", + ) + .add_field(name="Member", value=member.mention) + .add_field(name="App", value=app.name), + allowed_mentions=discord.AllowedMentions.none(), + ephemeral=True, + ) + return + await ctx.response.defer(ephemeral=True, thinking=True) + # In case they've changed, update our record + tester.username = member.name + tester = await self.testflight_storage.upsert_tester(tester) + log.debug(f"Updated tester: {tester}") + if not tester.email: + await ctx.followup.send( + embed=discord.Embed( + title=f"{member.name} is not registered", + description=f"{member.mention} has not provided an email address", + ) + .add_field(name="Member", value=member.mention) + .add_field(name="App", value=app.name), + ephemeral=True, + ) + return + existing_testing_requests = [ + r + async for r in self.testflight_storage.list_requests( + tester_id=tester.discord_id, + app_ids=[app_record_id], + exclude_removed=True, + ) + ] + if len(existing_testing_requests) == 0: + testing_request = TestingRequest( + tester=tester.id, + tester_discord_id=tester.discord_id, + app=app_record_id, + server_id=str(ctx.guild_id), + status=RequestStatus.APPROVED, + ) + else: + testing_request = existing_testing_requests[-1] + if not await self.handle_add_tester_existing_request( + ctx, member, testing_request + ): + return + + app = await self.testflight_storage.fetch_app(app_record_id) + user_added = await self.add_tester(ctx, tester, member, app) + guild = self.client.get_guild(int(ctx.guild_id)) + roles = [guild.get_role(int(role_id)) for role_id in app.reaction_role_ids] + tester_user = guild.get_member(int(tester.discord_id)) + if not all(r in tester_user.roles for r in roles): + log.debug(f"Adding roles {roles} to {tester_user}") + try: + await tester_user.add_roles( + *roles, reason=f"Testflight request for {app.name} approved" + ) + except discord.DiscordException as e: + log.error("Failed to add role to tester", exc_info=True) + await ctx.followup.send( + f"Failed to add role to tester: {e}", + ) + if user_added: + log.debug(f"Notifying {member} of TestFlight approval") + await member.send( + f"Hi again!\n" + f"You have been approved to test **{testing_request.app_name}**.\n" + f"A TestFlight invite should have been sent to `{tester.email}`" + ) + else: + log.debug(f"Skipping notification, as {member} already approved") + if len(existing_testing_requests) == 0: + testing_request = await self.testflight_storage.add_request( + testing_request + ) + else: + testing_request = await self.testflight_storage.update_request( + testing_request + ) + await ctx.followup.send( + embed=discord.Embed( + title=f"{member.name} added", + url=self.testflight_storage.url_for_request(testing_request), + ) + .add_field(name="Member", value=member.mention) + .add_field(name="App", value=app.name), + allowed_mentions=discord.AllowedMentions.none(), + ) + + @property + def group(self) -> app_commands.Group: + return self.command_group diff --git a/botto/mixins/reaction_roles.py b/botto/mixins/reaction_roles.py index 0efee2f..f71bf34 100644 --- a/botto/mixins/reaction_roles.py +++ b/botto/mixins/reaction_roles.py @@ -16,7 +16,7 @@ from botto.clients import AppStoreConnectClient from botto.extended_client import ExtendedClient from botto.models import AirTableError -from botto.storage import BetaTestersStorage, ConfigStorage +from botto.storage import BetaTestersStorage, TestFlightConfigStorage from botto.storage.beta_testers import model from botto.storage.beta_testers.beta_testers_storage import RequestApprovalFilter from botto.storage.beta_testers.model import ( @@ -118,7 +118,7 @@ def __init__( self, scheduler: AsyncIOScheduler, reactions_roles_storage: BetaTestersStorage, - testflight_config_storage: ConfigStorage, + testflight_config_storage: TestFlightConfigStorage, app_store_connect_client: AppStoreConnectClient, **kwargs, ): @@ -144,43 +144,6 @@ async def refresh_reaction_role_caches(self): self.testflight_storage.list_approvals_channel_ids(), ) - @cachedmethod( - lambda self: self.role_approvals_channels_cache, - key=partial(hashkey, "approvals_channels"), - ) - async def get_default_approvals_channel_id( - self, guild_id: str | int - ) -> Optional[str]: - if result := await self.reaction_roles_config_storage.get_config( - guild_id, "default_approvals_channel" - ): - return result.value - return None - - @cachedmethod( - lambda self: self.role_approvals_channels_cache, - key=partial(hashkey, "rule_agreement_role"), - ) - async def get_rule_agreement_role_id(self, guild_id: str) -> Optional[str]: - if result := await self.reaction_roles_config_storage.get_config( - guild_id, "rule_agreement_role" - ): - return result.value - return None - - @cachedmethod( - lambda self: self.role_approvals_channels_cache, - key=partial(hashkey, "tester_exit_notification_channel"), - ) - async def get_tester_exit_notification_channel( - self, guild_id: str - ) -> Optional[str]: - if result := await self.reaction_roles_config_storage.get_config( - guild_id, "tester_exit_notification_channel" - ): - return result.value - return None - async def get_rule_agreement_message( self, guild_id: str ) -> Optional[AgreementMessage]: @@ -245,7 +208,7 @@ async def handle_role_reaction(self, payload: discord.RawReactionActionEvent): return if reaction_role.requires_rules_approval and ( - rule_agreement_role_id := await self.get_rule_agreement_role_id( + rule_agreement_role_id := await self.reaction_roles_config_storage.get_rule_agreement_role_id( str(guild_id) ) ): @@ -393,7 +356,7 @@ async def send_request_notification_message( if request_approval_channel_id := request.approval_channel_id: approval_channel = self.get_channel(int(request_approval_channel_id)) elif ( - guild_approvals_channel_id := await self.get_default_approvals_channel_id( + guild_approvals_channel_id := await self.reaction_roles_config_storage.get_default_approvals_channel_id( request.server_id ) ) and ( @@ -444,8 +407,10 @@ async def send_approval_notification(self, request: TestingRequest, tester: Test async def is_approval_channel(self, channel_id: str, guild_id: str | int) -> bool: if channel_id in self.testflight_storage.approvals_channel_ids: return True - default_approvals_channel_id = await self.get_default_approvals_channel_id( - str(guild_id) + default_approvals_channel_id = ( + await self.reaction_roles_config_storage.get_default_approvals_channel_id( + str(guild_id) + ) ) return channel_id == default_approvals_channel_id @@ -756,7 +721,9 @@ async def handle_reaction(self, payload: discord.RawReactionActionEvent) -> bool channel = reaction_channel else: channel = await self.get_or_fetch_channel( - await self.get_default_approvals_channel_id(guild_id) + await self.reaction_roles_config_storage.get_default_approvals_channel_id( + guild_id + ) ) await channel.send( f"{payload.member.mention} Failed to handle reaction: {e}", @@ -942,7 +909,7 @@ async def remove_tester_from_app_store( async def on_raw_member_remove(self, payload: discord.RawMemberRemoveEvent): log.debug(f"{payload.user} left server {payload.guild_id}") - exit_notification_channel_id = await self.get_tester_exit_notification_channel( + exit_notification_channel_id = await self.reaction_roles_config_storage.get_tester_exit_notification_channel( str(payload.guild_id) ) if not exit_notification_channel_id: diff --git a/botto/slash_commands.py b/botto/slash_commands.py index be13848..91306a9 100644 --- a/botto/slash_commands.py +++ b/botto/slash_commands.py @@ -13,9 +13,10 @@ import discord from discord import app_commands, Interaction -from botto import responses +from botto import responses, commands from botto.clients import AppStoreConnectClient, AppStoreServerClient from botto.clients.stjude_scoreboard import StJudeScoreboardClient +from botto.commands.app_store import CommandApp from botto.message_checks import get_or_fetch_member from botto.models import AirTableError, Timezone from botto.reminder_manager import ( @@ -25,6 +26,7 @@ ) from botto.storage import TimezoneStorage, BetaTestersStorage from botto.errors import TlderNotFoundError +from botto.storage.testflight_config_storage import TestFlightConfigStorage from botto.tld_botto import TLDBotto from botto.views.testflight_form import TestFlightForm @@ -38,6 +40,7 @@ def setup_slash( reminder_manager: ReminderManager, timezones: TimezoneStorage, testflight_storage: BetaTestersStorage, + testflight_config_storage: TestFlightConfigStorage, app_store_connect: AppStoreConnectClient, app_store_server: AppStoreServerClient, ): @@ -412,12 +415,10 @@ async def on_testflight_registration_error(ctx: Interaction, error: Exception): client.tree.add_command(testflight) - app_store = app_commands.Group( - name="appstore", - description="Commands for the App Store", - guild_ids=[client.snailed_it_beta_guild.id], - default_permissions=discord.Permissions(administrator=True), + app_store_commands = commands.AppStoreCommands( + client, testflight_storage, testflight_config_storage, app_store_connect ) + app_store = app_store_commands.group from botto.storage.beta_testers import model @@ -428,7 +429,6 @@ async def send_tester_details(ctx, tester_or_email: model.Tester | str): except AttributeError: tester_email = tester_or_email log.info(f"Finding beta testers with email {tester_email}") - await ctx.response.defer(ephemeral=True, thinking=True) matching_testers = await app_store_connect.find_beta_tester(email=tester_email) approved_apps_to_testers = {} requested_apps_to_testers = {} @@ -499,6 +499,7 @@ async def lookup_tester( ctx: Interaction, tester_email: str, ): + await ctx.response.defer(ephemeral=True, thinking=True) await send_tester_details(ctx, tester_email) @app_store.command( @@ -514,17 +515,14 @@ async def lookup_user( member: discord.Member, ): tester = await testflight_storage.find_tester(discord_id=member.id) + await ctx.response.defer(ephemeral=True, thinking=True) if not tester: - await ctx.response.send_message( + await ctx.followup.send( f"User {member.mention} is not a beta tester", ephemeral=True ) return await send_tester_details(ctx, tester) - class CommandApp(enum.Enum): - Pushcut = enum.auto() - ToolboxPro = enum.auto() - @app_store.command( name="lookup_order_id", description="Lookup transactions associated with an order", @@ -541,12 +539,7 @@ async def lookup_order_id( order_id: str, show_other_users: Optional[bool], ): - app_record_id: str = "" - match app: - case CommandApp.Pushcut: - app_record_id = "recczpU4YLc2ZJOsd" - case CommandApp.ToolboxPro: - app_record_id = "recxoKsI2Yvxrh0zM" + app_record_id: str = app.record_id if not app_record_id: await ctx.response.send_message( "Invalid app specified", ephemeral=not show_other_users @@ -555,8 +548,9 @@ async def lookup_order_id( app = await testflight_storage.fetch_app(app_record_id) transactions = await app_store_server.lookup_order_id(app, order_id) + await ctx.response.defer(ephemeral=True, thinking=True) if not transactions: - await ctx.response.send_message("No transactions found", ephemeral=True) + await ctx.followup.send("No transactions found", ephemeral=True) return if len(transactions) > 1: for i, transaction in enumerate(transactions): @@ -650,19 +644,10 @@ def format_timestamp(timestamp: Optional[int]) -> str: @app_store.error async def on_app_store_error(ctx: Interaction, error: Exception): log.error("Failed to query app store", exc_info=True) + await ctx.followup.send(f"Command failed: {error}", ephemeral=True) client.tree.add_command(app_store) - @client.tree.context_menu(name="Raise Jira", guilds=[client.snailed_it_beta_guild]) - @app_commands.checks.has_role("Snailed It") - @client.tree.error - async def raise_jira(ctx: discord.Interaction, message: discord.Message): - try: - log.debug("Opening Raise Jira form") - await ctx.response.send_message(view=RaiseJiraForm(), ephemeral=True) - except: - log.error("Failed", exc_info=True) - cache = app_commands.Group( name="cache", description="Manage caches", @@ -723,8 +708,7 @@ async def clear_config( @cache_refresh.command( name="config", - description="Refresh all cached config (Note: This refreshes only config, not all caches associated with " - "TestFlight approvals)", + description="Refresh all cached config (Note: This refreshes only config, not all caches)", ) @app_commands.checks.has_role("Snailed It") async def refresh_config( diff --git a/botto/storage/__init__.py b/botto/storage/__init__.py index 6bc4c7a..15ea87c 100644 --- a/botto/storage/__init__.py +++ b/botto/storage/__init__.py @@ -3,6 +3,7 @@ from . import timezone_storage from . import enablement_storage from . import config_storage +from . import testflight_config_storage from .beta_testers import beta_testers_storage MealStorage = meal_storage.MealStorage @@ -11,4 +12,5 @@ TimezoneStorage = timezone_storage.TimezoneStorage EnablementStorage = enablement_storage.EnablementStorage ConfigStorage = config_storage.ConfigStorage +TestFlightConfigStorage = testflight_config_storage.TestFlightConfigStorage BetaTestersStorage = beta_testers_storage.BetaTestersStorage diff --git a/botto/storage/testflight_config_storage.py b/botto/storage/testflight_config_storage.py new file mode 100644 index 0000000..1f8c6d0 --- /dev/null +++ b/botto/storage/testflight_config_storage.py @@ -0,0 +1,47 @@ +from functools import partial +from typing import Optional + +from asyncache import cachedmethod +from cachetools import TTLCache +from cachetools.keys import hashkey + +from botto.storage.config_storage import ConfigStorage + + +class TestFlightConfigStorage(ConfigStorage): + def __init__(self, airtable_base: str, airtable_key: str): + self.cache = TTLCache(20, 600) + super().__init__(airtable_base, airtable_key) + + @cachedmethod( + lambda self: self.cache, + key=partial(hashkey, "approvals_channels"), + ) + async def get_default_approvals_channel_id( + self, guild_id: str | int + ) -> Optional[str]: + if result := await self.get_config(guild_id, "default_approvals_channel"): + return result.value + return None + + @cachedmethod( + lambda self: self.cache, + key=partial(hashkey, "rule_agreement_role"), + ) + async def get_rule_agreement_role_id(self, guild_id: str) -> Optional[str]: + if result := await self.get_config(guild_id, "rule_agreement_role"): + return result.value + return None + + @cachedmethod( + lambda self: self.cache, + key=partial(hashkey, "tester_exit_notification_channel"), + ) + async def get_tester_exit_notification_channel( + self, guild_id: str + ) -> Optional[str]: + if result := await self.get_config( + guild_id, "tester_exit_notification_channel" + ): + return result.value + return None diff --git a/botto/tests/test_startup.py b/botto/tests/test_startup.py index 19d6c39..f3c9e45 100644 --- a/botto/tests/test_startup.py +++ b/botto/tests/test_startup.py @@ -13,6 +13,7 @@ EnablementStorage, ConfigStorage, BetaTestersStorage, + TestFlightConfigStorage, ) from botto.tld_botto import TLDBotto @@ -35,7 +36,7 @@ def test_startup(): "fake_key", ) - testflight_config_storage = ConfigStorage( + testflight_config_storage = TestFlightConfigStorage( "fake_base", "fake_key", ) @@ -70,6 +71,7 @@ def test_startup(): reminder_manager, timezone_storage, testflight_storage, + testflight_config_storage, app_store_connect_client, app_store_server_client, ) diff --git a/botto/tld_botto.py b/botto/tld_botto.py index 3d6a7d0..214f1a0 100644 --- a/botto/tld_botto.py +++ b/botto/tld_botto.py @@ -22,10 +22,11 @@ from .clients import ClickUpClient, AppStoreConnectClient from .errors import TlderNotFoundError from .mixins import ClickupMixin, RemoteConfig, ReactionRoles +from .storage.testflight_config_storage import TestFlightConfigStorage from .views.testflight_form import TestFlightForm if TYPE_CHECKING: - from discord.abc import MessageableChannel + from discord.abc import MessageableChannel, Snowflake from botto import responses from .date_helpers import convert_24_hours @@ -109,7 +110,7 @@ def __init__( clickup_client: ClickUpClient, config_storage: ConfigStorage, test_flight_storage: BetaTestersStorage, - testflight_config_storage: ConfigStorage, + testflight_config_storage: TestFlightConfigStorage, app_store_connect_client: AppStoreConnectClient, ): self.config = config @@ -210,9 +211,16 @@ async def on_ready(self): log.info("We have logged in as {0.user}".format(self)) log.info("Syncing commands") + + async def sync_guild(guild: Snowflake): + try: + return self.get_guild(guild.id).name, await self.tree.sync(guild=guild) + except discord.app_commands.CommandSyncFailure as e: + return guild.id, e + sync_tasks = [ asyncio.create_task( - self.tree.sync(guild=guild), name=f"Sync commands for guild {guild}" + sync_guild(guild), name=f"Sync commands for guild {guild}" ) for guild in self.expected_guilds ] + [asyncio.create_task(self.tree.sync(), name=f"Sync global commands")] @@ -229,8 +237,16 @@ async def on_ready(self): ) log.info(f"Meal reminders for: {reminder_log_text}") - await asyncio.wait(sync_tasks) - log.info("Synced commands") + commands: tuple[tuple[str, discord.app_commands.AppCommand | Exception]] = ( + await asyncio.gather(*sync_tasks, return_exceptions=True) + ) + for result in commands: + if isinstance(result[1], Exception): + log.error( + f"Failed to sync commands for {result[0]}", exc_info=result[1] + ) + else: + log.info(f"Synced commands for {result[0]}") async def on_disconnect(self): log.warning("Bot disconnected")