packages feed

calamity-0.3.0.0: Calamity/Interactions/View.hs

{-# LANGUAGE TemplateHaskell #-}

module Calamity.Interactions.View (
  ViewEff (..),
  endView,
  replaceView,
  getSendResponse,
  View,
  row,
  runView,
  runViewInstance,
  button,
  button',
  select,
  select',
  textInput,
  textInput',
  deleteInitialMsg,
  instantiateView,
) where

import Calamity.Client.Client (react)
import Calamity.Client.Types (BotC, EventType (InteractionEvt))
import Calamity.HTTP.Channel (ChannelRequest (DeleteMessage))
import Calamity.HTTP.Internal.Ratelimit (RatelimitEff)
import Calamity.HTTP.Internal.Request (invoke)
import Calamity.Interactions.Eff (InteractionEff (..))
import Calamity.Internal.AesonThings
import Calamity.Metrics.Eff (MetricEff)
import Calamity.Types.LogEff (LogEff)
import Calamity.Types.Model.Channel.Component (CustomID)
import qualified Calamity.Types.Model.Channel.Component as C
import Calamity.Types.Model.Channel.Message (Message)
import Calamity.Types.Model.Interaction
import Calamity.Types.TokenEff (TokenEff)
import qualified Control.Concurrent.STM as STM
import Control.Lens ((.~), (?~), (^.), (^..), _1, _2, _3, _Just)
import Control.Monad (guard, void)
import qualified Data.Aeson
import Data.Aeson.Lens (AsValue (_Array), key)
import qualified Data.List
import qualified Data.Set as S
import Data.Text (Text)
import GHC.Generics (Generic)
import qualified GHC.TypeLits as E
import qualified Polysemy as P
import qualified Polysemy.Resource as P
import qualified Polysemy.State as P
import System.Random

data ViewComponent a = ViewComponent
  { component :: C.Component
  , parse :: Interaction -> ExtractResult a
  }

instance Functor ViewComponent where
  fmap f ViewComponent {component, parse} = ViewComponent {component, parse = fmap f . parse}

{- | A view containing one or more components

 This has an applicative interface to allow for easy composition of
 components.
-}
data View a
  = NilView a
  | SingView (forall g. RandomGen g => g -> (ViewComponent a, g))
  | RowView (View a)
  | forall x. MultView (View (x -> a)) (View x)

{- | Convert a 'View' such that it renders as a row.

 Note: nested rows are not allowed by discord, along with further restrictions
 listed here:
 https://discord.com/developers/docs/interactions/message-components
-}
row :: View a -> View a
row = RowView

instance Functor View where
  fmap f (NilView x) = NilView (f x)
  fmap f (SingView x) = SingView (\r -> let (x', r') = x r in (f <$> x', r'))
  fmap f (RowView x) = RowView (fmap f x)
  fmap f (MultView x y) = MultView (fmap (f .) x) y

instance Applicative View where
  pure = NilView
  (<*>) = MultView

type MonadViewMessage =
  'E.ShowType View
    'E.:<>: 'E.Text " doesn't have a Monad instance as we need to be able to inspect the contained components"
    'E.:$$: 'E.Text "If you haven't already, enable ApplicativeDo"
    'E.:$$: 'E.Text "Also, make sure you use lazy patterns: ~(a, b) instead of (a, b)"
    'E.:$$: 'E.Text "Refer to https://ghc.gitlab.haskell.org/ghc/doc/users_guide/exts/applicative_do.html"

instance E.TypeError MonadViewMessage => Monad View where
  (>>=) = undefined -- unreachable

data ExtractOkType
  = -- | At least one value has been extracted
    SomeExtracted
  | -- | No values have been extracted, we shouldn't trigger the callback
    NoneExtracted
  deriving (Show)

instance Semigroup ExtractOkType where
  SomeExtracted <> _ = SomeExtracted
  _ <> SomeExtracted = SomeExtracted
  _ <> _ = NoneExtracted

data ExtractResult a
  = -- | Extraction succeeded in some way
    ExtractOk ExtractOkType a
  | -- | Bail out from parsing this interaction for the current view
    ExtractFail
  deriving (Show, Functor)

instance Applicative ExtractResult where
  pure = ExtractOk SomeExtracted

  ExtractOk ta f <*> ExtractOk tb x = ExtractOk (ta <> tb) $ f x
  _ <*> _ = ExtractFail

data ViewInstance a = ViewInstance
  { -- customIDS :: Set C.CustomID ,
    extract :: Interaction -> ExtractResult a
  , rendered :: [C.Component]
  }

data ViewEff ret inp sendResp m a where
  -- | Mark the view as finished and set the return value.
  --
  -- This doesn't trigger the immediate exit from the code it is used in, the
  -- view will exit before it would wait for the next event.
  EndView :: ret -> ViewEff ret inp sendResp m ()
  -- | Given a view and a way to display the rendered view to discord, show the
  -- view and start tracking the new view
  --
  -- This works for both message components and modals
  ReplaceView :: View inp -> ([C.Component] -> m ()) -> ViewEff ret inp sendResp m ()
  -- | Get the result of the action that sent a value
  GetSendResponse :: ViewEff ret inp sendResp m sendResp

P.makeSem ''ViewEff

extractCustomID :: Interaction -> Maybe CustomID
extractCustomID Interaction {data_, type_} = do
  guard $ type_ == MessageComponentType
  data' <- data_
  data' ^. #customID

guardComponentType :: Interaction -> C.ComponentType -> Maybe ()
guardComponentType Interaction {data_} expected = do
  data' <- data_
  guard $ data' ^. #componentType == Just expected

extractOkFromMaybe :: Maybe a -> ExtractResult (Maybe a)
extractOkFromMaybe (Just a) = ExtractOk SomeExtracted (Just a)
extractOkFromMaybe Nothing = ExtractOk NoneExtracted Nothing

extractOkFromBool :: Bool -> ExtractResult Bool
extractOkFromBool True = ExtractOk SomeExtracted True
extractOkFromBool False = ExtractOk NoneExtracted False

{- | Construct a 'View' containing a 'C.Button' with the given style and label

 Other fields of 'C.Button' default to 'Nothing'
-}
button :: C.ButtonStyle -> Text -> View Bool
button s l = button' ((#style .~ s) . (#label ?~ l))

{- | Construct a 'View' containing a 'C.Button', then modify the component with
   the passed mapping function

 The 'C.Button' passed to the mapping function will have a style of
 'C.ButtonPrimary', other fields will be 'Nothing'
-}
button' :: (C.Button -> C.Button) -> View Bool
button' f = SingView $ \g ->
  let (cid, g') = uniform g
      comp = C.Button' . f $ C.button C.ButtonPrimary cid
      parse' int =
        Just True
          == ( do
                customID <- extractCustomID int
                guardComponentType int C.ButtonType
                pure $ customID == cid
             )
      parse = extractOkFromBool . parse'
   in (ViewComponent comp parse, g')

{- | Construct a 'View' containing a 'C.Select' with the given list of values

 Each element of the passed options list is used as both the display
 'C.SelectOption.label' and 'C.SelectOption.value', use 'select'' if you
 desire more control
-}
select :: [Text] -> View (Maybe Text)
select opts = ensureOne <$> select' (map (\x -> C.sopt x x) opts) Prelude.id
  where
    ensureOne :: Maybe [Text] -> Maybe Text
    ensureOne mx = do
      o <- mx
      guard $ length o == 1
      case o of
        [x] -> Just x
        _ -> Nothing

{- | Construct a 'View' containing a 'C.Select' with the given options, then
 modify the component with the passed mapping function
-}
select' :: [C.SelectOption] -> (C.Select -> C.Select) -> View (Maybe [Text])
select' opts f = SingView $ \g ->
  let (cid, g') = uniform g
      comp = f $ C.select opts cid
      finalValues = S.fromList $ comp ^.. #options . traverse . #value
      parse int = extractOkFromMaybe $ do
        customID <- extractCustomID int
        guard $ customID == cid
        guardComponentType int C.SelectType
        values <- int ^. #data_ . _Just . #values
        let values' = S.fromList values
        guard $ S.isSubsetOf values' finalValues
        pure values
   in (ViewComponent (C.Select' comp) parse, g')

data TextInputDecoded = TextInputDecoded
  { value :: Maybe Text
  , customID :: CustomID
  }
  deriving (Show, Generic)
  deriving (Data.Aeson.FromJSON) via CalamityJSON TextInputDecoded

parseTextInput :: CustomID -> Interaction -> ExtractResult (Maybe Text)
parseTextInput cid int = extractOkFromMaybe $ do
  components <- int ^. #data_ . _Just . #components
  -- currently, each text input is a singleton actionrow containing a single textinput component

  let textInputs = components ^.. traverse . key "components" . _Array . traverse
      inputs' :: Data.Aeson.Result [TextInputDecoded] = traverse Data.Aeson.fromJSON textInputs

  inputs <- case inputs' of
    Data.Aeson.Success x -> pure x
    Data.Aeson.Error _ -> Nothing

  thisValue <- Data.List.find ((== cid) . (^. #customID)) inputs

  thisValue ^. #value

{- | Construct a 'View' containing a 'C.TextInput' with the given style and label

 All other fields of 'C.TextInput' default to 'Nothing'

 This view ensures that a value was passed for an input
-}
textInput ::
  C.TextInputStyle ->
  -- | Label
  Text ->
  View Text
textInput s l = SingView $ \g ->
  let (cid, g') = uniform g
      comp = C.TextInput' $ C.textInput s l cid
      parse = ensure <$> parseTextInput cid
   in (ViewComponent comp parse, g')
  where
    ensure (ExtractOk v (Just x)) = ExtractOk v x
    ensure _ = ExtractFail

{- | Construct a 'View' containing a 'C.TextInput' with the given style and label,
   then modify the component with the passed mapping function
-}
textInput' ::
  C.TextInputStyle ->
  -- | Label
  Text ->
  (C.TextInput -> C.TextInput) ->
  View (Maybe Text)
textInput' s l f = SingView $ \g ->
  let (cid, g') = uniform g
      comp = C.TextInput' . f $ C.textInput s l cid
      parse = parseTextInput cid
   in (ViewComponent comp parse, g')

-- componentIDS :: C.Component -> S.Set CustomID
-- componentIDS (C.ActionRow' coms) = S.unions $ map componentIDS coms
-- componentIDS (C.Button' C.Button {customID}) = S.singleton customID
-- componentIDS (C.LinkButton' _) = S.empty
-- componentIDS (C.Select' C.Select {customID}) = S.singleton customID
-- componentIDS (C.TextInput' C.TextInput {customID}) = S.singleton customID

-- | Generate a 'ViewInstance' of a 'View' by filling in 'CustomID's with random values
instantiateView :: RandomGen g => g -> View a -> (ViewInstance a, g)
instantiateView g v =
  case v of
    NilView x -> (ViewInstance (const $ ExtractOk SomeExtracted x) [], g)
    SingView f ->
      let (ViewComponent c p, g') = f g
          i = ViewInstance p [c]
       in (i, g')
    RowView x ->
      let (v'@ViewInstance {rendered}, g') = instantiateView g x
       in (v' {rendered = [C.ActionRow' rendered]}, g')
    MultView a b ->
      let (ViewInstance ia ra, g') = instantiateView g a
          (ViewInstance ib rb, g'') = instantiateView g' b
          inv i = ia i <*> ib i
       in (ViewInstance inv (ra <> rb), g'')

-- | Delete the initial message containing components
deleteInitialMsg :: (BotC r, P.Member (ViewEff a inp (Either e Message)) r) => P.Sem r ()
deleteInitialMsg = do
  ini <- getSendResponse
  case ini of
    Right m ->
      void . invoke $ DeleteMessage m m
    Left _ -> pure ()

-- | Run a 'View', returning the value passed to 'endView'
--
-- This function will not return until 'endView' is used inside the view.
-- If you want it to run in the background, consider using "Polysemy.Async".
--
-- This is async exception safe, you can use libraries such as
-- [polysemy-conc](https://hackage.haskell.org/package/polysemy-conc) to stop
-- views after a timeout.
runView ::
  BotC r =>
  -- | The initial view to render
  View inp ->
  -- | A function to send the rendered view (i.e. as a message or a modal)
  ([C.Component] -> P.Sem r sendResp) ->
  -- | Your callback effect.
  --
  -- local state semantics are preserved between calls here, you can keep state around
  (inp -> P.Sem (InteractionEff ': ViewEff a inp sendResp ': r) ()) ->
  P.Sem r a
runView v sendRendered m = do
  inst@ViewInstance {rendered} <- getStdRandom (`instantiateView` v)
  r <- sendRendered rendered
  runViewInstance r inst m

{- | Run a prerendered 'View', returning the value passed to 'endView'

 This function won't send the view, you should do that yourself
-}
runViewInstance ::
  BotC r =>
  -- | An initial value to act as the value of @GetSendResponse@
  --
  -- If you just sent a message, probably pass that
  sendResp ->
  -- | The initial view to run
  ViewInstance inp ->
  -- | Your callback effect.
  --
  -- In here you get access to the 'InteractionEff' and 'ViewEff' effects.
  --
  -- local state semantics are preserved between calls here, you can keep state around
  (inp -> P.Sem (InteractionEff ': ViewEff a inp sendResp ': r) ()) ->
  P.Sem r a
runViewInstance initSendResp inst m = P.resourceToIOFinal $ do
  eventIn <- P.embed STM.newTQueueIO

  P.bracket
    (P.raise $ react @'InteractionEvt (P.embed . sender eventIn))
    P.raise
    ( \_ -> do
        P.raise $ innerLoop initSendResp inst eventIn m
    )
  where
    interpretInteraction ::
      forall r.
      Interaction ->
      P.Sem (InteractionEff ': r) () ->
      P.Sem r ()
    interpretInteraction int =
      P.interpret
        ( \case
            GetInteraction -> pure int
        )

    interpretView ::
      forall r ret inp sendResp.
      P.Member (P.Embed IO) r =>
      P.Sem (ViewEff ret inp sendResp ': r) () ->
      P.Sem (P.State (Maybe ret, ViewInstance inp, sendResp) ': r) ()
    interpretView =
      P.reinterpretH
        ( \case
            EndView x -> P.modify' (_1 ?~ x) >>= P.pureT
            ReplaceView v m -> do
              inst@ViewInstance {rendered} <- P.embed $ getStdRandom (`instantiateView` v)
              P.modify' (_2 .~ inst)
              P.runTSimple $ m rendered
            GetSendResponse -> P.gets (^. _3) >>= P.pureT
        )

    innerLoop ::
      forall r ret inp sendResp.
      P.Members '[RatelimitEff, TokenEff, LogEff, MetricEff, P.Embed IO] r =>
      sendResp ->
      ViewInstance inp ->
      STM.TQueue Interaction ->
      (inp -> P.Sem (InteractionEff ': ViewEff ret inp sendResp ': r) ()) ->
      P.Sem r ret
    innerLoop initialSendResp initialInst inChan m = P.evalState (Nothing, initialInst, initialSendResp) innerLoop'
      where
        innerLoop' :: P.Sem (P.State (Maybe ret, ViewInstance inp, sendResp) ': r) ret
        innerLoop' = do
          (s, ViewInstance {extract}, _) <- P.get
          case s of
            Just x -> pure x
            Nothing -> do
              int <- P.embed $ STM.atomically (STM.readTQueue inChan)
              case extract int of
                ExtractOk SomeExtracted x -> interpretView $ interpretInteraction int (m x)
                _ -> pure ()

              -- if Just True == ((`S.member` customIDS) <$> extractCustomID int)
              --   then case extract int of
              --     ExtractOk SomeExtracted x -> interpretView $ interpretInteraction int (m x)
              --     _ -> pure ()
              --   else pure ()
              innerLoop'

    sender :: STM.TQueue Interaction -> Interaction -> IO ()
    sender eventIn int = STM.atomically $ STM.writeTQueue eventIn int