slack-web 2.1.0.0 → 2.2.0.0
raw patch · 12 files changed
+408/−7 lines, 12 filesdep ~http-api-data
Dependency ranges changed: http-api-data
Files
- CHANGELOG.md +8/−0
- slack-web.cabal +5/−1
- src/Web/Slack/Conversation.hs +157/−2
- src/Web/Slack/Internal.hs +2/−0
- src/Web/Slack/Pager.hs +4/−2
- tests/Web/Slack/ConversationSpec.hs +2/−0
- tests/golden/InfoRsp/general.golden +37/−0
- tests/golden/InfoRsp/general.json +49/−0
- tests/golden/InfoRsp/im.json +71/−0
- tests/golden/JoinRsp/test.golden +37/−0
- tests/golden/JoinRsp/test.json +34/−0
- tests/golden/UsersConversationsResponse/im_and_channels.golden +2/−2
CHANGELOG.md view
@@ -1,3 +1,11 @@+# 2.1.1.0+* [#145](https://github.com/MercuryTechnologies/slack-web/pull/145)+ Implement `conversations.info` API method.++ Breaking change: imIsUserDeleted is now Maybe to reflect reality.+* [#146](https://github.com/MercuryTechnologies/slack-web/pull/146)+ Implement `conversations.join` API method.+ # 2.1.0.0 (2025-03-06) * [#138](https://github.com/MercuryTechnologies/slack-web/pull/138) Implement `views.publish` method and App Home tab events.
slack-web.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: slack-web-version: 2.1.0.0+version: 2.2.0.0 build-type: Simple @@ -35,6 +35,10 @@ tests/golden/PublishResp/*.golden tests/golden/ResponseJSON/*.json tests/golden/ResponseJSON/*.golden+ tests/golden/InfoRsp/*.golden+ tests/golden/InfoRsp/*.json+ tests/golden/JoinRsp/*.json+ tests/golden/JoinRsp/*.golden category: Web
src/Web/Slack/Conversation.hs view
@@ -5,6 +5,7 @@ {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE StrictData #-} {-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE TypeOperators #-} -- FIXME: squashes warnings {-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-} @@ -34,6 +35,15 @@ RepliesReq (..), mkRepliesReq, ResponseMetadata (..),+ Api,+ InfoReq (..),+ InfoRsp (..),+ conversationsInfo,+ conversationsInfo_,+ JoinReq (..),+ JoinRsp (..),+ conversationsJoin,+ conversationsJoin_, ) where import Data.Aeson@@ -43,9 +53,14 @@ 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@@ -118,7 +133,7 @@ $(deriveJSON (jsonOpts "channel") ''ChannelConversation) -- | Conversation object representing a private channel or--- _a multi-party instant message (mpim)*, which only invited people in the+-- _a multi-party instant message (mpim)_, which only invited people in the -- team can join in and see. data GroupConversation = GroupConversation { groupId :: ConversationId@@ -171,7 +186,7 @@ , imIsArchived :: Bool , imIsOrgShared :: Bool , imUser :: UserId- , imIsUserDeleted :: Bool+ , imIsUserDeleted :: Maybe Bool , imPriority :: Scientific } deriving stock (Eq, Show, Generic)@@ -429,3 +444,143 @@ , 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
src/Web/Slack/Internal.hs view
@@ -23,6 +23,8 @@ newtype ResponseJSON a = ResponseJSON (Either Common.ResponseSlackError a) deriving stock (Show) +type role ResponseJSON representational+ instance (FromJSON a) => FromJSON (ResponseJSON a) where parseJSON = withObject "Response" $ \o -> do ok <- o .: "ok"
src/Web/Slack/Pager.hs view
@@ -6,8 +6,8 @@ module Web.Slack.Pager.Types, ) where +import Data.Kind (Type) import Web.Slack.Common qualified as Common-import Web.Slack.Conversation qualified as Conversation import Web.Slack.Pager.Types import Web.Slack.Prelude @@ -19,6 +19,8 @@ -- If there is no more response, the action returns an empty list. type LoadPage m a = m (Response [a]) +type LoadPage :: forall {k}. (Type -> k) -> Type -> k+ -- | Utility function for 'LoadPage'. Perform the 'LoadPage' action to call -- the function with the loaded page, until an empty page is loaded. loadingPage :: (Monad m, Monoid n) => LoadPage m a -> (Response [a] -> m n) -> m n@@ -46,7 +48,7 @@ let requestFromCursor cursor = setCursor cursor initialRequest collectAndUpdateCursor resp = do- let newCursor = Conversation.responseMetadataNextCursor =<< getResponseMetadata resp+ let newCursor = responseMetadataNextCursor =<< getResponseMetadata resp -- emptyCursor is used for the marker to show that there are no more pages. cursorToSave = if isNothing newCursor then emptyCursor else newCursor writeIORef cursorRef cursorToSave
tests/Web/Slack/ConversationSpec.hs view
@@ -114,6 +114,8 @@ describe "Golden tests" $ do mapM_ (oneGoldenTestDecode @Conversation) ["shared_channel"]+ mapM_ (oneGoldenTestDecode @InfoRsp) ["general"]+ mapM_ (oneGoldenTestDecode @JoinRsp) ["test"] it "errors accurately if no variant matches" $ do let badData =
+ tests/golden/InfoRsp/general.golden view
@@ -0,0 +1,37 @@+InfoRsp+ { infoRspChannel = Channel+ ( ChannelConversation+ { channelId = ConversationId+ { unConversationId = "C043YJGBY49" }+ , channelName = "general"+ , channelCreated = 1663890553+ , channelIsArchived = False+ , channelIsGeneral = True+ , channelUnlinked = 0+ , channelNameNormalized = "general"+ , channelIsShared = False+ , channelCreator = UserId+ { unUserId = "U043H11ES4V" }+ , channelIsExtShared = False+ , channelIsOrgShared = False+ , channelSharedTeamIds = Just+ [ TeamId+ { unTeamId = "T043DB835ML" }+ ]+ , channelIsPendingExtShared = False+ , channelIsMember = Just True+ , channelTopic = Topic+ { topicValue = ""+ , topicCreator = ""+ , topicLastSet = 0+ }+ , channelPurpose = Purpose+ { purposeValue = "This is the one channel that will always include everyone. It’s a great spot for announcements and team-wide conversations."+ , purposeCreator = "U043H11ES4V"+ , purposeLastSet = 1663890553+ }+ , channelPreviousNames = []+ , channelNumMembers = Nothing+ }+ )+ }
+ tests/golden/InfoRsp/general.json view
@@ -0,0 +1,49 @@+{+ "ok": true,+ "channel": {+ "id": "C043YJGBY49",+ "name": "general",+ "is_channel": true,+ "is_group": false,+ "is_im": false,+ "is_mpim": false,+ "is_private": false,+ "created": 1663890553,+ "is_archived": false,+ "is_general": true,+ "unlinked": 0,+ "name_normalized": "general",+ "is_shared": false,+ "is_org_shared": false,+ "is_pending_ext_shared": false,+ "pending_shared": [],+ "context_team_id": "T043DB835ML",+ "updated": 1740098274213,+ "parent_conversation": null,+ "creator": "U043H11ES4V",+ "is_read_only": false,+ "is_thread_only": false,+ "is_non_threadable": false,+ "is_ext_shared": false,+ "shared_team_ids": [+ "T043DB835ML"+ ],+ "pending_connected_team_ids": [],+ "is_member": true,+ "last_read": "1740098745.617639",+ "topic": {+ "value": "",+ "creator": "",+ "last_set": 0+ },+ "purpose": {+ "value": "This is the one channel that will always include everyone. It’s a great spot for announcements and team-wide conversations.",+ "creator": "U043H11ES4V",+ "last_set": 1663890553+ },+ "properties": {+ "use_case": "welcome"+ },+ "previous_names": []+ }+}
+ tests/golden/InfoRsp/im.json view
@@ -0,0 +1,71 @@+{+ "ok": true,+ "channel": {+ "id": "D0442US94JD",+ "created": 1663960005,+ "is_archived": false,+ "is_im": true,+ "is_org_shared": false,+ "context_team_id": "T043DB835ML",+ "updated": 1740098832769,+ "user": "U043H11ES4V",+ "last_read": "1671145573.165309",+ "latest": {+ "user": "U0442US8QGH",+ "type": "message",+ "ts": "1741206064.642559",+ "bot_id": "B0439P161B9",+ "app_id": "A0442TUPHGR",+ "text": "Done!",+ "team": "T043DB835ML",+ "bot_profile": {+ "id": "B0439P161B9",+ "deleted": false,+ "name": "Slacklinker dev",+ "updated": 1740097237,+ "app_id": "A0442TUPHGR",+ "icons": {+ "image_36": "https://a.slack-edge.com/80588/img/plugins/app/bot_36.png",+ "image_48": "https://a.slack-edge.com/80588/img/plugins/app/bot_48.png",+ "image_72": "https://a.slack-edge.com/80588/img/plugins/app/service_72.png"+ },+ "team_id": "T043DB835ML"+ },+ "blocks": [+ {+ "type": "rich_text",+ "block_id": "rdhT",+ "elements": [+ {+ "type": "rich_text_section",+ "elements": [+ {+ "type": "text",+ "text": "Done!"+ }+ ]+ }+ ]+ }+ ]+ },+ "unread_count": 48,+ "unread_count_display": 4,+ "is_open": true,+ "properties": {+ "tabs": [+ {+ "type": "files",+ "label": "",+ "id": "files"+ }+ ],+ "tabz": [+ {+ "type": "files"+ }+ ]+ },+ "priority": 0+ }+}
+ tests/golden/JoinRsp/test.golden view
@@ -0,0 +1,37 @@+JoinRsp+ { joinRspChannel = Channel+ ( ChannelConversation+ { channelId = ConversationId+ { unConversationId = "C0451SKQN72" }+ , channelName = "new-channel-2"+ , channelCreated = 1664407230+ , channelIsArchived = False+ , channelIsGeneral = False+ , channelUnlinked = 0+ , channelNameNormalized = "new-channel-2"+ , channelIsShared = False+ , channelCreator = UserId+ { unUserId = "U043H11ES4V" }+ , channelIsExtShared = False+ , channelIsOrgShared = False+ , channelSharedTeamIds = Just+ [ TeamId+ { unTeamId = "T043DB835ML" }+ ]+ , channelIsPendingExtShared = False+ , channelIsMember = Just True+ , channelTopic = Topic+ { topicValue = ""+ , topicCreator = ""+ , topicLastSet = 0+ }+ , channelPurpose = Purpose+ { purposeValue = ""+ , purposeCreator = ""+ , purposeLastSet = 0+ }+ , channelPreviousNames = []+ , channelNumMembers = Nothing+ }+ )+ }
+ tests/golden/JoinRsp/test.json view
@@ -0,0 +1,34 @@+{+ "ok": true,+ "channel": {+ "id": "C0451SKQN72",+ "name": "new-channel-2",+ "is_channel": true,+ "is_group": false,+ "is_im": false,+ "is_mpim": false,+ "is_private": false,+ "created": 1664407230,+ "is_archived": false,+ "is_general": false,+ "unlinked": 0,+ "name_normalized": "new-channel-2",+ "is_shared": false,+ "is_org_shared": false,+ "is_pending_ext_shared": false,+ "pending_shared": [],+ "context_team_id": "T043DB835ML",+ "parent_conversation": null,+ "creator": "U043H11ES4V",+ "is_ext_shared": false,+ "shared_team_ids": ["T043DB835ML"],+ "pending_connected_team_ids": [],+ "is_member": true,+ "last_read": "1664407230.583859",+ "topic": { "value": "", "creator": "", "last_set": 0 },+ "purpose": { "value": "", "creator": "", "last_set": 0 },+ "previous_names": [],+ "priority": 0+ }+}+
tests/golden/UsersConversationsResponse/im_and_channels.golden view
@@ -79,7 +79,7 @@ , imIsOrgShared = False , imUser = UserId { unUserId = "U043H11ES4V" }- , imIsUserDeleted = False+ , imIsUserDeleted = Just False , imPriority = 0.0 } )@@ -92,7 +92,7 @@ , imIsOrgShared = False , imUser = UserId { unUserId = "USLACKBOT" }- , imIsUserDeleted = False+ , imIsUserDeleted = Just False , imPriority = 0.0 } )