diff options
| author | LLLL Colonq <llll@colonq> | 2023-11-16 19:06:43 -0500 |
|---|---|---|
| committer | LLLL Colonq <llll@colonq> | 2023-11-16 19:06:43 -0500 |
| commit | dcef0b65069fb38fd0f6c4382353167f603ebff1 (patch) | |
| tree | 45954ffe308c3dd056e6af4f734e6d2af89e5856 /fig-monitor-twitch/src | |
Initial commit
Diffstat (limited to 'fig-monitor-twitch/src')
| -rw-r--r-- | fig-monitor-twitch/src/Fig/Monitor/Twitch.hs | 530 | ||||
| -rw-r--r-- | fig-monitor-twitch/src/Fig/Monitor/Twitch/Utils.hs | 87 |
2 files changed, 617 insertions, 0 deletions
diff --git a/fig-monitor-twitch/src/Fig/Monitor/Twitch.hs b/fig-monitor-twitch/src/Fig/Monitor/Twitch.hs new file mode 100644 index 0000000..360e9c8 --- /dev/null +++ b/fig-monitor-twitch/src/Fig/Monitor/Twitch.hs @@ -0,0 +1,530 @@ +{-# Language QuasiQuotes #-} +{-# Language RecordWildCards #-} +{-# Language ApplicativeDo #-} + +module Fig.Monitor.Twitch + ( twitchEventClient + , twitchChatClient + , userTokenRedirectServer + ) where + +import Fig.Prelude + +import Control.Monad (unless) + +import qualified Data.Maybe as Maybe +import qualified Data.Text as Text +import qualified Data.Text.Lazy as Text.Lazy +import qualified Data.ByteString.Base64 as BS.Base64 +import qualified Data.Vector as V +import qualified Data.Map.Strict as Map + +import Data.Default.Class (def) + +import Data.Aeson ((.:), (.:?), (.=)) +import qualified Data.Aeson as Aeson +import qualified Data.Aeson.Types as Aeson + +import qualified Wuss as WS +import qualified Network.WebSockets.Connection as WS + +import Network.Wai.Handler.Warp (setPort) +import qualified Web.Scotty as Scotty + +import Network.HTTP.Client as HTTP +import Network.HTTP.Types.Status as HTTP + +import Fig.Utils.SExpr +import Fig.Bus.Client +import Fig.Monitor.Twitch.Utils + +loginToMaybeUserId :: Text -> Authed (Maybe Text) +loginToMaybeUserId login = do + res <- authedRequestJSON "GET" ("https://api.twitch.tv/helix/users?login=" <> login) () + let mid = flip Aeson.parseMaybe res \obj -> do + obj .: "data" >>= \case + Aeson.Array ((V.!? 0) -> Just (Aeson.Object d)) -> d .: "id" + _ -> mempty + pure mid + +loginToUserId :: Text -> Authed Text +loginToUserId login = do + res <- authedRequestJSON "GET" ("https://api.twitch.tv/helix/users?login=" <> login) () + let mid = flip Aeson.parseMaybe res \obj -> do + obj .: "data" >>= \case + Aeson.Array ((V.!? 0) -> Just (Aeson.Object d)) -> d .: "id" + _ -> mempty + maybe (throwM $ FigMonitorTwitchException "Failed to extract user ID") pure mid + +subscribe :: Text -> Text -> Text -> Authed () +subscribe sessionId event user = do + log $ "Subscribing to " <> event <> " events for user ID: " <> user + res <- authedRequestJSON "POST" "https://api.twitch.tv/helix/eventsub/subscriptions" $ Aeson.object + [ "type" .= event + , "version" .= ("1" :: Text) + , "condition" .= Aeson.object + [ "broadcaster_user_id" .= user + ] + , "transport" .= Aeson.object + [ "method" .= ("websocket" :: Text) + , "session_id" .= sessionId + ] + ] + case Aeson.parseMaybe (.: "total_cost") res of + Just (_ :: Int) -> pure () + _ -> throwM $ FigMonitorTwitchException "Failed to subscribe to event" + +subscribeFollows :: Text -> Text -> Authed () +subscribeFollows sessionId user = do + log $ "Subscribing to channel.follow events for user ID: " <> user + res <- authedRequestJSON "POST" "https://api.twitch.tv/helix/eventsub/subscriptions" $ Aeson.object + [ "type" .= ("channel.follow" :: Text) + , "version" .= ("2" :: Text) + , "condition" .= Aeson.object + [ "broadcaster_user_id" .= user + , "moderator_user_id" .= user + ] + , "transport" .= Aeson.object + [ "method" .= ("websocket" :: Text) + , "session_id" .= sessionId + ] + ] + case Aeson.parseMaybe (.: "total_cost") res of + Just (_ :: Int) -> pure () + _ -> throwM $ FigMonitorTwitchException "Failed to subscribe to event" + +subscribeRaids :: Text -> Text -> Authed () +subscribeRaids sessionId user = do + log $ "Subscribing to channel.raid events for user ID: " <> user + res <- authedRequestJSON "POST" "https://api.twitch.tv/helix/eventsub/subscriptions" $ Aeson.object + [ "type" .= ("channel.raid" :: Text) + , "version" .= ("1" :: Text) + , "condition" .= Aeson.object + [ "to_broadcaster_user_id" .= user + ] + , "transport" .= Aeson.object + [ "method" .= ("websocket" :: Text) + , "session_id" .= sessionId + ] + ] + case Aeson.parseMaybe (.: "total_cost") res of + Just (_ :: Int) -> pure () + _ -> throwM $ FigMonitorTwitchException "Failed to subscribe to event" + +poll :: Text -> [Text] -> Text -> Authed () +poll title choices user = do + log $ "Starting a new poll: \"" <> title <> "\"" + res <- authedRequestJSON "POST" "https://api.twitch.tv/helix/polls" $ Aeson.object + [ "broadcaster_id" .= user + , "title" .= title + , "choices" .= ((\c -> Aeson.object ["title" .= c]) <$> choices) + , "channel_points_voting_enabled" .= True + , "channel_points_per_vote" .= (1000 :: Integer) + , "duration" .= (60 :: Integer) + ] + let mid = flip Aeson.parseMaybe res \obj -> do + obj .: "data" >>= \case + Aeson.Array ((V.!? 0) -> Just (Aeson.Object d)) -> d .: "id" + _ -> mempty + case mid of + Just (_ :: Text) -> pure () + Nothing -> do + log "Failed to start poll" + log $ tshow res + +createPrediction :: Text -> [Text] -> Text -> Authed () +createPrediction title choices user = do + log $ "Starting a new prediction: \"" <> title <> "\"" + res <- authedRequestJSON "POST" "https://api.twitch.tv/helix/predictions" $ Aeson.object + [ "broadcaster_id" .= user + , "title" .= title + , "outcomes" .= ((\c -> Aeson.object ["title" .= c]) <$> choices) + , "prediction_window" .= (120 :: Integer) + ] + let mid = flip Aeson.parseMaybe res \obj -> do + obj .: "data" >>= \case + Aeson.Array ((V.!? 0) -> Just (Aeson.Object d)) -> d .: "id" + _ -> mempty + case mid of + Just (_ :: Text) -> pure () + Nothing -> log "Failed to start prediction" + +finishPrediction :: Text -> Text -> Text -> Authed () +finishPrediction pid oid user = do + log $ "Ending prediction: \"" <> pid <> "\"" + res <- authedRequestJSON "PATCH" "https://api.twitch.tv/helix/predictions" $ Aeson.object + [ "broadcaster_id" .= user + , "id" .= pid + , "status" .= ("RESOLVED" :: Text) + , "winning_outcome_id" .= oid + ] + let mid = flip Aeson.parseMaybe res \obj -> do + obj .: "data" >>= \case + Aeson.Array ((V.!? 0) -> Just (Aeson.Object d)) -> d .: "id" + _ -> mempty + case mid of + Just (_ :: Text) -> pure () + Nothing -> log "Failed to end prediction" + +addVIP :: Text -> Text -> Authed () +addVIP vipuser user = do + log $ "Adding VIP user: \"" <> vipuser <> "\"" + let body = Aeson.encode $ Aeson.object + [ "broadcaster_id" .= user + , "user_id" .= vipuser + ] + rc <- ask + initialRequest <- liftIO . HTTP.parseRequest $ unpack "https://api.twitch.tv/helix/channels/vips" + let request = initialRequest + { method = encodeUtf8 "POST" + , requestBody = RequestBodyLBS body + , requestHeaders = + [ ("Authorization", encodeUtf8 $ "Bearer " <> rc.config.userToken) + , ("Client-Id", encodeUtf8 rc.config.clientId) + , ("Content-Type", "application/json") + ] + } + response <- liftIO $ HTTP.httpLbs request rc.manager + unless (HTTP.statusIsSuccessful $ HTTP.responseStatus response) $ do + log $ "Failed to add VIP: error " <> tshow (HTTP.statusCode $ HTTP.responseStatus response) + +removeVIP :: Text -> Text -> Authed () +removeVIP vipuser user = do + log $ "Removing VIP user: \"" <> vipuser <> "\"" + let body = Aeson.encode $ Aeson.object + [ "broadcaster_id" .= user + , "user_id" .= vipuser + ] + rc <- ask + initialRequest <- liftIO . HTTP.parseRequest $ unpack "https://api.twitch.tv/helix/channels/vips" + let request = initialRequest + { method = encodeUtf8 "DELETE" + , requestBody = RequestBodyLBS body + , requestHeaders = + [ ("Authorization", encodeUtf8 $ "Bearer " <> rc.config.userToken) + , ("Client-Id", encodeUtf8 rc.config.clientId) + , ("Content-Type", "application/json") + ] + } + response <- liftIO $ HTTP.httpLbs request rc.manager + unless (HTTP.statusIsSuccessful $ HTTP.responseStatus response) $ do + log $ "Failed to remove VIP: error " <> tshow (HTTP.statusCode $ HTTP.responseStatus response) + +twitchEventClient :: Config -> (Text, Text) -> IO () +twitchEventClient cfg busAddr = do + WS.runSecureClient "eventsub.wss.twitch.tv" 443 "/ws" \conn -> do + welcomeStr <- WS.receiveData conn + (sessionId :: Text) <- case Aeson.eitherDecodeStrict welcomeStr of + Left err -> throwM . FigMonitorTwitchException $ tshow err + Right res -> do + let mid = flip Aeson.parseMaybe res \obj -> do + payload <- obj .: "payload" + session <- payload .: "session" + session .: "id" + maybe (throwM $ FigMonitorTwitchException "Failed to extract session ID") pure mid + log $ "Connected to Twitch API, session ID is: " <> sessionId + runAuthed cfg do + user <- loginToUserId cfg.userLogin + subscribe sessionId "channel.channel_points_custom_reward_redemption.add" user + subscribe sessionId "channel.prediction.begin" user + subscribe sessionId "channel.prediction.end" user + subscribe sessionId "channel.poll.begin" user + subscribe sessionId "channel.poll.end" user + subscribe sessionId "channel.subscribe" user + subscribe sessionId "channel.subscription.gift" user + subscribeFollows sessionId user + subscribeRaids sessionId user + busClient busAddr + (\cmds -> do + cmds.subscribe [sexp|(monitor twitch poll create)|] + cmds.subscribe [sexp|(monitor twitch prediction create)|] + cmds.subscribe [sexp|(monitor twitch prediction finish)|] + cmds.subscribe [sexp|(monitor twitch vip add)|] + cmds.subscribe [sexp|(monitor twitch vip remove)|] + forever do + resp <- WS.receiveData conn + case Aeson.eitherDecodeStrict resp of + Left err -> throwM . FigMonitorTwitchException $ tshow err + Right res -> case Aeson.parseMaybe ((.: "metadata") >=> (.: "message_type")) res of + Just ("notification" :: Text) -> case Aeson.parseMaybe ((.: "metadata") >=> (.: "subscription_type")) res of + Just ("channel.channel_points_custom_reward_redemption.add" :: Text) -> do + let parseEvent o = do + payload <- o .: "payload" + event <- payload .: "event" + nm <- event .: "user_name" + reward <- event .: "reward" + title <- reward .: "title" + minput <- event .:? "user_input" + pure (nm, title, minput) + case Aeson.parseMaybe parseEvent res of + Just (nm, title, minput) -> do + log $ "Channel point reward \"" <> title <> "\" redeemed by: " <> nm + cmds.publish [sexp|(monitor twitch redeem incoming)|] + $ [SExprString nm, SExprString title] <> maybe [] ((:[]) . SExprString . BS.Base64.encodeBase64 . encodeUtf8) minput + _ -> log "Failed to extract payload from channel point redeem event" + Just ("channel.prediction.begin" :: Text) -> do + let parseEvent o = do + payload <- o .: "payload" + event <- payload .: "event" + pid <- event .: "id" + oids <- event .: "outcomes" >>= \case + Aeson.Array os -> forM os $ \case + Aeson.Object out -> (,) <$> (out .: "title") <*> (out .: "id") + _ -> mempty + _ -> mempty + pure (pid, oids) + case Aeson.parseMaybe parseEvent res of + Just (pid, oids) -> do + log $ "Prediction begin: " <> pid + cmds.publish [sexp|(monitor twitch prediction begin)|] + [ SExprString pid + , SExprList $ (\(title, oid) -> SExprList [SExprString title, SExprString oid]) <$> toList oids + ] + _ -> log "Failed to extract ID from payload for prediction begin event" + Just ("channel.prediction.end" :: Text) -> do + log "Prediction end" + cmds.publish [sexp|(monitor twitch prediction end)|] [] + Just ("channel.raid" :: Text) -> do + let parseEvent o = do + payload <- o .: "payload" + event <- payload .: "event" + event .: "from_broadcaster_user_name" + case Aeson.parseMaybe parseEvent res of + Just nm -> do + log $ "Incoming raid from: " <> nm + cmds.publish [sexp|(monitor twitch raid)|] [SExprString nm] + _ -> log "Failed to extract user from raid event" + Just ("channel.follow" :: Text) -> do + let parseEvent o = do + payload <- o .: "payload" + event <- payload .: "event" + event .: "user_name" + case Aeson.parseMaybe parseEvent res of + Just nm -> do + log $ "New follower: " <> nm + cmds.publish [sexp|(monitor twitch follow)|] [SExprString nm] + _ -> log "Failed to extract user from follow event" + Just ("channel.subscribe" :: Text) -> do + let parseEvent o = do + payload <- o .: "payload" + event <- payload .: "event" + event .: "user_name" + case Aeson.parseMaybe parseEvent res of + Just nm -> do + log $ "New subscriber: " <> nm + cmds.publish [sexp|(monitor twitch subscribe)|] [SExprString nm] + _ -> log "Failed to extract user from subscribe event" + Just ("channel.cheer" :: Text) -> do + let parseEvent o = do + payload <- o .: "payload" + event <- payload .: "event" + nm <- event .: "user_name" + bits <- event .: "bits" + pure (nm, bits) + case Aeson.parseMaybe parseEvent res of + Just (nm, bits) -> do + log $ "New cheer: " <> nm <> " " <> tshow bits + cmds.publish [sexp|(monitor twitch cheer)|] [SExprString nm, SExprInteger bits] + _ -> log "Failed to extract user from cheer event" + Just ("channel.subscription.gift" :: Text) -> do + let parseEvent o = do + payload <- o .: "payload" + event <- payload .: "event" + nm <- event .: "user_name" + num <- event .: "total" + pure (nm, num) + case Aeson.parseMaybe parseEvent res of + Just (nm, num) -> do + log $ "User " <> nm <> " gifted subs: " <> tshow num + cmds.publish [sexp|(monitor twitch gift)|] [SExprString nm, SExprInteger num] + _ -> log "Failed to extract user from gift sub event" + Just ("channel.poll.begin" :: Text) -> do + let parseEvent o = do + payload <- o .: "payload" + event <- payload .: "event" + event .: "id" + case Aeson.parseMaybe parseEvent res of + Just pollid -> do + log $ "Poll begin: " <> pollid + cmds.publish [sexp|(monitor twitch poll begin)|] [SExprString pollid] + _ -> log "Failed to extract ID from payload for poll begin event" + Just ("channel.poll.end" :: Text) -> do + let parseEvent o = do + payload <- o .: "payload" + event <- payload .: "event" + pollid <- event .: "id" + event .: "choices" >>= \case + Aeson.Array cs -> do + choices <- forM cs \case + Aeson.Object c -> do + t <- c .: "title" + v <- c .: "votes" + pure (t, v) + _ -> mempty + pure (pollid, toList choices) + _ -> mempty + case Aeson.parseMaybe parseEvent res of + Just (pollid, choices) -> do + let schoices = (\(t, v) -> SExprList [SExprString t, SExprInteger v]) <$> choices + log $ "Poll end: " <> pollid + cmds.publish [sexp|(monitor twitch poll end)|] [SExprString pollid, SExprList schoices] + _ -> log "Failed to extract ID from payload for poll end event" + _ -> log $ "Received unknown notification event: " <> tshow resp + Just "session_keepalive" -> pure () + _ -> log $ "Received unknown response: " <> tshow resp + ) + (\_cmds d -> do + case d of + SExprList [ev, SExprString title, SExprList schoices] + | ev == [sexp|(monitor twitch poll create)|] -> do + let choices = Maybe.mapMaybe (\case SExprString c -> Just c; _ -> Nothing) schoices + runAuthed cfg do + user <- loginToUserId cfg.userLogin + poll title choices user + | ev == [sexp|(monitor twitch prediction create)|] -> do + let choices = Maybe.mapMaybe (\case SExprString c -> Just c; _ -> Nothing) schoices + runAuthed cfg do + user <- loginToUserId cfg.userLogin + createPrediction title choices user + SExprList [ev, SExprString pid, SExprString oid] + | ev == [sexp|(monitor twitch prediction finish)|] -> do + runAuthed cfg do + user <- loginToUserId cfg.userLogin + finishPrediction pid oid user + SExprList [ev, SExprString u] + | ev == [sexp|(monitor twitch vip add)|] -> do + runAuthed cfg do + user <- loginToUserId cfg.userLogin + loginToMaybeUserId u >>= \case + Nothing -> pure () + Just vipuser -> addVIP vipuser user + | ev == [sexp|(monitor twitch vip remove)|] -> do + runAuthed cfg do + user <- loginToUserId cfg.userLogin + loginToMaybeUserId u >>= \case + Nothing -> pure () + Just vipuser -> removeVIP vipuser user + _ -> log $ "Invalid incoming message: " <> tshow d + ) + (pure ()) + +data IRCMessage = IRCMessage + { tags :: Map.Map Text Text + , prefix :: Maybe Text + , command :: Text + , params :: [Text] + } deriving (Show, Eq, Ord) + +parseIRCMessage :: Text -> IRCMessage +parseIRCMessage (Text.strip -> fullrest) = + let + (tags, tagsrest) = + if Text.head fullrest == '@' + then + let (tstr, rest) = Text.breakOn " " fullrest + in ( Map.fromList $ second (Text.drop 1) . Text.breakOn "=" <$> Text.splitOn ";" (Text.drop 1 tstr) + , Text.strip rest + ) + else (Map.empty, fullrest) + (prefix, prefixrest) = + if Text.head tagsrest == ':' + then + let (pstr, rest) = Text.breakOn " " tagsrest + in ( Just $ Text.drop 1 pstr + , Text.strip rest + ) + else (Nothing, tagsrest) + (command, cmdrest) = Text.breakOn " " prefixrest + params = case Text.breakOn ":" $ Text.strip cmdrest of + (Text.strip -> "", rest) -> [rest] + (ps, rest) -> Text.splitOn " " (Text.strip ps) <> [Text.drop 1 rest] + in IRCMessage{..} + +twitchChatClient :: Config -> (Text, Text) -> IO () +twitchChatClient cfg busAddr = do + log "Starting chatbot" + WS.runSecureClient "irc-ws.chat.twitch.tv" 443 "/" \conn -> do + WS.sendTextData conn $ "PASS oauth:" <> cfg.userToken + WS.sendTextData conn ("NICK lcolonq" :: Text) + WS.sendTextData conn ("CAP REQ :twitch.tv/commands twitch.tv/tags" :: Text) + WS.sendTextData conn $ "JOIN #" <> cfg.monitorChat + -- WS.sendTextData conn ("PRIVMSG #lcolonq :test the other direction" :: Text) + busClient busAddr + (\cmds -> do + cmds.subscribe [sexp|(monitor twitch chat outgoing)|] + forever do + resp <- WS.receiveData conn + forM (Text.lines resp) $ \line -> do + let msg = parseIRCMessage line + case msg.command of + "PING" -> do + log "Received PING, sending PONG" + WS.sendTextData conn $ "PONG :" <> mconcat msg.params + "CLEARCHAT" -> do + log "Received CLEARCHAT" + cmds.publish [sexp|(monitor twitch chat clear-chat)|] $ SExprString <$> msg.params + "NOTICE" -> do + log "Received NOTICE" + cmds.publish [sexp|(monitor twitch chat notice)|] $ SExprString <$> msg.params + "USERNOTICE" -> do + log "Received USERNOTICE" + cmds.publish [sexp|(monitor twitch chat user-notice)|] $ SExprString <$> msg.params + "PRIVMSG" + | Just displaynm <- Map.lookup "display-name" msg.tags + , Nothing <- Map.lookup "custom-reward-id" msg.tags -> do + cmds.publish [sexp|(monitor twitch chat incoming)|] + [ SExprString . BS.Base64.encodeBase64 $ encodeUtf8 displaynm + , SExprList $ (\(key, v) -> SExprList [SExprString key, SExprString v]) <$> Map.toList msg.tags + , SExprString . BS.Base64.encodeBase64 . encodeUtf8 . Text.unwords $ drop 1 msg.params + ] + _ -> pure () + ) + (\_cmds d -> do + case d of + SExprList [ev, SExprString msg] | ev == [sexp|(monitor twitch chat outgoing)|] -> do + log $ "Sending: " <> msg + WS.sendTextData conn $ mconcat + [ "PRIVMSG #" + , cfg.monitorChat + , " :" + , msg + ] + _ -> log $ "Invalid outgoing message: " <> tshow d + ) + (pure ()) + +userTokenRedirectServer :: Config -> IO () +userTokenRedirectServer cfg = do + log "Starting token redirect server on port 4444" + Scotty.scottyOpts opts do + Scotty.get "/" do + Scotty.html $ mconcat + [ "<a href=\"https://id.twitch.tv/oauth2/authorize?response_type=token" + , "&client_id=", Text.Lazy.fromStrict cfg.clientId + , "&redirect_uri=http://localhost:4444" + , "&scope=", Text.Lazy.replace ":" "%3A" $ Text.Lazy.intercalate "+" scopes + , "\">Authenticate</a>" + ] + where + opts = Scotty.Options + { Scotty.verbose = 0 + , Scotty.settings = setPort 4444 (Scotty.settings def) + } + scopes = + [ "channel:manage:polls" + , "channel:manage:predictions" + , "channel:manage:redemptions" + , "channel:manage:vips" + , "channel:read:polls" + , "channel:read:predictions" + , "channel:read:redemptions" + , "channel:read:subscriptions" + , "channel:read:vips" + , "channel:moderate" + , "moderator:read:followers" + , "moderator:read:chatters" + , "moderator:manage:shoutouts" + , "chat:edit" + , "chat:read" + , "bits:read" + ] diff --git a/fig-monitor-twitch/src/Fig/Monitor/Twitch/Utils.hs b/fig-monitor-twitch/src/Fig/Monitor/Twitch/Utils.hs new file mode 100644 index 0000000..f1d757c --- /dev/null +++ b/fig-monitor-twitch/src/Fig/Monitor/Twitch/Utils.hs @@ -0,0 +1,87 @@ +{-# Language RecordWildCards #-} +{-# Language ApplicativeDo #-} + +module Fig.Monitor.Twitch.Utils + ( FigMonitorTwitchException(..) + , loadConfig + , RequestConfig(..) + , Config(..) + , authedRequest + , authedRequestJSON + , Authed + , runAuthed + ) where + +import Fig.Prelude + +import Control.Monad.Reader (ReaderT, runReaderT) + +import qualified Data.ByteString.Lazy as BS.Lazy + +import qualified Toml + +import qualified Data.Aeson as Aeson + +import Network.HTTP.Client as HTTP +import Network.HTTP.Client.TLS as HTTP + +newtype FigMonitorTwitchException = FigMonitorTwitchException Text + deriving (Show, Eq, Ord) +instance Exception FigMonitorTwitchException + +data Config = Config + { clientId :: Text + , userToken :: Text + , userLogin :: Text + , monitorChat :: Text + } deriving (Show, Eq, Ord) + +configCodec :: Toml.TomlCodec Config +configCodec = do + clientId <- Toml.text "client_id" Toml..= (\a -> a.clientId) + userToken <- Toml.text "user_token" Toml..= (\a -> a.userToken) + -- userIds <- Toml.arrayOf Toml._Text "user_ids" Toml..= (\a -> a.userIds) + userLogin <- Toml.text "user_login" Toml..= (\a -> a.userLogin) + monitorChat <- Toml.text "monitor_chat" Toml..= (\a -> a.monitorChat) + pure $ Config{..} + +loadConfig :: FilePath -> IO Config +loadConfig path = Toml.decodeFileEither configCodec path >>= \case + Left err -> throwM . FigMonitorTwitchException $ tshow err + Right config -> pure config + +data RequestConfig = RequestConfig + { config :: Config + , manager :: HTTP.Manager + } + +newtype Authed a = Authed { unAuthed :: ReaderT RequestConfig IO a } + deriving (Functor, Applicative, Monad, MonadReader RequestConfig, MonadIO, MonadThrow) + +authedRequest :: Text -> Text -> BS.Lazy.ByteString -> Authed BS.Lazy.ByteString +authedRequest method url body = do + rc <- ask + initialRequest <- liftIO . HTTP.parseRequest $ unpack url + let request = initialRequest + { method = encodeUtf8 method + , requestBody = RequestBodyLBS body + , requestHeaders = + [ ("Authorization", encodeUtf8 $ "Bearer " <> rc.config.userToken) + , ("Client-Id", encodeUtf8 rc.config.clientId) + , ("Content-Type", "application/json") + ] + } + response <- liftIO $ HTTP.httpLbs request rc.manager + pure $ HTTP.responseBody response + +authedRequestJSON :: (Aeson.ToJSON a, Aeson.FromJSON b) => Text -> Text -> a -> Authed b +authedRequestJSON method url val = do + resp <- authedRequest method url $ Aeson.encode val + case Aeson.eitherDecode resp of + Left err -> throwM . FigMonitorTwitchException $ tshow err + Right res -> pure res + +runAuthed :: Config -> Authed a -> IO a +runAuthed config body = do + manager <- HTTP.newManager HTTP.tlsManagerSettings + runReaderT body.unAuthed RequestConfig{..} |
