diff --git a/package.yaml b/package.yaml
index 1ec285d..6b6e9cd 100644
--- a/package.yaml
+++ b/package.yaml
@@ -37,6 +37,7 @@ library:
- formatting
- guid
- glider-nlp
+ - http-api-data
- http-client
- http-client-tls
- http-types
@@ -49,6 +50,7 @@ library:
- o-clock
- random
- optparse-applicative
+ - servant
- servant-auth
- servant-auth-client
- servant-client
@@ -69,6 +71,7 @@ library:
- validation
- yaml
- utf8-string
+ - warp
executables:
tzbot-exe:
diff --git a/src/TzBot/BotMain.hs b/src/TzBot/BotMain.hs
index 8ae6f4f..23f66d1 100644
--- a/src/TzBot/BotMain.hs
+++ b/src/TzBot/BotMain.hs
@@ -6,29 +6,16 @@ module TzBot.BotMain where
import Universum
-import Control.Monad.Managed (managed, runManaged)
import Data.ByteString qualified as BS
-import Network.HTTP.Client (newManager)
-import Network.HTTP.Client.TLS (tlsManagerSettings)
import Options.Applicative (execParser)
-import Slacker
- (defaultSlackConfig, handleThreadExceptionSensibly, runSocketMode, setApiToken, setAppToken,
- setGracefulShutdownHandler, setOnException)
import System.Directory (doesFileExist)
import Text.Interpolation.Nyan (int, rmode')
-import Time (hour)
-import TzBot.Cache
- (TzCacheSettings(tcsExpiryRandomAmplitudeFraction), defaultTzCacheSettings, withTzCache,
- withTzCacheDefault)
-import TzBot.Config
+import TzBot.BotMain.Server (runServer)
+import TzBot.BotMain.Server.Verification (runVerificationServer)
+import TzBot.BotMain.SocketMode (runSocketMode)
import TzBot.Config.Default (defaultConfigText)
-import TzBot.Config.Types (BotConfig)
-import TzBot.Logger
import TzBot.Options
-import TzBot.ProcessEvents (handler)
-import TzBot.RunMonad
-import TzBot.Util (withMaybe)
{- |
Usage:
@@ -43,7 +30,11 @@ main = do
cliOptions <- execParser totalParser
case cliOptions of
DumpConfig dumpOpts -> dumpConfig dumpOpts
- DefaultCommand op -> run op
+ RunSocketMode opts -> runSocketMode opts
+ RunServer opts ->
+ if rsoVerification opts
+ then runVerificationServer
+ else runServer opts
dumpConfig :: DumpOptions -> IO ()
dumpConfig = \case
@@ -57,51 +48,3 @@ dumpConfig = \case
(hPutStrLn @Text stderr [int||File #{path} already exists, \
use --force to overwrite|] >> exitFailure)
writeAction
-
-run :: Options -> IO ()
-run opts = do
- let mbConfigFilePath = oConfigFile opts
- bsConfig@Config {..} <- readConfig mbConfigFilePath
- runManaged $ do
-
- let fifteenPercentAmplitudeSettings = defaultTzCacheSettings
- { tcsExpiryRandomAmplitudeFraction = Just 0.15
- }
-
- gracefulShutdownContainer <- liftIO $ newIORef $ (pure () :: IO ())
- let extractShutdownFunction :: IO () -> IO ()
- extractShutdownFunction = writeIORef gracefulShutdownContainer
- let sCfg = defaultSlackConfig
- & setApiToken (unBotToken cBotToken)
- & setAppToken (unAppLevelToken cAppToken)
- & setOnException handleThreadExceptionSensibly -- auto-handle disconnects
- & setGracefulShutdownHandler extractShutdownFunction
-
- 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
- liftIO $ runSocketMode sCfg $ handler gracefulShutdownContainer 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)
diff --git a/src/TzBot/BotMain/Common.hs b/src/TzBot/BotMain/Common.hs
new file mode 100644
index 0000000..0965897
--- /dev/null
+++ b/src/TzBot/BotMain/Common.hs
@@ -0,0 +1,58 @@
+-- SPDX-FileCopyrightText: 2022 Serokell
+--
+-- 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)
diff --git a/src/TzBot/BotMain/Server.hs b/src/TzBot/BotMain/Server.hs
new file mode 100644
index 0000000..289ee83
--- /dev/null
+++ b/src/TzBot/BotMain/Server.hs
@@ -0,0 +1,133 @@
+-- SPDX-FileCopyrightText: 2022 Serokell
+--
+-- 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
diff --git a/src/TzBot/BotMain/Server/Extractors.hs b/src/TzBot/BotMain/Server/Extractors.hs
new file mode 100644
index 0000000..612667c
--- /dev/null
+++ b/src/TzBot/BotMain/Server/Extractors.hs
@@ -0,0 +1,38 @@
+-- SPDX-FileCopyrightText: 2022 Serokell
+--
+-- 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))
diff --git a/src/TzBot/BotMain/Server/Verification.hs b/src/TzBot/BotMain/Server/Verification.hs
new file mode 100644
index 0000000..a7afc8a
--- /dev/null
+++ b/src/TzBot/BotMain/Server/Verification.hs
@@ -0,0 +1,50 @@
+-- SPDX-FileCopyrightText: 2022 Serokell
+--
+-- SPDX-License-Identifier: MPL-2.0
+
+module TzBot.BotMain.Server.Verification where
+
+import Universum
+
+import Data.Aeson (FromJSON(..), ToJSON, Value)
+import Network.Wai.Handler.Warp (defaultSettings)
+import Network.Wai.Handler.Warp qualified as Warp
+import Servant (Application, Handler, JSON, PlainText, Post, ReqBody, type (:>))
+import Servant.API.Generic ((:-))
+import Servant.Server.Generic (genericServe)
+import Text.Interpolation.Nyan (int, rmode's)
+
+import TzBot.Util (RecordWrapper(..))
+
+type API = ReqBody '[JSON] Value :> Post '[PlainText] Text
+
+newtype VerificationRoutes mode = VerificationRoutes
+ { vrMain :: mode :- ReqBody '[JSON] VerifyingRequest :> Post '[PlainText] Text
+ } deriving stock (Generic)
+
+data VerifyingRequest = VerifyingRequest
+ { vrChallenge :: Text
+ , vrToken :: Text
+ , vrType :: Text
+ } deriving stock (Show, Eq, Generic)
+ deriving (FromJSON, ToJSON) via RecordWrapper VerifyingRequest
+
+-- | When trying to submit a URL for the bot, Slack will send verification
+-- request, the bot should just respond with \"challenge\" value.
+
+-- TODO: Slack also should check the server SSL certificates; currently this
+-- was just tested with ngrok which has its own certificates, but for production
+-- we need our own ones.
+runVerificationServer :: IO ()
+runVerificationServer = do
+ let settings = Warp.setPort 8080 defaultSettings
+ putStrLn @Text "Running in verification mode"
+ Warp.runSettings settings app
+ where
+ app :: Application
+ app = genericServe $ VerificationRoutes handler
+
+handler :: VerifyingRequest -> Handler Text
+handler verReq = do
+ putStrLn @Text [int||got verification value: #s{verReq}|]
+ pure $ vrChallenge verReq
diff --git a/src/TzBot/BotMain/SocketMode.hs b/src/TzBot/BotMain/SocketMode.hs
new file mode 100644
index 0000000..19ac6af
--- /dev/null
+++ b/src/TzBot/BotMain/SocketMode.hs
@@ -0,0 +1,76 @@
+-- SPDX-FileCopyrightText: 2022 Serokell
+--
+-- SPDX-License-Identifier: MPL-2.0
+
+module TzBot.BotMain.SocketMode where
+
+import Universum
+
+import Control.Exception (AsyncException(UserInterrupt))
+import Control.Monad.Managed (runManaged)
+import Slacker
+ (DisconnectBody(..), EventsApiEnvelope(..), HelloBody(..), SlashCommandsEnvelope(..),
+ SocketModeEvent(..), defaultSlackConfig, handleThreadExceptionSensibly, pattern BlockAction,
+ pattern Command, pattern EventValue, pattern Interactive, runSocketMode, setApiToken, setAppToken,
+ setGracefulShutdownHandler, setOnException)
+import Slacker.SocketMode (InteractiveEnvelope(..))
+import Text.Interpolation.Nyan (int, rmode', rmode's)
+import UnliftIO.Exception qualified as UnliftIO
+
+import TzBot.BotMain.Common
+import TzBot.Config
+import TzBot.Logger
+import TzBot.Options
+import TzBot.ProcessEvents
+ (handleRawBlockAction, handleRawEvent, handleRawInteractive, handleSlashCommand)
+import TzBot.RunMonad (BotM, BotState, runBotM)
+
+runSocketMode :: RunSocketModeOptions -> IO ()
+runSocketMode opts = do
+ let mbConfigFilePath = rsmoConfigFile opts
+ bsConfig@Config {..} <- readConfig mbConfigFilePath
+ runManaged $ do
+
+ gracefulShutdownContainer <- liftIO $ newIORef $ (pure () :: IO ())
+ let extractShutdownFunction :: IO () -> IO ()
+ extractShutdownFunction = writeIORef gracefulShutdownContainer
+ let sCfg = defaultSlackConfig
+ & setApiToken (unBotToken cBotToken)
+ & setAppToken (unAppLevelToken cAppToken)
+ & setOnException handleThreadExceptionSensibly -- auto-handle disconnects
+ & setGracefulShutdownHandler extractShutdownFunction
+ botState <- withBotState bsConfig
+ liftIO $ Slacker.runSocketMode sCfg \_ e ->
+ run gracefulShutdownContainer botState $ socketModeHandler e
+ where
+ run :: IORef (IO ()) -> BotState -> BotM a -> IO ()
+ run shutdownRef bState action = void $ runBotM bState $ do
+ eithRes <- UnliftIO.trySyncOrAsync action
+ whenLeft eithRes $ \e -> do
+ case fromException e of
+ Just UserInterrupt -> liftIO $ join $ readIORef shutdownRef
+ _ -> logError [int||Error occured: #{displayException e}|]
+
+socketModeHandler :: SocketModeEvent -> BotM ()
+socketModeHandler e = do
+ logDebug [int||Received Slack event: #{show @Text e}|]
+ case e of
+ Command _cmdType slashCmd -> handleSlashCommand slashCmd
+
+ EventValue eventType evtRaw -> handleRawEvent envelopeIdentifier eventType evtRaw
+
+ -- BlockAction events form a subset of Interactive, so check them first
+ BlockAction actionId blockActionRaw ->
+ handleRawBlockAction envelopeIdentifier actionId blockActionRaw
+
+ Interactive interactiveType interactiveRaw ->
+ handleRawInteractive envelopeIdentifier interactiveType interactiveRaw
+ _ -> logWarn [int||Unknown SocketModeEvent #s{e}|]
+ where
+ envelopeIdentifier :: Text
+ envelopeIdentifier = case e of
+ EventsApi EventsApiEnvelope {..} -> eaeEnvelopeId
+ SlashCommands SlashCommandsEnvelope {..} -> sceEnvelopeId
+ InteractiveEvent InteractiveEnvelope {..} -> ieEnvelopeId
+ Hello HelloBody {} -> "hello_body"
+ Disconnect DisconnectBody {} -> "disconnect_body"
diff --git a/src/TzBot/Instances.hs b/src/TzBot/Instances.hs
index d15ad13..f79794a 100644
--- a/src/TzBot/Instances.hs
+++ b/src/TzBot/Instances.hs
@@ -16,7 +16,13 @@ import Data.Time.Zones.All (TZLabel, toTZName)
import Data.Time.Zones.All qualified as TZ
import Formatting.Buildable (Buildable(..))
import Glider.NLP.Tokenizer (Token(..))
+import Slacker (SlashCommand)
import Time (KnownRatName, Time, unitsF, unitsP)
+import Web.FormUrlEncoded (FromForm(..), genericFromForm)
+
+import Servant (FromHttpApiData)
+import Servant.API (FromHttpApiData(..))
+import TzBot.Util (decodeText, defaultFromFormOptions)
instance Buildable TZLabel where
build = build . T.decodeUtf8 . toTZName
@@ -40,3 +46,9 @@ instance KnownRatName unit => ToJSON (Time unit) where
toJSON = String . fromString . unitsF
deriving stock instance Ord Token
+
+instance FromForm SlashCommand where
+ fromForm = genericFromForm defaultFromFormOptions
+
+instance FromHttpApiData Value where
+ parseUrlPiece t = maybe (Left "invalid JSON value") Right $ decodeText t
diff --git a/src/TzBot/Options.hs b/src/TzBot/Options.hs
index 5b22791..8fca46c 100644
--- a/src/TzBot/Options.hs
+++ b/src/TzBot/Options.hs
@@ -7,17 +7,24 @@ module TzBot.Options where
import Universum
import Options.Applicative
+import Text.Interpolation.Nyan (int)
data Command
- = DefaultCommand Options
+ = RunServer RunServerOptions
+ | RunSocketMode RunSocketModeOptions
| DumpConfig DumpOptions
data DumpOptions
= DOStdOut
| DOFile FilePath Bool
-data Options = Options
- { oConfigFile :: Maybe FilePath
+data RunServerOptions = RunServerOptions
+ { rsoConfigFile :: Maybe FilePath
+ , rsoVerification :: Bool
+ }
+
+newtype RunSocketModeOptions = RunSocketModeOptions
+ { rsmoConfigFile :: Maybe FilePath
}
totalParser :: ParserInfo Command
@@ -25,20 +32,26 @@ totalParser = info (commandParserWithDefault <**> helper) $
mconcat
[ fullDesc
, progDesc
- "Perform time references translation on new messages post to \
- \Slack conversations or on direct user triggers."
+ [int|n|
+ Perform time references translation on new messages post to
+ Slack conversations or on direct user triggers.
+ |]
, header "Slack timezone bot"
, footer configAndEnvironmentNote
]
+----------------------------------------------------------------------------
+---- Commands
+----------------------------------------------------------------------------
commandParserWithDefault :: Parser Command
commandParserWithDefault = asum
- [ commandParser
- , DefaultCommand <$> optionsParser
+ [ dumpCommandParser
+ , runServerCommandParser
+ , runSocketModeParser
]
-commandParser :: Parser Command
-commandParser = hsubparser $
+dumpCommandParser :: Parser Command
+dumpCommandParser = hsubparser $
command "dump-config" $
info (DumpConfig <$> dumpOptionsParser) (progDesc "Dump default config")
@@ -51,16 +64,42 @@ dumpOptionsParser = asum [stdoutParser, dumpFileParser]
fileOption = (long "file" <> short 'f' <> metavar "FILEPATH" <> help "Dump to file FILEPATH")
forceOption = switch (long "force" <> help "Whether to overwrite existing file")
-optionsParser :: Parser Options
-optionsParser = Options <$> do
- optional $
- strOption
- (long "config" <> short 'c' <> metavar "FILEPATH" <> help "Load configuration from FILEPATH")
+runServerCommandParser :: Parser Command
+runServerCommandParser = hsubparser $
+ command "server" $
+ info (RunServer <$> runServerOptionsParser) (progDesc "Run the bot as a server")
+
+runServerOptionsParser :: Parser RunServerOptions
+runServerOptionsParser = do
+ rsoConfigFile <- optional configOptionParser
+ rsoVerification <- switch (long "verification" <> help "Run server in the verification mode")
+ pure RunServerOptions {..}
+
+runSocketModeParser :: Parser Command
+runSocketModeParser = hsubparser $
+ command "socket-mode" $
+ info (RunSocketMode <$> runSocketModeOptionsParser) (progDesc "Run the bot in the socket mode")
+
+runSocketModeOptionsParser :: Parser RunSocketModeOptions
+runSocketModeOptionsParser = RunSocketModeOptions <$> optional configOptionParser
+
+----------------------------------------------------------------------------
+---- Common
+----------------------------------------------------------------------------
+configOptionParser :: Parser FilePath
+configOptionParser = strOption
+ (long "config" <> short 'c' <> metavar "FILEPATH"
+ <> help "Load configuration from FILEPATH")
+----------------------------------------------------------------------------
+---- Footer
+----------------------------------------------------------------------------
configAndEnvironmentNote :: String
configAndEnvironmentNote =
- "Configuration parameters can be also specified using environment\
- \ variables, for details run `tzbot dump-config -f ` and\
- \ see the config fields descriptions. If all the parameters are contained\
- \ by either envvars or the default config, the additional config file is\
- \ not required."
+ [int|n|
+ Configuration parameters can be also specified using environment
+ variables, for details run `tzbot dump-config -f ` and
+ see the config fields descriptions. If all the parameters are contained
+ by either envvars or the default config, the additional config file is
+ not required.
+ |]
diff --git a/src/TzBot/ProcessEvents.hs b/src/TzBot/ProcessEvents.hs
index 083e22a..e4e9922 100644
--- a/src/TzBot/ProcessEvents.hs
+++ b/src/TzBot/ProcessEvents.hs
@@ -3,22 +3,17 @@
-- SPDX-License-Identifier: MPL-2.0
module TzBot.ProcessEvents
- ( handler
- ) where
+ ( handleSlashCommand
+ , handleRawEvent
+ , handleRawBlockAction
+ , handleRawInteractive) where
import Universum
-import Control.Exception (AsyncException(UserInterrupt))
import Data.Aeson (FromJSON(..), Value)
import Data.Aeson.Types (parseEither)
-import Slacker
- (DisconnectBody(DisconnectBody), EventsApiEnvelope(EventsApiEnvelope, eaeEnvelopeId),
- HelloBody(..), SlackConfig, SlashCommandsEnvelope(SlashCommandsEnvelope, sceEnvelopeId),
- SocketModeEvent(..), pattern BlockAction, pattern Command, pattern EventValue,
- pattern Interactive)
-import Slacker.SocketMode (InteractiveEnvelope(..))
+import Slacker (SlashCommand, scCommand)
import Text.Interpolation.Nyan (int, rmode', rmode's)
-import UnliftIO.Exception qualified as UnliftIO
import TzBot.Logger
import TzBot.ProcessEvents.BlockAction qualified as B
@@ -26,12 +21,12 @@ import TzBot.ProcessEvents.ChannelEvent (processMemberJoinedChannel, processMemb
import TzBot.ProcessEvents.Command (processHelpCommand)
import TzBot.ProcessEvents.Interactive qualified as I
import TzBot.ProcessEvents.Message (processMessageEvent)
-import TzBot.RunMonad (BotM, BotState(..), runBotM)
+import TzBot.RunMonad (BotM)
import TzBot.Slack.API.Block (ActionId(..))
import TzBot.Slack.Fixtures qualified as Fixtures
import TzBot.Util (encodeText)
-{- |
+{-
After the message event came, the bot sends some ephemerals
containing translations of time references in that message.
@@ -51,54 +46,43 @@ event comes, and the bot collects user feedback in the configured way.
The bot also has a command `\tzhelp`, should return help message in response.
-}
-handler :: IORef (IO ()) -> BotState -> SlackConfig -> SocketModeEvent -> IO ()
-handler shutdownRef bState _cfg e = run $ do
- logDebug [int||Received Slack event: #{show @Text e}|]
- case e of
- Command cmdType slashCmd -> case cmdType of
- Fixtures.HelpCommand -> katipAddNamespaceText cmdType $ processHelpCommand slashCmd
- unknownCmd -> logWarn [int||Unknown command #{unknownCmd}|]
- EventValue eventType evtRaw
- | eventType == "message" ->
- decodeAndProcess eventType envelopeIdentifier processMessageEvent evtRaw
- | eventType == "member_joined_channel" ->
- decodeAndProcess eventType envelopeIdentifier processMemberJoinedChannel evtRaw
- | eventType == "member_left_channel" ->
- decodeAndProcess eventType envelopeIdentifier processMemberLeftChannel evtRaw
- | otherwise -> logWarn [int||Unrecognized EventValue #{encodeText evtRaw}|]
+handleSlashCommand :: SlashCommand -> BotM ()
+handleSlashCommand slashCmd = do
+ let cmdType = scCommand slashCmd
+ case cmdType of
+ Fixtures.HelpCommand -> katipAddNamespaceText cmdType $ processHelpCommand slashCmd
+ unknownCmd -> logWarn [int||Unknown command #{unknownCmd}|]
- -- BlockAction events form a subset of Interactive, so check them first
- BlockAction actionId blockActionRaw
- | actionId == unActionId Fixtures.reportButtonActionId ->
- decodeAndProcess actionId envelopeIdentifier B.processReportButtonToggled blockActionRaw
- | otherwise ->
- logWarn [int||Unrecognized BlockAction #s{e}|]
-
- Interactive interactiveType interactiveRaw
- | interactiveType == "message_action" ->
- decodeAndProcess interactiveType envelopeIdentifier I.processInteractive interactiveRaw
- | interactiveType == "view_submission" ->
- decodeAndProcess interactiveType envelopeIdentifier I.processViewSubmission interactiveRaw
- | otherwise ->
- logWarn [int||Unrecognized Interactive event #s{e}|]
- _ -> logWarn [int||Unknown SocketModeEvent #s{e}|]
+handleRawEvent :: Text -> Text -> Value -> BotM ()
+handleRawEvent envelopeIdentifier eventType evtRaw
+ | eventType == "message" =
+ go processMessageEvent
+ | eventType == "member_joined_channel" =
+ go processMemberJoinedChannel
+ | eventType == "member_left_channel" =
+ go processMemberLeftChannel
+ | otherwise = logWarn [int||Unrecognized EventValue #{encodeText evtRaw}|]
where
- run :: BotM a -> IO ()
- run action = void $ runBotM bState $ do
- eithRes <- UnliftIO.trySyncOrAsync action
- whenLeft eithRes $ \e -> do
- case fromException e of
- Just UserInterrupt -> liftIO $ join $ readIORef shutdownRef
- _ -> logError [int||Error occured: #{displayException e}|]
+ go :: (FromJSON a) => (a -> BotM ()) -> BotM ()
+ go action = decodeAndProcess eventType envelopeIdentifier action evtRaw
+
+-- BlockAction events form a subset of Interactive, so check them first
+handleRawBlockAction :: Text -> Text -> Value -> BotM ()
+handleRawBlockAction envelopeIdentifier actionId blockActionRaw
+ | actionId == unActionId Fixtures.reportButtonActionId =
+ decodeAndProcess actionId envelopeIdentifier B.processReportButtonToggled blockActionRaw
+ | otherwise =
+ logWarn [int||Unrecognized BlockAction identifier #{actionId}|]
- envelopeIdentifier :: Text
- envelopeIdentifier = case e of
- EventsApi EventsApiEnvelope {..} -> eaeEnvelopeId
- SlashCommands SlashCommandsEnvelope {..} -> sceEnvelopeId
- InteractiveEvent InteractiveEnvelope {..} -> ieEnvelopeId
- Hello HelloBody {} -> "hello_body"
- Disconnect DisconnectBody {} -> "disconnect_body"
+handleRawInteractive :: Text -> Text -> Value -> BotM ()
+handleRawInteractive envelopeIdentifier interactiveType interactiveRaw
+ | interactiveType == "message_action" =
+ decodeAndProcess interactiveType envelopeIdentifier I.processInteractive interactiveRaw
+ | interactiveType == "view_submission" =
+ decodeAndProcess interactiveType envelopeIdentifier I.processViewSubmission interactiveRaw
+ | otherwise =
+ logWarn [int||Unrecognized Interactive event type #{interactiveType}|]
decodeAndProcess :: FromJSON a => Text -> Text -> (a -> BotM b) -> Value -> BotM ()
decodeAndProcess interactiveType envelopeIdentifier processFunc raw = do
diff --git a/src/TzBot/Util.hs b/src/TzBot/Util.hs
index 8bf32e5..75e78da 100644
--- a/src/TzBot/Util.hs
+++ b/src/TzBot/Util.hs
@@ -23,11 +23,13 @@ import Data.Yaml qualified as Y
import GHC.Generics
import GHC.IO (unsafePerformIO)
import Language.Haskell.TH
+import Servant (QueryParam', Required)
import System.Clock (TimeSpec, fromNanoSecs, toNanoSecs)
import System.Environment (lookupEnv)
import System.Random (randomRIO)
import Text.Interpolation.Nyan (int, rmode')
import Time (KnownDivRat, Nanosecond, Time, floorRat, ns, toUnit)
+import Web.FormUrlEncoded qualified as Form
attach :: (Functor f) => (a -> b) -> f a -> f (a, b)
attach f = fmap (\x -> (x, f x))
@@ -80,9 +82,17 @@ x +- y = (x - y, x + y)
decodeMaybe :: FromJSON a => Value -> Maybe a
decodeMaybe = parseMaybe parseJSON
+defaultRecordFieldModifier :: String -> String
+defaultRecordFieldModifier = camelTo2 '_' . dropWhile isLower
+
+defaultFromFormOptions :: Form.FormOptions
+defaultFromFormOptions = Form.defaultFormOptions
+ { Form.fieldLabelModifier = defaultRecordFieldModifier
+ }
+
defaultRecordOptions :: Options
defaultRecordOptions = defaultOptions
- { fieldLabelModifier = camelTo2 '_' . dropWhile isLower
+ { fieldLabelModifier = defaultRecordFieldModifier
, omitNothingFields = True
}
@@ -175,3 +185,8 @@ postfixFields = lensRules & lensField .~ mappingNamer (\n -> [n ++ "L"])
whenT :: (Applicative m) => Bool -> m Bool -> m Bool
whenT cond_ action_ = if cond_ then action_ else pure False
+
+----------------------------------------------------------------------------
+---- servant
+----------------------------------------------------------------------------
+type MandatoryParam = QueryParam' '[Required, Strict]
diff --git a/tzbot.cabal b/tzbot.cabal
index 458b740..a767be1 100644
--- a/tzbot.cabal
+++ b/tzbot.cabal
@@ -1,6 +1,6 @@
cabal-version: 1.12
--- This file has been generated from package.yaml by hpack version 0.34.4.
+-- This file has been generated from package.yaml by hpack version 0.35.1.
--
-- see: https://github.com/sol/hpack
@@ -25,6 +25,11 @@ source-repository head
library
exposed-modules:
TzBot.BotMain
+ TzBot.BotMain.Common
+ TzBot.BotMain.Server
+ TzBot.BotMain.Server.Extractors
+ TzBot.BotMain.Server.Verification
+ TzBot.BotMain.SocketMode
TzBot.Cache
TzBot.Config
TzBot.Config.Default
@@ -129,6 +134,7 @@ library
, formatting
, glider-nlp
, guid
+ , http-api-data
, http-client
, http-client-tls
, http-types
@@ -141,6 +147,7 @@ library
, o-clock
, optparse-applicative
, random
+ , servant
, servant-auth
, servant-auth-client
, servant-client
@@ -160,6 +167,7 @@ library
, unordered-containers
, utf8-string
, validation
+ , warp
, yaml
default-language: Haskell2010