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