""" UI Views for Twitch linkage. - Persistent view with interaction-button to enter link flow. - We don't store the view, but we listen to interaction button id. - Command to enter link flow. For link flow, send ephemeral embed with instructions and what to expect, with link button below. After auth is granted through OAuth flow (or if not granted, e.g. on timeout or failure) edit the embed to reflect auth situation. If migration occurred, add the migration text as a field to the embed. """ import asyncio from datetime import timedelta from enum import IntEnum from typing import Optional import aiohttp import discord from discord.ui.button import button, Button from discord.enums import ButtonStyle from twitchAPI.helper import first from meta import LionBot from meta.errors import SafeCancellation from meta.logger import log_wrap from utils.ui import MessageUI from utils.lib import MessageArgs, utc_now from utils.ui.leo import LeoUI from modules.profiles.profile import UserProfile import brand from .. import logger class TwitchLinkStatic(LeoUI): """ Static UI whose only job is to display a persistent button to ask people to connect their twitch account. """ def __init__(self, **kwargs): super().__init__(**kwargs) self._embed: Optional[discord.Embed] = None async def interaction_check(self, interaction: discord.Interaction): return True @property def embed(self) -> discord.Embed: """ This is the persistent message people will see with the button that starts the Oauth flow. Not sure what this should actually say, or whether it should be customisable via command. :TODO-MARKER: """ embed = discord.Embed( title="Link your Twitch account!", description=( "To participate in the Dreamspace Adventure Game :TM:, " "please start by pressing the button below to begin the login flow with Twitch!" ), colour=brand.ACCENT_COLOUR, ) return embed @embed.setter def embed(self, value): self._embed = value @button(label="Connect", custom_id="BTN-LINK-TWITCH", style=ButtonStyle.green, emoji='🔗') @log_wrap(action="link-twitch-btn") async def button_linker(self, interaction: discord.Interaction, btn: Button): # Here we just reply to the interaction with the AuthFlow UI # TODO flowui = TwitchLinkFlow(interaction.client, interaction.user, callerid=interaction.user.id) await flowui.run(interaction) await flowui.wait() class FlowState(IntEnum): SETUP = -1 WAITING = 0 CANCELLED = 1 TIMEOUT = 2 ERRORED = 3 WORKING = 9 DONE = 10 class TwitchLinkFlow(MessageUI): def __init__(self, bot: LionBot, caller: discord.User | discord.Member, *args, **kwargs): kwargs.setdefault('callerid', caller.id) super().__init__(*args, **kwargs) self.bot = bot self._auth_task = None self._stage: FlowState = FlowState.SETUP self.flow = None self.authrow = None self.user = caller self._info = None self._migration_details = None # ----- UI API ----- async def run(self, interaction: discord.Interaction, **kwargs): await interaction.response.defer(ephemeral=True, thinking=True) await self._start_flow() await self.draw(interaction, **kwargs) if self._stage is FlowState.ERRORED: # This can happen if starting the flow failed await self.close() @log_wrap(action="start-twitch-flow-ui") async def _start_flow(self): logger.info(f"Starting twitch authentication flow for {self.user}") try: self.flow = await self.bot.get_cog('TwitchAuthCog').start_auth() except aiohttp.ClientError: self._stage = FlowState.ERRORED self._info = ( "Could not establish a connection to the authentication server! " "Please try again later~" ) logger.exception("Unexpected exception while starting authentication flow!", exc_info=True) else: self._stage = FlowState.WAITING self._auth_task = asyncio.create_task(self._auth_flow()) @log_wrap(action="run-twitch-flow-ui") async def _auth_flow(self): """ Run the flow and wait for a timeout, cancellation, or callback. Update the message accordingly. """ assert self.flow is not None try: # TODO: Cancel this in cleanup authrow = await asyncio.wait_for(self.flow.run(), timeout=60) except asyncio.TimeoutError: self._stage = FlowState.TIMEOUT # Link Timed Out! self._info = ( "We didn't receive a response so we closed the uplink " "to keep your account safe! If you still want to connect, please try again!" ) await self.refresh() await self.close() except asyncio.CancelledError: # Presumably the user exited or the bot is shutting down. # Not safe to edit the message, but try and cleanup await self.close() except SafeCancellation as e: logger.info("User or server cancelled authentication flow: ", exc_info=True) # Uplink Cancelled! self._info = ( f"We couldn't complete the uplink!\nReason:*{e.msg}*" ) self._stage = FlowState.CANCELLED await self.refresh() await self.close() except Exception: logger.exception("Something unexpected went wrong while running the flow!") else: self._stage = FlowState.WORKING self._info = ( "Authentication complete! Connecting your Dreamspace account ...." ) self.authrow = authrow await self.refresh() await self._link_twitch(str(authrow.userid)) await self.refresh() await self.close() async def cleanup(self): await super().cleanup() if self._auth_task and not self._auth_task.cancelled(): self._auth_task.cancel() async def _link_twitch(self, twitch_id: str): """ Link the caller's profile to the given twitch_id. Performs migration if needed. """ try: twitch_user = await first(self.bot.twitch.get_users(user_ids=[twitch_id])) except Exception: logger.exception( f"Looking up user {self.authrow} from Twitch authentication flow raised an error." ) self._stage = FlowState.ERRORED self._info = "Failed to look up your user details from Twitch! Please try again later." return if twitch_user is None: logger.error( f"User {self.authrow} obtained from Twitch authentication does not exist." ) self._stage = FlowState.ERRORED self._info = "Authentication failed! Please try again later." return profiles = self.bot.get_cog('ProfileCog') userid = self.user.id caller_profile = await UserProfile.fetch_from_discordid(self.bot, userid) twitch_profile = await UserProfile.fetch_from_twitchid(self.bot, twitch_id) succ_info = ( f"Successfully established uplink to your Twitch account **{twitch_user.display_name}** " "and transferred dreamspace data! Happy adventuring, and watch out for the grue~" ) # ::TODO-MARKER:: if twitch_profile is None: if caller_profile is None: # Neither profile exists profile = await UserProfile.create_from_discord(self.bot, self.user) await profile.attach_twitch(twitch_id) self._stage = FlowState.DONE self._info = succ_info else: await caller_profile.attach_twitch(twitch_id) self._stage = FlowState.DONE self._info = succ_info else: if caller_profile is None: await twitch_profile.attach_discord(self.user.id) self._stage = FlowState.DONE self._info = succ_info elif twitch_profile.profileid == caller_profile.profileid: self._stage = FlowState.CANCELLED self._info = ( f"The Twitch account **{twitch_user.display_name}** is already linked to your profile!" ) else: # In this case we have conflicting profiles we need to migrate try: results = await profiles.migrate_profile(twitch_profile, caller_profile) except Exception: self._stage = FlowState.ERRORED self._info = ( "An issue was encountered while merging your account profiles! " "The migration was rolled back, and not data has been lost.\n" "The developer has been notified, please try again later!" ) logger.exception(f"Failed to migrate profiles {twitch_profile=} to {caller_profile=}") else: self._stage = FlowState.DONE self._info = succ_info self._migration_details = '\n'.join(results) logger.info( f"Migrated {twitch_profile=} to {caller_profile}. Info: {self._migration_details}" ) # ----- UI Flow ----- async def make_message(self) -> MessageArgs: if self._stage is FlowState.SETUP: raise ValueError("Making message before flow initialisation!") assert self.flow is not None if self._stage is FlowState.WAITING: # Message should be the initial request page dur = discord.utils.format_dt(utc_now() + timedelta(seconds=60), style='R') title = "Press the button to login!" desc = ( "We have generated a custom secure link for you to connect your Twitch profile! " "Press the button below and accept the connection in your browser, " "and we will begin the transfer!\n" f"(Note: The link expires {dur})" ) colour = brand.ACCENT_COLOUR elif self._stage is FlowState.CANCELLED: # Show cancellation message # Show 'you can close this' title = "Uplink Cancelled!" desc = self._info colour = discord.Colour.brand_red() elif self._stage is FlowState.TIMEOUT: title = "Link Timed Out" desc = self._info colour = discord.Colour.brand_red() elif self._stage is FlowState.ERRORED: title = "Something went wrong!" desc = self._info colour = discord.Colour.brand_red() elif self._stage is FlowState.WORKING: # We've received the auth, we are now doing migration title = "Establishing Connection" desc = self._info colour = brand.ACCENT_COLOUR elif self._stage is FlowState.DONE: title = "Success!" desc = self._info colour = discord.Colour.brand_green() else: raise ValueError(f"Invalid stage value {self._stage}") embed = discord.Embed(title=title, description=desc, colour=colour, timestamp=utc_now()) if self._migration_details: embed.add_field( name="Profile migration details", value=self._migration_details ) return MessageArgs(embed=embed) async def refresh_layout(self): # If we haven't received the auth callback yet, make the flow link button if self.flow is None: raise ValueError("Refreshing before flow initialisation!") if self._stage <= FlowState.WAITING: flow_link = self.flow.auth.return_auth_url() button = Button( style=ButtonStyle.link, url=flow_link, label="Login With Twitch" ) self.set_layout((button,)) else: self.set_layout(()) async def reload(self): pass