packages feed

slack-web-2.2.0.0: src/Web/Slack/Conversation.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeOperators #-}
-- FIXME: squashes warnings
{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}

----------------------------------------------------------------------

----------------------------------------------------------------------

-- |
-- Module: Web.Slack.Channel
-- Description: Types and functions related to <https://api.slack.com/docs/conversations-api Conversation API>
module Web.Slack.Conversation (
  Conversation (..),
  ConversationId (..),
  ConversationType (..),
  ChannelConversation (..),
  GroupConversation (..),
  ImConversation (..),
  TeamId (..),
  Purpose (..),
  Topic (..),
  ListReq (..),
  mkListReq,
  ListRsp (..),
  HistoryReq (..),
  mkHistoryReq,
  HistoryRsp (..),
  RepliesReq (..),
  mkRepliesReq,
  ResponseMetadata (..),
  Api,
  InfoReq (..),
  InfoRsp (..),
  conversationsInfo,
  conversationsInfo_,
  JoinReq (..),
  JoinRsp (..),
  conversationsJoin,
  conversationsJoin_,
) where

import Data.Aeson
import Data.Aeson.Encoding
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.TH
import Data.Aeson.Types
import Data.Scientific
import Data.Text qualified as T
import Servant.API (AuthProtect, FormUrlEncoded, JSON, Post, ReqBody, (:<|>) (..), (:>))
import Servant.Client (ClientM, client)
import Servant.Client.Core (AuthenticatedRequest)
import Web.FormUrlEncoded
import Web.HttpApiData
import Web.Slack.Common
import Web.Slack.Internal (ResponseJSON (..), SlackConfig (..), mkSlackAuthenticateReq, run)
import Web.Slack.Pager (Response)
import Web.Slack.Pager.Types (PagedRequest (..), PagedResponse (..), ResponseMetadata (..))
import Web.Slack.Prelude
import Web.Slack.Util

data Topic = Topic
  { topicValue :: Text
  , topicCreator :: Text
  , topicLastSet :: Integer
  }
  deriving stock (Eq, Show, Generic)

instance NFData Topic

$(deriveJSON (jsonOpts "topic") ''Topic)

data Purpose = Purpose
  { purposeValue :: Text
  , purposeCreator :: Text
  , purposeLastSet :: Integer
  }
  deriving stock (Eq, Show, Generic)

instance NFData Purpose

$(deriveJSON (jsonOpts "purpose") ''Purpose)

-- | Conversation object representing a public channel,
--   which any people in the team can join in and see.
data ChannelConversation = ChannelConversation
  { channelId :: ConversationId
  , channelName :: Text
  , channelCreated :: Integer
  , channelIsArchived :: Bool
  , channelIsGeneral :: Bool
  , channelUnlinked :: Integer
  , channelNameNormalized :: Text
  , channelIsShared :: Bool
  , -- FIXME:
    -- I'm not sure the correct type of this field, because I only found
    -- example responses whose @parent_conversation@ is @null@
    -- , channelParentConversation: null
    channelCreator :: UserId
  , channelIsExtShared :: Bool
  , channelIsOrgShared :: Bool
  , channelSharedTeamIds :: Maybe [TeamId]
  -- ^ Ironically this has been observed to be absent on real shared-channel
  -- responses.
  , -- FIXME:
    -- I'm not sure the correct type of these fields, because I only found
    -- example responses whose @pending_connected_team_ids@ and
    -- @pending_shared@ are empty arrays. (Perhaps this is because
    -- my team is a free account. The names make me guess its type is
    -- @[TeamId]@, but these were not documented as long as I looked up.
    -- , channelPendingShared :: [TeamId]
    -- , channelPendingConnectedTeamIds :: [TeamId]

    channelIsPendingExtShared :: Bool
  , channelIsMember :: Maybe Bool
  -- ^ Absent from @users.conversations@ response
  , channelTopic :: Topic
  , channelPurpose :: Purpose
  , channelPreviousNames :: [Text]
  , channelNumMembers :: Maybe Integer
  -- ^ Absent from @conversations.join@ response
  }
  deriving stock (Eq, Show, Generic)

instance NFData ChannelConversation

$(deriveJSON (jsonOpts "channel") ''ChannelConversation)

-- | Conversation object representing a private channel or
--   _a multi-party instant message (mpim)_, which only invited people in the
--  team can join in and see.
data GroupConversation = GroupConversation
  { groupId :: ConversationId
  , groupName :: Text
  , groupCreated :: Integer
  , groupIsArchived :: Bool
  , groupIsGeneral :: Bool
  , groupUnlinked :: Integer
  , groupNameNormalized :: Text
  , groupIsShared :: Bool
  , -- FIXME:
    -- I'm not sure the correct type of this field, because I only found
    -- example responses whose @parent_conversation@ is @null@
    -- , groupParentConversation :: null

    groupCreator :: UserId
  , groupIsExtShared :: Bool
  , groupIsOrgShared :: Bool
  , groupSharedTeamIds :: [TeamId]
  , -- FIXME:
    -- I'm not sure the correct type of these fields, because I only found
    -- example responses whose @pending_connected_team_ids@ and
    -- @pending_shared@ are empty arrays. (Perhaps this is because
    -- my team is a free account. The names make me guess its type is
    -- @[TeamId]@, but these were not documented as long as I looked up.
    -- , group_pending_shared :: []
    -- , group_pending_connected_team_ids :: []

    groupIsPendingExtShared :: Bool
  , groupIsMember :: Bool
  , groupIsPrivate :: Bool
  , groupIsMpim :: Bool
  , groupLastRead :: SlackTimestamp
  , groupIsOpen :: Bool
  , groupTopic :: Topic
  , groupPurpose :: Purpose
  , groupPriority :: Scientific
  }
  deriving stock (Eq, Show, Generic)

instance NFData GroupConversation

$(deriveJSON (jsonOpts "group") ''GroupConversation)

-- | Conversation object representing a (single-party) instance message,
--   where only two people talk.
data ImConversation = ImConversation
  { imId :: ConversationId
  , imCreated :: Integer
  , imIsArchived :: Bool
  , imIsOrgShared :: Bool
  , imUser :: UserId
  , imIsUserDeleted :: Maybe Bool
  , imPriority :: Scientific
  }
  deriving stock (Eq, Show, Generic)

instance NFData ImConversation

$(deriveJSON (jsonOpts "im") ''ImConversation)

-- | Ref. https://api.slack.com/types/conversation
data Conversation
  = Channel ChannelConversation
  | Group GroupConversation
  | Im ImConversation
  deriving stock (Eq, Show, Generic)

instance NFData Conversation

instance FromJSON Conversation where
  parseJSON = withObject "Conversation" $ \o ->
    fromMaybe (noneMatched o)
      =<< parseWhen "is_channel" Channel o
      `parseOr` parseWhen "is_group" Group o
      `parseOr` parseWhen "is_im" Im o
    where
      noneMatched o =
        prependFailure
          "parsing a Conversation failed: neither channel, group, nor im: "
          (typeMismatch "Conversation" (Object o))

      -- '(<|>)' that pierces one layer of 'Monad' first
      parseOr :: (Monad m, Alternative a) => m (a b) -> m (a b) -> m (a b)
      parseOr = liftM2 (<|>)

      -- This uses the outer Parser monad since deciding which parser to use
      -- is monadic, then the Maybe to decide which parser is picked, then
      -- finally the inner parser to actually run it
      parseWhen :: (FromJSON a) => Key -> (a -> b) -> Object -> Parser (Maybe (Parser b))
      parseWhen key con o = do
        -- Slack only inconsistently includes the is_* attributes if false.
        is <- o .:? key .!= False
        if is
          then pure . Just $ con <$> parseJSON (Object o)
          else pure $ Nothing

instance ToJSON Conversation where
  toJSON (Channel channel) =
    let (Object obj) = toJSON channel
     in Object
          . KM.insert "is_channel" (Bool True)
          . KM.insert "is_group" (Bool False)
          $ KM.insert "is_im" (Bool False) obj
  toJSON (Group theGroup) =
    let (Object obj) = toJSON theGroup
     in Object
          . KM.insert "is_channel" (Bool False)
          . KM.insert "is_group" (Bool True)
          $ KM.insert "is_im" (Bool False) obj
  toJSON (Im im) =
    let (Object obj) = toJSON im
     in Object
          . KM.insert "is_channel" (Bool False)
          . KM.insert "is_group" (Bool False)
          $ KM.insert "is_im" (Bool True) obj

data ConversationType
  = PublicChannelType
  | PrivateChannelType
  | MpimType
  | ImType
  deriving stock (Eq, Show, Generic)

instance NFData ConversationType

instance ToHttpApiData ConversationType where
  toUrlPiece PublicChannelType = "public_channel"
  toUrlPiece PrivateChannelType = "private_channel"
  toUrlPiece MpimType = "mpim"
  toUrlPiece ImType = "im"

instance ToJSON ConversationType where
  toJSON = toJSON . toUrlPiece
  toEncoding = text . toUrlPiece

instance FromJSON ConversationType where
  parseJSON = withText "ConversationType" $ \case
    "public_channel" -> pure PublicChannelType
    "private_channel" -> pure PrivateChannelType
    "mpim" -> pure MpimType
    "im" -> pure ImType
    actual ->
      prependFailure "must be either \"public_channel\", \"private_channel\", \"mpim\" or \"im\"!"
        . typeMismatch "ConversationType"
        $ String actual

data ListReq = ListReq
  { listReqExcludeArchived :: Maybe Bool
  , listReqTypes :: [ConversationType]
  , listReqCursor :: Maybe Cursor
  , listReqLimit :: Maybe Int
  , listReqTeamId :: Maybe TeamId
  }
  deriving stock (Eq, Show, Generic)

instance NFData ListReq

$(deriveJSON (jsonOpts "listReq") ''ListReq)

mkListReq ::
  ListReq
mkListReq =
  ListReq
    { listReqExcludeArchived = Nothing
    , listReqTypes = []
    , listReqLimit = Nothing
    , listReqTeamId = Nothing
    , listReqCursor = Nothing
    }

instance ToForm ListReq where
  toForm
    ( ListReq
        { listReqExcludeArchived
        , listReqTypes = types
        , listReqTeamId
        , listReqCursor
        , listReqLimit
        }
      ) =
      archivedForm
        <> typesForm
        <> toQueryParamIfJust "team_id" listReqTeamId
        <> toQueryParamIfJust "cursor" listReqCursor
        <> toQueryParamIfJust "limit" listReqLimit
      where
        archivedForm =
          maybe mempty (\val -> [("exclude_archived", toUrlPiece val)]) listReqExcludeArchived
        typesForm =
          if null types
            then mempty
            else [("types", T.intercalate "," $ map toUrlPiece types)]

data ListRsp = ListRsp
  { listRspChannels :: [Conversation]
  , listRspResponseMetadata :: Maybe ResponseMetadata
  }
  deriving stock (Eq, Show, Generic)

instance NFData ListRsp

$(deriveFromJSON (jsonOpts "listRsp") ''ListRsp)

instance PagedRequest ListReq where
  setCursor c r = r {listReqCursor = c}

instance PagedResponse ListRsp where
  type ResponseObject ListRsp = Conversation
  getResponseData ListRsp {listRspChannels} = listRspChannels
  getResponseMetadata ListRsp {listRspResponseMetadata} = listRspResponseMetadata

data HistoryReq = HistoryReq
  { historyReqChannel :: ConversationId
  , historyReqCursor :: Maybe Cursor
  , historyReqCount :: Int
  , historyReqLatest :: Maybe SlackTimestamp
  , historyReqOldest :: Maybe SlackTimestamp
  , historyReqInclusive :: Bool
  }
  deriving stock (Eq, Show, Generic)

instance NFData HistoryReq

$(deriveJSON (jsonOpts "historyReq") ''HistoryReq)

mkHistoryReq ::
  ConversationId ->
  HistoryReq
mkHistoryReq channel =
  HistoryReq
    { historyReqChannel = channel
    , historyReqCursor = Nothing
    , historyReqCount = 100
    , historyReqLatest = Nothing
    , historyReqOldest = Nothing
    , historyReqInclusive = True
    }

instance ToForm HistoryReq where
  -- can't use genericToForm because slack expects booleans as 0/1
  toForm HistoryReq {..} =
    [("channel", toQueryParam historyReqChannel)]
      <> toQueryParamIfJust "cursor" historyReqCursor
      <> [("count", toQueryParam historyReqCount)]
      <> toQueryParamIfJust "latest" historyReqLatest
      <> toQueryParamIfJust "oldest" historyReqOldest
      <> [("inclusive", toQueryParam (if historyReqInclusive then 1 :: Int else 0))]

data HistoryRsp = HistoryRsp
  { historyRspMessages :: [Message]
  , historyRspResponseMetadata :: Maybe ResponseMetadata
  }
  deriving stock (Eq, Show, Generic)

instance NFData HistoryRsp

$(deriveJSON (jsonOpts "historyRsp") ''HistoryRsp)

instance PagedRequest HistoryReq where
  setCursor c r = r {historyReqCursor = c}

instance PagedResponse HistoryRsp where
  type ResponseObject HistoryRsp = Message
  getResponseMetadata HistoryRsp {historyRspResponseMetadata} = historyRspResponseMetadata
  getResponseData HistoryRsp {historyRspMessages} = historyRspMessages

data RepliesReq = RepliesReq
  { repliesReqTs :: SlackTimestamp
  , repliesReqCursor :: Maybe Cursor
  , repliesReqChannel :: ConversationId
  , repliesReqLimit :: Int
  , repliesReqLatest :: Maybe SlackTimestamp
  , repliesReqOldest :: Maybe SlackTimestamp
  , repliesReqInclusive :: Bool
  }
  deriving stock (Eq, Show, Generic)

instance NFData RepliesReq

$(deriveJSON (jsonOpts "repliesReq") ''RepliesReq)

instance ToForm RepliesReq where
  -- can't use genericToForm because slack expects booleans as 0/1
  toForm RepliesReq {..} =
    [("channel", toQueryParam repliesReqChannel)]
      <> [("ts", toQueryParam repliesReqTs)]
      <> toQueryParamIfJust "cursor" repliesReqCursor
      <> [("limit", toQueryParam repliesReqLimit)]
      <> toQueryParamIfJust "latest" repliesReqLatest
      <> toQueryParamIfJust "oldest" repliesReqOldest
      <> [("inclusive", toQueryParam (if repliesReqInclusive then 1 :: Int else 0))]

instance PagedRequest RepliesReq where
  setCursor c r = r {repliesReqCursor = c}

mkRepliesReq ::
  ConversationId ->
  SlackTimestamp ->
  RepliesReq
mkRepliesReq channel ts =
  RepliesReq
    { repliesReqChannel = channel
    , repliesReqCursor = Nothing
    , repliesReqTs = ts
    , repliesReqLimit = 100
    , repliesReqLatest = Nothing
    , repliesReqOldest = Nothing
    , repliesReqInclusive = True
    }

-- | @conversations.info@ request: retrieve a conversation's metadata.
--
-- <https://api.slack.com/methods/conversations.info>
--
-- @since 2.2.0.0
data InfoReq = InfoReq
  { infoReqChannel :: ConversationId
  , infoReqIncludeLocale :: Maybe Bool
  , infoReqIncludeNumMembers :: Maybe Bool
  }
  deriving stock (Eq, Show, Generic)

instance ToForm InfoReq where
  toForm InfoReq {..} =
    [("channel", unConversationId infoReqChannel)]
      <> toQueryParamIfJust "include_locale" infoReqIncludeLocale
      <> toQueryParamIfJust "include_num_members" infoReqIncludeNumMembers

-- | @conversations.info@ response
--
-- <https://api.slack.com/methods/conversations.info>
--
-- @since 2.2.0.0
data InfoRsp = InfoRsp
  { infoRspChannel :: Conversation
  }
  deriving stock (Eq, Show, Generic)

$(deriveJSON (jsonOpts "infoRsp") ''InfoRsp)

-- | FIXME(jadel): move the rest of the Conversations API into here since the old "shoving all the API in one spot" is soft deprecated.
-- @since 2.2.0.0
type Api =
  "conversations.info"
    :> AuthProtect "token"
    :> ReqBody '[FormUrlEncoded] InfoReq
    :> Post '[JSON] (ResponseJSON InfoRsp)
    :<|> "conversations.join"
      :> AuthProtect "token"
      :> ReqBody '[FormUrlEncoded] JoinReq
      :> Post '[JSON] (ResponseJSON JoinRsp)

-- | Joins a conversation.
--
-- <https://api.slack.com/methods/conversations.join>
data JoinReq = JoinReq
  { joinReqChannel :: ConversationId
  }
  deriving stock (Eq, Show, Generic)

instance ToForm JoinReq where
  toForm = genericToForm (formOpts "joinReq")

-- |
-- @
-- {
--     "ok": true,
--     "channel": {
--         "id": "C061EG9SL",
--         "name": "general",
--         "is_channel": true,
--         "is_group": false,
--         "is_im": false,
--         "created": 1449252889,
--         "creator": "U061F7AUR",
--         "is_archived": false,
--         "is_general": true,
--         "unlinked": 0,
--         "name_normalized": "general",
--         "is_shared": false,
--         "is_ext_shared": false,
--         "is_org_shared": false,
--         "pending_shared": [],
--         "is_pending_ext_shared": false,
--         "is_member": true,
--         "is_private": false,
--         "is_mpim": false,
--         "topic": {
--             "value": "Which widget do you worry about?",
--             "creator": "",
--             "last_set": 0
--         },
--         "purpose": {
--             "value": "For widget discussion",
--             "creator": "",
--             "last_set": 0
--         },
--         "previous_names": []
--     },
--     "warning": "already_in_channel",
--     "response_metadata": {
--         "warnings": [
--             "already_in_channel"
--         ]
--     }
-- }
-- @
data JoinRsp = JoinRsp
  { joinRspChannel :: Conversation
  }
  deriving stock (Show)

$(deriveFromJSON (jsonOpts "joinRsp") ''JoinRsp)

-- | @since 2.2.0.0
conversationsJoin_ :: AuthenticatedRequest (AuthProtect "token") -> JoinReq -> ClientM (ResponseJSON JoinRsp)

-- | @since 2.2.0.0
conversationsInfo_ ::
  AuthenticatedRequest (AuthProtect "token") ->
  InfoReq ->
  ClientM (ResponseJSON InfoRsp)
conversationsInfo_ :<|> conversationsJoin_ = client (Proxy @Api)

-- | Retrieve a conversation's metadata.
--
-- <https://api.slack.com/methods/conversations.info>
--
-- @since 2.2.0.0
conversationsInfo ::
  SlackConfig ->
  InfoReq ->
  IO (Response InfoRsp)
conversationsInfo = flip $ \listReq -> do
  authR <- mkSlackAuthenticateReq
  run (conversationsInfo_ authR listReq) . slackConfigManager

-- | Joins a conversation.
--
-- <https://api.slack.com/methods/conversations.join>
--
-- @since 2.2.0.0
conversationsJoin ::
  SlackConfig ->
  JoinReq ->
  IO (Response JoinRsp)
conversationsJoin slackConfig req = do
  let authR = mkSlackAuthenticateReq slackConfig
  run (conversationsJoin_ authR req) . slackConfigManager $ slackConfig