-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[#64] Add possibility to run tzbot as server
Problem: only server-like Slack apps can be published in the Slack App Directory. Solution: Allow to choose how to run the server, using common handler functions.
- Loading branch information
1 parent
635077e
commit 73e8807
Showing
12 changed files
with
501 additions
and
142 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
-- SPDX-FileCopyrightText: 2022 Serokell <https://serokell.io/> | ||
-- | ||
-- SPDX-License-Identifier: MPL-2.0 | ||
|
||
module TzBot.BotMain.Common where | ||
|
||
|
||
import Universum | ||
|
||
import Control.Monad.Managed (Managed, managed) | ||
import Network.HTTP.Client (newManager) | ||
import Network.HTTP.Client.TLS (tlsManagerSettings) | ||
import Time (hour) | ||
|
||
import TzBot.Cache | ||
(TzCacheSettings(tcsExpiryRandomAmplitudeFraction), defaultTzCacheSettings, withTzCache, | ||
withTzCacheDefault) | ||
import TzBot.Config | ||
import TzBot.Config.Types (BotConfig) | ||
import TzBot.Logger | ||
import TzBot.RunMonad | ||
import TzBot.Util | ||
|
||
withBotState :: BotConfig -> Managed BotState | ||
withBotState bsConfig@Config {..} = do | ||
let fifteenPercentAmplitudeSettings = defaultTzCacheSettings | ||
{ tcsExpiryRandomAmplitudeFraction = Just 0.15 | ||
} | ||
|
||
bsManager <- liftIO $ newManager tlsManagerSettings | ||
bsFeedbackConfig <- | ||
managed $ withFeedbackConfig bsConfig | ||
bsUserInfoCache <- | ||
managed $ withTzCache fifteenPercentAmplitudeSettings cCacheUsersInfo | ||
|
||
bsConversationMembersCache <- | ||
managed $ withTzCache fifteenPercentAmplitudeSettings cCacheConversationMembers | ||
let defaultMessageInfoCachingTime = hour 1 | ||
bsMessageCache <- | ||
managed $ withTzCacheDefault defaultMessageInfoCachingTime | ||
bsMessageLinkCache <- | ||
managed $ withTzCacheDefault defaultMessageInfoCachingTime | ||
bsReportEntries <- | ||
managed $ withTzCacheDefault cCacheReportDialog | ||
-- auto-acknowledge received messages | ||
(bsLogNamespace, bsLogContext, bsLogEnv) <- managed $ withLogger cLogLevel | ||
pure BotState {..} | ||
|
||
withFeedbackConfig :: BotConfig -> (FeedbackConfig -> IO a) -> IO a | ||
withFeedbackConfig Config {..} action = do | ||
let fcFeedbackChannel = cFeedbackChannel | ||
withFeedbackFile cFeedbackFile $ \fcFeedbackFile -> | ||
action FeedbackConfig {..} | ||
where | ||
withFeedbackFile :: Maybe FilePath -> (Maybe Handle -> IO a) -> IO a | ||
withFeedbackFile mbPath action = | ||
withMaybe mbPath (action Nothing) $ \path -> | ||
withFile path AppendMode (action . Just) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
-- SPDX-FileCopyrightText: 2022 Serokell <https://serokell.io/> | ||
-- | ||
-- SPDX-License-Identifier: MPL-2.0 | ||
|
||
module TzBot.BotMain.Server where | ||
|
||
import Universum | ||
|
||
import Control.Monad.Managed (runManaged) | ||
import Data.Aeson (FromJSON(..), Value) | ||
import Data.Aeson.Encode.Pretty (encodePrettyToTextBuilder) | ||
import Data.Aeson.Types (parseEither) | ||
import Network.Wai.Handler.Warp (defaultSettings) | ||
import Network.Wai.Handler.Warp qualified as Warp | ||
import Servant | ||
(Application, FormUrlEncoded, Handler(Handler), Header, JSON, NoContent(..), PlainText, Post, | ||
ReqBody, type (:>)) | ||
import Servant.API.Generic ((:-)) | ||
import Servant.Server.Generic (genericServeT) | ||
import Slacker (SlashCommand) | ||
import Slacker.SocketMode (EventWrapper) | ||
import Text.Interpolation.Nyan (int, rmode', rmode's) | ||
import UnliftIO (async, try) | ||
import Web.FormUrlEncoded (FromForm(..), genericFromForm) | ||
|
||
import TzBot.BotMain.Common (withBotState) | ||
import TzBot.BotMain.Server.Extractors | ||
(pattern BlockActionServer, pattern EventValueServer, pattern InteractiveServer) | ||
import TzBot.Config (readConfig) | ||
import TzBot.Logger (logError) | ||
import TzBot.Options (RunServerOptions(..)) | ||
import TzBot.ProcessEvents | ||
(handleRawBlockAction, handleRawEvent, handleRawInteractive, handleSlashCommand) | ||
import TzBot.RunMonad (BotM, BotState, runBotM) | ||
import TzBot.Util (defaultFromFormOptions) | ||
|
||
type ReqIdHeader = Header "X-Slack-Request-Timestamp" Text | ||
|
||
data Routes mode = Routes | ||
{ rCommon :: mode | ||
:- ReqIdHeader | ||
:> ReqBody '[JSON] Value | ||
:> Post '[PlainText] NoContent | ||
, rHelp :: mode | ||
:- "help" | ||
:> ReqBody '[FormUrlEncoded] SlashCommand | ||
:> Post '[PlainText] NoContent | ||
, rInteractive :: mode | ||
:- "interactive" | ||
:> ReqIdHeader | ||
:> ReqBody '[FormUrlEncoded] InteractiveRequest | ||
:> Post '[PlainText] NoContent | ||
} deriving stock (Generic) | ||
|
||
runServer :: RunServerOptions -> IO () | ||
runServer opts = do | ||
let mbConfigFilePath = rsoConfigFile opts | ||
bsConfig <- readConfig mbConfigFilePath | ||
let settings = Warp.setPort 8080 defaultSettings | ||
runManaged do | ||
botState <- withBotState bsConfig | ||
liftIO $ Warp.runSettings settings $ app botState | ||
where | ||
app :: BotState -> Application | ||
app bState = genericServeT (naturalTransformation bState) Routes | ||
{ rCommon = handleEvent | ||
, rHelp = handleCommand | ||
, rInteractive = handleInteractive | ||
} | ||
|
||
-- | Here we never report any errors to Slack so never return `ServerError` | ||
naturalTransformation :: BotState -> BotM a -> Handler a | ||
naturalTransformation botState action = Handler $ lift $ runBotM botState action | ||
|
||
---------------------------------------------------------------------------- | ||
---- Subscribed events | ||
---------------------------------------------------------------------------- | ||
handleEvent :: Maybe Text -> Value -> BotM NoContent | ||
handleEvent mbReqTimestamp val = forkAndReturnAck $ do | ||
let logTag = fromMaybe "unknown" mbReqTimestamp | ||
let eventWrapper = parseEither parseJSON val :: Either String EventWrapper | ||
case eventWrapper of | ||
Left err -> do | ||
logError [int||Unrecognized EventWrapper: #{err}|] | ||
logError [int||Full EventWrapper value: #{encodePrettyToTextBuilder val}|] | ||
Right ew -> case ew of | ||
EventValueServer typ val -> handleRawEvent logTag typ val | ||
_ -> logError [int||Invalid Event: #s{ew}|] | ||
|
||
---------------------------------------------------------------------------- | ||
---- Interactive (including block actions) | ||
---------------------------------------------------------------------------- | ||
newtype InteractiveRequest = InteractiveRequest | ||
{ irPayload :: Value | ||
} deriving stock (Generic) | ||
|
||
instance FromForm InteractiveRequest where | ||
fromForm = genericFromForm defaultFromFormOptions | ||
|
||
handleInteractive :: Maybe Text -> InteractiveRequest -> BotM NoContent | ||
handleInteractive mbReqTimestamp req = forkAndReturnAck do | ||
let logTag = fromMaybe "unknown" mbReqTimestamp | ||
intValue = irPayload req | ||
case intValue of | ||
BlockActionServer actionId blockActionRaw -> | ||
handleRawBlockAction logTag actionId blockActionRaw | ||
InteractiveServer typ interactiveRaw -> | ||
handleRawInteractive logTag typ interactiveRaw | ||
_ -> logError [int||Unrecognized interactive event: #{encodePrettyToTextBuilder intValue}|] | ||
|
||
---------------------------------------------------------------------------- | ||
---- Commands | ||
---------------------------------------------------------------------------- | ||
handleCommand :: SlashCommand -> BotM NoContent | ||
handleCommand slashCmd = forkAndReturnAck $ handleSlashCommand slashCmd | ||
|
||
---------------------------------------------------------------------------- | ||
---- Common | ||
---------------------------------------------------------------------------- | ||
|
||
-- | Slack advices to send ack response as soon as possible, so we run the actual | ||
-- handler in a separate async (without caring about its further destiny) | ||
forkAndReturnAck :: BotM () -> BotM NoContent | ||
forkAndReturnAck action = do | ||
-- Here we only log sync exceptions, | ||
-- let the servant decide how to handle others | ||
let logExceptionWrapper :: BotM () -> BotM () | ||
logExceptionWrapper a = do | ||
eithRes <- UnliftIO.try @_ @SomeException a | ||
whenLeft eithRes \e -> | ||
logError [int||Error occured: #{displayException e}|] | ||
UnliftIO.async $ logExceptionWrapper action | ||
pure NoContent |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
-- SPDX-FileCopyrightText: 2022 Serokell <https://serokell.io/> | ||
-- | ||
-- SPDX-License-Identifier: MPL-2.0 | ||
|
||
{- | This module contains extractors that are similar to ones defined in the `slacker` | ||
- package, but adapted to server incoming requests. | ||
-} | ||
module TzBot.BotMain.Server.Extractors where | ||
|
||
import Universum | ||
|
||
import Data.Aeson (Value) | ||
import Data.Aeson.Lens (AsPrimitive(_String), AsValue(_Array), key) | ||
import Slacker.SocketMode (EventWrapper(..)) | ||
|
||
getEvent :: Value -> Maybe (Text, Value) | ||
getEvent evt = | ||
(,) <$> evt ^? key "type" . _String | ||
<*> pure evt | ||
|
||
pattern EventValueServer :: Text -> Value -> EventWrapper | ||
pattern EventValueServer typ event <- | ||
EventWrapper | ||
{ ewEvent = getEvent -> Just (typ, event) | ||
, ewType = "event_callback" | ||
} | ||
|
||
pattern BlockActionServer :: Text -> Value -> Value | ||
pattern BlockActionServer actionId val <- | ||
(getEvent -> Just ("block_actions", getAction -> Just (actionId, val))) | ||
|
||
getAction :: Value -> Maybe (Text, Value) | ||
getAction evt = do | ||
[action] <- toList <$> evt ^? key "actions" . _Array | ||
(,) <$> (action ^? key "action_id" . _String) <*> pure evt | ||
|
||
pattern InteractiveServer :: Text -> Value -> Value | ||
pattern InteractiveServer typ val <- (getEvent -> Just (typ, val)) |
Oops, something went wrong.