heddit-0.0.2: src/Network/Reddit.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE ViewPatterns #-}
-- |
-- Module : Network.Reddit
-- Copyright : (c) 2021 Rory Tyler Hayford
-- License : BSD-3-Clause
-- Maintainer : rory.hayford@protonmail.com
-- Stability : experimental
-- Portability : GHC
--
module Network.Reddit
( newClient
, newClientWithManager
, loadClient
, getAuthURL
, runReddit
, runRedditT
, tryReddit
, getRateLimits
, withRateLimitDelay
, fileTokenManager
-- * Actions
, firstPage
, nextPage
, emptyPaginator
, stream
-- * Basic types
, MonadReddit
, RedditT
, Client(Client)
, RateLimits(RateLimits)
, Listing(Listing)
, Paginator(Paginator)
, Paginable
, ItemOpts(ItemOpts)
, defaultItemOpts
, ItemSort(..)
, ItemReport(ItemReport)
, Distinction(..)
, Time(..)
, ItemType(..)
, UploadURL
, Body
, Title
, URL
, Subject
, RGBText
, Name
, Domain
, Modifier
-- ** Exceptions
, RedditException
, ClientException(..)
, APIException(..)
, OAauthError(OAauthError)
, ErrorMessage(..)
, StatusMessage(StatusMessage)
, StatusCode
, JSONError(JSONError)
-- * Auth
, ClientState
, AppType(..)
, AuthConfig(AuthConfig)
, UserAgent(UserAgent)
, AccessToken(AccessToken)
, Token
, Code
, Scope(..)
, PasswordFlow(PasswordFlow)
, CodeFlow(CodeFlow)
, ClientID
, ClientSecret
, TokenDuration(..)
-- * Re-exports
-- | Only modules covering basic functionality are re-exported,
-- including those for users, subreddits, submissions, comments,
-- and actions for the authenticated user. For actions and types
-- touching on moderation, collections, live threads, and more,
-- import the respective modules directly
, module M
) where
import Conduit
( (.|)
, ConduitT
, decodeUtf8LenientC
, encodeUtf8C
, mapC
, runConduit
, runConduitRes
, sinkFile
, sinkLazy
, sourceLazy
, withSourceFile
, yieldMany
)
import Control.Monad.Catch
( Exception
, MonadCatch(catch)
, MonadThrow(throwM)
, try
)
import Control.Monad.Reader
import Data.Bool
import Data.Generics.Product ( HasField(field) )
import Data.Maybe
import Data.Sequence ( Seq(Empty, (:<|)) )
import qualified Data.Sequence as Seq
import qualified Data.Text as T
import qualified Data.Text.Lazy as LT
import Data.Time.Clock.POSIX
import Lens.Micro
import Network.HTTP.Client.TLS ( newTlsManager )
import Network.Reddit.Auth
import Network.Reddit.Comment as M
import Network.Reddit.Me as M
import Network.Reddit.Submission as M
import Network.Reddit.Subreddit as M
import Network.Reddit.Types
import Network.Reddit.User as M
import Network.Reddit.Utils
import System.Random
import UnliftIO ( MonadUnliftIO )
import UnliftIO.Concurrent ( threadDelay )
import UnliftIO.IORef
import Web.FormUrlEncoded ( ToForm(toForm) )
-- | Create a new 'Client' for API access, given an 'AuthConfig'. This client is
-- required to run all actions in this library.
--
-- See 'loadClient' if you have a 'ScriptApp' or 'ApplicationOnly' app and would
-- like to load your auth details from an ini file
newClient :: (MonadUnliftIO m, MonadThrow m) => AuthConfig -> m Client
newClient ac =
Client ac <$> newTlsManager <*> (newIORef =<< newState) <*> pure Nothing
where
newState = ClientState <$> getAccessToken toForm ac
<*> liftIO getPOSIXTime
<*> pure Nothing
-- | Create a new client with an existing refresh token, for 'WebApp's and
-- 'InstalledApp's. The initial refresh token is provided with a 'TokenManager'
-- that will also handle saving and loading refresh tokens over the life of
-- the new 'Client'
newClientWithManager :: (MonadUnliftIO m, MonadCatch m)
=> TokenManager
-> AuthConfig
-> m Client
newClientWithManager mgr@TokenManager { .. } ac = Client ac <$> newTlsManager
<*> (newIORef =<< newState)
<*> pure (Just mgr)
where
newState = do
token <- flip getAccessTokenWith ac =<< loadToken
putToken $ token ^. field @"refreshToken"
ClientState token <$> liftIO getPOSIXTime <*> pure Nothing
-- | Load a client from saved credentials, which are stored in an ini file. Files
-- should conform to the following formats:
--
-- For 'ScriptApp's:
--
-- > [NAME]
-- > id = <clientID>
-- > secret = <clientSecret>
-- > username = <username>
-- > password = <password>
-- > agent = <platform>,<appID>,<version>,<author>
--
-- For 'ApplicationOnly' apps without a user context:
--
-- > [NAME]
-- > id = <clientID>
-- > secret = <clientSecret>
-- > agent = <platform>,<appID>,<version>,<author>
--
-- Where NAME corresponds to a 'ClientSite' that you pass to this function.
-- You can have various different distinct sites in a single ini file. When
-- invoking this function, if the provided client site is @Nothing@, a section
-- labeled @[DEFAULT]@ will be used. If none is provided, an exception will be
-- thrown. Note that all section labels are case-insensitive.
--
-- The following locations are searched for an ini file, in order:
--
-- * $PWD\/auth.ini
-- * $XDG_CONFIG_HOME\/heddit\/auth.ini
--
-- __Note__: Only 'ScriptApp's and 'ApplicationOnly' are supported via this method
loadClient :: (MonadUnliftIO m, MonadThrow m) => Maybe ClientSite -> m Client
loadClient cs = newClient =<< loadAuthConfig (fromMaybe "default" cs)
-- | Run an action with your Reddit 'Client'. This will catch any exceptions
-- related to POST rate-limiting for you. After sleeping for the indicated
-- duration, it will attempt to re-run the action that triggered the exception.
-- If you do not wish to catch these exceptions, or would like to handle them
-- in a different way, use 'runRedditT', which simply runs the provided action
--
-- __Note__: Confusingly, Reddit uses /two/ different rate-limiting mechanisms.
-- This action only catches rate limiting applied to POST requests. Another form
-- of rate limiting is applied to API requests in general. This library does not
-- automatically deal with this second type. If you wish to deal with this
-- yourself, see the action 'withRateLimitDelay', which automatically applies a
-- delay based on the most recent rate limit headers returned from Reddit
runReddit :: (MonadCatch m, MonadIO m) => Client -> RedditT m a -> m a
runReddit client action =
catch @_ @APIException (runRedditT client action) $ \case
ErrorWithMessage (Ratelimited duration _) -> do
threadDelay $ fromInteger duration * 1000000
runReddit client action
e -> throwM e
-- | Run an action with your Reddit 'Client', catching the exception specified and
-- returning an @Either@ in case of failure. It may be best to use @TypeApplications@
-- to specify the exception type.
--
-- For example, to try to see the user 'FlairTemplate's for a subreddit which may or
-- may not allow user flair:
--
-- >>> tryReddit @APIException c $ getUserFlairTemplates =<< mkSubredditName "haskell"
-- Left (ErrorWithStatus (StatusMessage {statusCode = 403, message = "Forbidden"}))
--
tryReddit :: forall e a m.
(Exception e, MonadCatch m, MonadIO m)
=> Client
-> RedditT m a
-> m (Either e a)
tryReddit c = try @_ @e . runReddit c
-- | Convenience wrapper for actions taking a 'Paginator' and which return a
-- 'Listing'. This runs the action with a default initial paginator, and extracts
-- the @children@ from the returned 'Listing'. This discards all of the pagination
-- controls that are returned in the @Listing@. This is useful if you only care
-- about the child contents of the first \"page\" of results
--
-- For example, to get only the first page of results for a user's comments,
-- you could use the following:
--
-- > runReddit yourClient . firstPage $ getUserComments someUsername
--
firstPage :: (MonadReddit m, Paginable a)
=> (Paginator t a -> m (Listing t a))
-> m (Seq a)
firstPage f = f emptyPaginator { limit = 100 } <&> (^. field @"children")
-- | Update a 'Paginator' with a 'Listing' to make a query for the next \"page\"
-- of content. If the first argument is @Nothing@, defaults will be used for
-- the options, partially depending on the type of paginator
--
-- __Note__: You cannot supply both the @before@ and @after@ fields when making
-- requests to API endpoints. If both fields are @Just@ in the @Paginator@ you
-- get back from this function, the @after@ field will take precedence. If you
-- want to use @before@ in such a scenario, make sure to set it to @Nothing@
-- before using the paginator in an action
--
-- Example:
--
-- >>> best1 <- runReddit yourClient $ getBest emptyPaginator
-- >>> best2 <- runReddit yourClient . getBest $ nextPage Nothing best1
--
nextPage :: forall t a.
Paginable a
=> Maybe (Paginator t a)
-> Listing t a
-> Paginator t a
nextPage (Just p) Listing { .. } = p { before, after }
nextPage (const (emptyPaginator @t @a) -> p) -- Default paginator
Listing { .. } = p { before, after }
-- | Get current information on rate limiting, if any
getRateLimits :: MonadReddit m => m (Maybe RateLimits)
getRateLimits = readClientState $ field @"limits"
-- | Run the provided 'MonadReddit' action with a delay, if rate-limiting
-- information is currently available
withRateLimitDelay :: MonadReddit m => m a -> m a
withRateLimitDelay action = getRateLimits >>= \case
Nothing -> action
Just RateLimits { .. } -> do
now <- liftIO getPOSIXTime
let duration = nextRequest - now
sleep = threadDelay $ round duration * 1000000
bool (pure ()) sleep $ duration > 0
action
-- | Transform an action producing a @Listing@ of items into an infinite stream.
-- Items are pushed to the stream as they are fetched, with oldest items yielded
-- first. New items are fetched in 100-item batches. If nothing new arrives in
-- the stream, a jittered exponential backoff is applied, up to a cap of ~16s,
-- resetting once new items arrive again.
--
-- For example, to fetch new submissions published to \"r/haskell\", as they
-- are created, and print their IDs to the console:
--
-- >>> import Conduit
-- >>> subName <- mkSubredditName "haskell"
-- >>> action = getNewSubmissions subName
-- >>> printTitle = liftIO . print . (^. #title)
-- >>> runReddit c . runConduit $ stream Nothing action .| mapM_C printTitle
-- SubmissionID "o6948i"
-- SubmissionID "o6b0w0"
-- SubmissionID "o6cqof"
-- SubmissionID "o6ddl9"
-- SubmissionID "o6dlas"
-- ...
--
stream :: forall m t a.
( MonadReddit m --
, Paginable a
, t ~ PaginateThing a
)
=> Maybe Bool
-- ^ When @True@, will only yield items that have
-- newly arrived, thus skipping items from the first
-- request that already existed
-> (Paginator t a -> m (Listing t a))
-> ConduitT () a m ()
stream (fromMaybe False -> skip) action =
go skip 1 emptyPaginator { limit = 100 }
where
go :: Bool -> Double -> Paginator t a -> ConduitT i a m b
go skipInit n paginator = do
Listing { children } <- lift $ action paginator
case children of
Empty -> do
(delay, nextBase) <- backoff n
threadDelay . round $ delay * 1000000
go False nextBase paginator { after = Nothing }
t :<| _ -> do
bool (yieldMany $ Seq.reverse children) (pure ()) skipInit
go False
1
paginator
{ before = Just $ getFullname t --
, after = Nothing
}
backoff base = do
jitter <- randomIO @Double
pure ( base + jitter * maxJitter - maxJitter / 2
, min (base * 2) maxBase
)
where
maxJitter = base / 16
maxBase = 16
-- | This is an example 'TokenManager' that can be used to store and retrieve
-- OAUth refresh tokens, which could be used with 'newClientWithManager'. For
-- a real application, you would probably want to use a more sophisticated
-- manager
fileTokenManager
:: Exception e
=> e
-- ^ An exception that will be thrown when Reddit doesn\'t return a
-- new refresh token
-> FilePath -- ^ The location of the stored tokens
-> TokenManager
fileTokenManager e fp = TokenManager load put
where
load :: MonadIO m => m Token
load = liftIO . withSourceFile @_ @IO fp $ \b -> LT.toStrict
<$> runConduit (b .| decodeUtf8LenientC .| mapC T.strip .| sinkLazy)
put :: (MonadIO m, MonadThrow m) => Maybe Token -> m ()
put = maybe (throwM e) writeToken
where
writeToken rt = liftIO . runConduitRes
$ sourceLazy (LT.fromStrict rt) .| encodeUtf8C .| sinkFile fp