336 lines
12 KiB
Python
336 lines
12 KiB
Python
"""
|
|
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
|