packages feed

sauron-0.0.0.0: src/Sauron/Top.hs

{- |
Copyright: (c) 2022 Dmitrii Kovanikov
SPDX-License-Identifier: MPL-2.0
Maintainer: Dmitrii Kovanikov <kovanikov@gmail.com>

The 'sauron top' command.

-}

module Sauron.Top
    ( runTop
    ) where

import Data.Aeson.Encode.Pretty (encodePretty)
import Data.List (minimum)
import Data.Time.Clock (UTCTime, getCurrentTime)
import Servant.Client.Core (ClientError)

import Sauron.App (App)
import Sauron.Cli (CacheMode (..), TopArgs (..))
import Sauron.Top.Client (getTweets, getUserByUsername)
import Sauron.Top.Json (Data (..), Meta (..), Page (..))
import Sauron.Top.Tweet (Tweet (..), showTime, subtractSecond, topTweets)
import Sauron.Top.User (User (..), UserId (..), mkUsername)

import qualified Data.Aeson as Aeson


runTop :: TopArgs -> App ()
runTop TopArgs{..} = do
    let username = mkUsername topArgsUsername
    User{..} <- getUserByUsername username

    putTextLn $ "[info] User id of " <> topArgsUsername <> " is: " <> unUserId userId
    putTextLn $ "[info] Total number of tweets: " <> show userTweetCount

    tweets <- case topArgsCacheMode of
        ToFile toFile     -> timelineLoop userId toFile
        FromFile fromFile -> parseCachedTweets fromFile

    putTextLn $ topTweets topArgsMax username tweets

-- | Parse already saved tweets in the file
parseCachedTweets :: FilePath -> App [Tweet]
parseCachedTweets path =
    liftIO (Aeson.eitherDecodeFileStrict @[Tweet] path) >>= \case
        Right tweets -> pure tweets
        Left err -> do
            putStrLn $ "Error parsing " <> path <> ": " <> err
            exitFailure

{- | A data type representing the current state of fetching the
Twitter timeline
-}
data TimelineState
    -- | Fetching the timeline for the first time
    = Start

    -- | An error occurred; dump the current tweets to the file and exit with error
    | Error TimelineError [Tweet]

    -- | We haven't exhausted our queries
    | NextPage
        UTCTime -- ^ Timestamp used to fetch the previous page(s)
        Text    -- ^ Pagination token
        [Tweet]

    -- | We've reached the 3200 limit. Now change the end date.
    | NextDate
        UTCTime  -- ^ Time of the last tweet minus one second
        [Tweet]

    -- | Successfully fetched the entire timeline!
    | Finish [Tweet]

data TimelineError
    = InvalidRequest ClientError

displayTimelineError :: TimelineError -> Text
displayTimelineError = \case
    InvalidRequest err -> "[InvalidRequest] " <> show err

timelineLoop :: UserId -> FilePath -> App [Tweet]
timelineLoop userId savePath = loop Start
  where
    loop :: TimelineState -> App [Tweet]
    loop Start = do
        now <- liftIO getCurrentTime

        let endTime = showTime now
        putStrLn $ "[debug] Current time: " <> endTime

        getTweets userId endTime Nothing >>= \case
            Left err -> loop $ Error (InvalidRequest err) []
            Right Page{..} -> do
                let tweets = unData pageData
                case metaNextToken pageMeta of
                    Nothing ->
                        loop $ Finish $ unData pageData
                    Just nextToken ->
                        loop $ NextPage now nextToken tweets

    loop (Error err tweets) = do
        putTextLn $ "[error] " <> displayTimelineError err
        saveTweets savePath tweets
        exitFailure

    loop (Finish tweets) = do
        saveTweets savePath tweets
        pure tweets

    loop (NextPage time currentToken tweets) = do
        debugTweets tweets

        let endTime = showTime time
        getTweets userId endTime (Just currentToken) >>= \case
            Left err -> loop $ Error (InvalidRequest err) tweets
            Right Page{..} -> do
                let newTweets = unData pageData
                case metaNextToken pageMeta of
                    -- We fetched all pages
                    Nothing -> do
                        let earliestTime = minimum $ map tweetCreatedAt tweets
                        let newEndTime = subtractSecond earliestTime
                        loop $ NextDate newEndTime (newTweets ++ tweets)

                    -- More pages to fetch
                    Just nextToken ->
                        loop $ NextPage time nextToken (newTweets ++ tweets)

    loop (NextDate time tweets) = do
        putTextLn $ "[debug] Next date: " <> show time
        debugTweets tweets

        let endTime = showTime time
        getTweets userId endTime Nothing >>= \case
            Left err -> loop $ Error (InvalidRequest err) tweets
            Right Page{..} -> do
                let newTweets = unData pageData
                case metaNextToken pageMeta of
                    -- We fetched all the tweets
                    Nothing ->
                        loop $ Finish (newTweets ++ tweets)

                    -- More pages to fetch
                    Just nextToken ->
                        loop $ NextPage time nextToken (newTweets ++ tweets)


debugTweets :: [Tweet] -> App ()
debugTweets tweets = do
    let len = length tweets
    putTextLn $ "[debug] Fetched total tweets: " <> show len

saveTweets :: FilePath -> [Tweet] -> App ()
saveTweets savePath tweets = do
    let tweetsCount = length tweets
    putTextLn $ "[info] Saving " <> show tweetsCount <> " tweets to: " <> toText savePath

    let json = encodePretty tweets
    writeFileLBS savePath json