packages feed

calamity-0.1.25.0: src/Calamity/Commands/Dsl.hs

{-# LANGUAGE RecursiveDo #-}

-- | A DSL for generating commands and groups
module Calamity.Commands.Dsl
    ( -- * Commands DSL
      -- $dslTutorial
      command
    , command'
    , commandA
    , commandA'
    , hide
    , help
    , requires
    , requires'
    , requiresPure
    , group
    , group'
    , groupA
    , groupA'
    , DSLState
    , raiseDSL
    , fetchHandler ) where

import           Calamity.Commands.AliasType
import           Calamity.Commands.Check
import           Calamity.Commands.Command     hiding ( help )
import           Calamity.Commands.CommandUtils
import           Calamity.Commands.Context     hiding ( command )
import           Calamity.Commands.Error
import           Calamity.Commands.Group       hiding ( help )
import           Calamity.Commands.Handler
import           Calamity.Internal.LocalWriter

import qualified Data.HashMap.Lazy             as LH
import qualified Data.Text                     as S
import qualified Data.Text.Lazy                as L

import qualified Polysemy                      as P
import qualified Polysemy.Fail                 as P
import qualified Polysemy.Tagged               as P
import qualified Polysemy.Fixpoint             as P
import qualified Polysemy.Reader               as P
import Data.List.NonEmpty (NonEmpty(..))

-- $dslTutorial
--
-- This module provides a way of constructing bot commands in a declarative way.
--
-- The main component of this is the 'command' function, which takes a
-- type-level list of command parameters, the name, and the callback and
-- produces a command. There are also the alternatives 'command'', 'commandA'
-- and 'commandA'', for when you want to handle parsing of the input yourself,
-- and/or want aliases of the command.
--
-- The functions: 'hide', 'help', 'requires', and 'group' can be used to change
-- attributes of any commands declared inside the monadic action passed to them,
-- for example:
--
-- @
-- 'hide' '$' do
--   'command' \@'[] "test" \\ctx -> 'pure' ()
-- @
--
-- In the above block, any command declared inside 'hide' will have it's
-- \'hidden\' flag set and will not be shown by the default help command:
-- 'Calamity.Commands.Help.helpCommand'
--
-- The 'Calamity.Commands.Help.helpCommand' function can be used to create a
-- help command for the commands DSL action it is used in, read it's doc page
-- for more information on how it functions.
--
-- The 'Calamity.Commands.Utils.addCommands' function creates the command
-- handler for the commands registered in the passed action, it is what reads a
-- message to determine what command was invoked. It should be used to register the
-- commands with the bot by using it inside the setup action, for example:
--
-- @
-- 'Calamity.Client.runBotIO' ('Calamity.BotToken' token)
--   $ 'Calamity.Commands.Utils.addCommands' $ do
--     'Calamity.Commands.Help.helpCommand'
--     'Calamity.Commands.Dsl.command' \@'[] "test" \\ctx ->
--       'Control.Monad.void' $ 'Calamity.Types.Tellable.tell' \@'L.Text' ctx "hi"
-- @
--
-- The above block will create a command with no parameters named \'test\',
-- along with a help command.

type DSLState r =
  ( LocalWriter (LH.HashMap S.Text (Command, AliasType))
      ': LocalWriter (LH.HashMap S.Text (Group, AliasType))
      ': P.Reader (Maybe Group)
      ': P.Tagged "hidden" (P.Reader Bool)
      ': P.Reader (Context -> L.Text)
      ': P.Tagged "original-help" (P.Reader (Context -> L.Text))
      ': P.Reader [Check]
      ': P.Reader CommandHandler
      ': P.Fixpoint
      ': r
  )

raiseDSL :: P.Sem r a -> P.Sem (DSLState r) a
raiseDSL = P.raise . P.raise . P.raise . P.raise . P.raise . P.raise . P.raise . P.raise . P.raise

-- | Given the command name and parameter names, @parser@ and @callback@ for a
-- command in the 'P.Sem' monad, build a command by transforming the Polysemy
-- actions into IO actions. Then register the command.
--
-- The parent group, visibility, checks, and command help are drawn from the
-- reader context.
command'
  :: P.Member (P.Final IO) r
  => S.Text
  -- ^ The name of the command
  -> [S.Text]
  -- ^ The names of the command's parameters
  -> (Context -> P.Sem r (Either CommandError a))
  -- ^ The parser for this command
  -> ((Context, a) -> P.Sem (P.Fail ': r) ())
  -- ^ The callback for this command
  -> P.Sem (DSLState r) Command
command' name params parser cb = commandA' name [] params parser cb

-- | Given the command name, aliases, and parameter names, @parser@ and
-- @callback@ for a command in the 'P.Sem' monad, build a command by
-- transforming the Polysemy actions into IO actions. Then register the command.
--
-- The parent group, visibility, checks, and command help are drawn from the
-- reader context.
commandA'
  :: P.Member (P.Final IO) r
  => S.Text
  -- ^ The name of the command
  -> [S.Text]
  -- ^ The aliases for the command
  -> [S.Text]
  -- ^ The names of the command's parameters
  -> (Context -> P.Sem r (Either CommandError a))
  -- ^ The parser for this command
  -> ((Context, a) -> P.Sem (P.Fail ': r) ())
  -- ^ The callback for this command
  -> P.Sem (DSLState r) Command
commandA' name aliases params parser cb = do
  parent <- P.ask @(Maybe Group)
  hidden <- P.tag $ P.ask @Bool
  checks <- P.ask @[Check]
  help' <- P.ask @(Context -> L.Text)
  cmd <- raiseDSL $ buildCommand' (name :| aliases) parent hidden checks params help' parser cb
  ltell $ LH.singleton name (cmd, Original)
  ltell $ LH.fromList [(name, (cmd, Alias)) | name <- aliases]
  pure cmd

-- | Given the name of a command and a callback, and a type level list of
-- the parameters, build and register a command.
--
-- The parent group, visibility, checks, and command help are drawn from the
-- reader context.
--
-- Command parameters are parsed by first invoking
-- 'Calamity.Commands.Parser.parse' for the first
-- 'Calamity.Commands.Parser.Parser', then running the next parser on the
-- remaining input, and so on.
--
-- ==== Examples
--
-- Building a command that bans a user by id.
--
-- @
-- 'command' \@\'['Calamity.Commands.Parser.Named' "user" ('Calamity.Types.Snowflake' 'Calamity.Types.Model.User'),
--                'Calamity.Commands.Parser.Named' "reason" ('Calamity.Commands.Parser.KleeneStarConcat' 'S.Text')]
--    "ban" $ \\ctx uid r -> case (ctx 'Control.Lens.^.' #guild) of
--      'Just' guild -> do
--        'Control.Monad.void' . 'Calamity.HTTP.invoke' $ 'Calamity.HTTP.Guild.CreateGuildBan' guild uid ('Calamity.HTTP.Guild.CreateGuildBanData' 'Nothing' $ 'Just' r)
--        'Control.Monad.void' $ 'Calamity.Types.Tellable.tell' ctx ("Banned user `" '<>' 'TextShow.showt' uid '<>' "` with reason: " '<>' r)
--      'Nothing' -> 'void' $ 'Calamity.Types.Tellable.tell' @'L.Text' ctx "Can only ban users from guilds."
-- @
command :: forall ps r.
        ( P.Member (P.Final IO) r,
          TypedCommandC ps r)
        => S.Text
        -- ^ The name of the command
        -> (Context -> CommandForParsers ps r)
        -- ^ The callback for this command
        -> P.Sem (DSLState r) Command
command name cmd = commandA @ps name [] cmd

-- | Given the name and aliases of a command and a callback, and a type level list of
-- the parameters, build and register a command.
--
-- The parent group, visibility, checks, and command help are drawn from the
-- reader context.
--
-- ==== Examples
--
-- Building a command that bans a user by id.
--
-- @
-- 'commandA' \@\'['Calamity.Commands.Parser.Named' "user" ('Calamity.Types.Snowflake' 'Calamity.Types.Model.User'),
--                'Calamity.Commands.Parser.Named' "reason" ('Calamity.Commands.Parser.KleeneStarConcat' 'S.Text')]
--    "ban" [] $ \\ctx uid r -> case (ctx 'Control.Lens.^.' #guild) of
--      'Just' guild -> do
--        'Control.Monad.void' . 'Calamity.HTTP.invoke' $ 'Calamity.HTTP.Guild.CreateGuildBan' guild uid ('Calamity.HTTP.Guild.CreateGuildBanData' 'Nothing' $ 'Just' r)
--        'Control.Monad.void' $ 'Calamity.Types.Tellable.tell' ctx ("Banned user `" '<>' 'TextShow.showt' uid '<>' "` with reason: " '<>' r)
--      'Nothing' -> 'void' $ 'Calamity.Types.Tellable.tell' @'L.Text' ctx "Can only ban users from guilds."
-- @
commandA :: forall ps r.
        ( P.Member (P.Final IO) r,
          TypedCommandC ps r)
        => S.Text
        -- ^ The name of the command
        -> [S.Text]
        -- ^ The aliases for the command
        -> (Context -> CommandForParsers ps r)
        -- ^ The callback for this command
        -> P.Sem (DSLState r) Command
commandA name aliases cmd = do
  parent <- P.ask @(Maybe Group)
  hidden <- P.tag $ P.ask @Bool
  checks <- P.ask @[Check]
  help' <- P.ask @(Context -> L.Text)
  cmd' <- raiseDSL $ buildCommand @ps (name :| aliases) parent hidden checks help' cmd
  ltell $ LH.singleton name (cmd', Original)
  ltell $ LH.fromList [(name, (cmd', Alias)) | name <- aliases]
  pure cmd'

-- | Set the visibility of any groups or commands registered inside the given
-- action to hidden.
hide :: P.Member (P.Tagged "hidden" (P.Reader Bool)) r
     => P.Sem r a
     -> P.Sem r a
hide = P.tag @"hidden" . P.local @Bool (const True) . P.raise

-- | Set the help for any groups or commands registered inside the given action.
help :: P.Member (P.Reader (Context -> L.Text)) r
     => (Context -> L.Text)
     -> P.Sem r a
     -> P.Sem r a
help = P.local . const

-- | Add to the list of checks for any commands registered inside the given
-- action.
requires :: [Check]
         -> P.Sem (DSLState r) a
         -> P.Sem (DSLState r) a
requires = P.local . (<>)

-- | Construct a check and add it to the list of checks for any commands
-- registered inside the given action.
--
-- Refer to 'Calamity.Commands.Check.Check' for more info on checks.
requires' :: P.Member (P.Final IO) r
          => S.Text
          -- ^ The name of the check
          -> (Context -> P.Sem r (Maybe L.Text))
          -- ^ The callback for the check
          -> P.Sem (DSLState r) a
          -> P.Sem (DSLState r) a
requires' name cb m = do
  check <- raiseDSL $ buildCheck name cb
  requires [check] m

-- | Construct some pure checks and add them to the list of checks for any
-- commands registered inside the given action.
--
-- Refer to 'Calamity.Commands.Check.Check' for more info on checks.
requiresPure :: [(S.Text, Context -> Maybe L.Text)]
             -- A list of check names and check callbacks
             -> P.Sem (DSLState r) a
             -> P.Sem (DSLState r) a
requiresPure checks = requires $ map (uncurry buildCheckPure) checks

-- | Construct a group and place any commands registered in the given action
-- into the new group.
--
-- This also resets the @help@ function back to it's original value, use
-- 'group'' if you don't want that (i.e. your help function is context aware).
group :: P.Member (P.Final IO) r
      => S.Text
      -- ^ The name of the group
      -> P.Sem (DSLState r) a
      -> P.Sem (DSLState r) a
group name m = groupA name [] m

-- | Construct a group with aliases and place any commands registered in the
-- given action into the new group.
--
-- The parent group, visibility, checks, and command help are drawn from the
-- reader context.
--
-- This also resets the @help@ function back to it's original value, use
-- 'group'' if you don't want that (i.e. your help function is context aware).
groupA :: P.Member (P.Final IO) r
       => S.Text
       -- ^ The name of the group
       -> [S.Text]
       -- ^ The aliases of the group
       -> P.Sem (DSLState r) a
       -> P.Sem (DSLState r) a
groupA name aliases m = mdo
  parent <- P.ask @(Maybe Group)
  hidden <- P.tag $ P.ask @Bool
  checks <- P.ask @[Check]
  help'  <- P.ask @(Context -> L.Text)
  origHelp <- fetchOrigHelp
  let group' = Group (name :| aliases) parent hidden commands children help' checks
  (children, (commands, res)) <- llisten @(LH.HashMap S.Text (Group, AliasType)) $
                                 llisten @(LH.HashMap S.Text (Command, AliasType)) $
                                 P.local @(Maybe Group) (const $ Just group') $
                                 P.local @(Context -> L.Text) (const origHelp) m
  ltell $ LH.singleton name (group', Original)
  ltell $ LH.fromList [(name, (group', Alias)) | name <- aliases]
  pure res

fetchOrigHelp :: P.Member (P.Tagged "original-help" (P.Reader (Context -> L.Text))) r => P.Sem r (Context -> L.Text)
fetchOrigHelp = P.tag P.ask

-- | Construct a group and place any commands registered in the given action
-- into the new group.
--
-- The parent group, visibility, checks, and command help are drawn from the
-- reader context.
--
-- Unlike 'help' this doesn't reset the @help@ function back to it's original
-- value.
group' :: P.Member (P.Final IO) r
         => S.Text
         -- The name of the group
         -> P.Sem (DSLState r) a
         -> P.Sem (DSLState r) a
group' name m = groupA' name [] m

-- | Construct a group with aliases and place any commands registered in the given action
-- into the new group.
--
-- The parent group, visibility, checks, and command help are drawn from the
-- reader context.
--
-- Unlike 'help' this doesn't reset the @help@ function back to it's original
-- value.
groupA' :: P.Member (P.Final IO) r
         => S.Text
         -- ^ The name of the group
         -> [S.Text]
         -- ^ The aliases of the group
         -> P.Sem (DSLState r) a
         -> P.Sem (DSLState r) a
groupA' name aliases m = mdo
  parent <- P.ask @(Maybe Group)
  hidden <- P.tag $ P.ask @Bool
  checks <- P.ask @[Check]
  help'  <- P.ask @(Context -> L.Text)
  let group' = Group (name :| aliases) parent hidden commands children help' checks
  (children, (commands, res)) <- llisten @(LH.HashMap S.Text (Group, AliasType)) $
                                 llisten @(LH.HashMap S.Text (Command, AliasType)) $
                                 P.local @(Maybe Group) (const $ Just group') m
  ltell $ LH.singleton name (group', Original)
  ltell $ LH.fromList [(name, (group', Alias)) | name <- aliases]
  pure res

-- | Retrieve the final command handler for this block
fetchHandler :: P.Sem (DSLState r) CommandHandler
fetchHandler = P.ask