packages feed

heddit-0.1: src/Network/Reddit/Submission.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE ViewPatterns #-}

-- |
-- Module      : Network.Reddit.Submission
-- Copyright   : (c) 2021 Rory Tyler Hayford
-- License     : BSD-3-Clause
-- Maintainer  : rory.hayford@protonmail.com
-- Stability   : experimental
-- Portability : GHC
--
module Network.Reddit.Submission
    (  -- * Reading submissions
      getSubmissions
    , getSubmission
    , getSubmissionByURL
    , getSubmissionsByDomain
    , getBest
    , getChildComments
    , getDuplicateSubmissions
    , saveSubmission
    , unsaveSubmission
    , hideSubmissions
    , hideSubmission
    , unhideSubmissions
    , unhideSubmission
      -- * Creating, editing, and deleting
    , deleteSubmission
    , editSubmission
    , replyToSubmission
    , submitSelfPost
    , submitWithInlineMedia
    , submitLink
    , submitImage
    , submitVideo
    , submitPoll
    , submitGallery
    , submit
    , crosspost
    , setSubmissionReplies
      -- * Submission flair
    , getSubmissionFlairChoices
    , selectSubmissionFlair
      -- * Searching
    , search
      -- * Voting
      -- $vote
    , upvoteSubmission
    , downvoteSubmission
    , unvoteSubmission
      -- * Awards
    , awardSubmission
    , gildSubmission
      -- * Misc
    , reportSubmission
    , unmarkNSFW
    , markNSFW
    , setOC
    , unsetOC
    , setSpoiler
    , unsetSpoiler
      -- * Types
    , module M'
    ) where

import           Control.Monad                         ( void, when )
import           Control.Monad.Catch                   ( MonadThrow(throwM) )
import           Control.Monad.IO.Class                ( MonadIO(liftIO) )

import           Data.Aeson                            ( ToJSON(toJSON) )
import           Data.Generics.Product                 ( HasField(field) )
import           Data.Generics.Wrapped
                 ( wrappedFrom
                 , wrappedTo
                 )
import qualified Data.HashMap.Strict                   as HM
import qualified Data.Map                              as M
import           Data.Maybe                            ( fromMaybe )
import           Data.Sequence                         ( Seq((:<|), Empty) )
import           Data.Text                             ( Text )
import qualified Data.Text                             as T
import qualified Data.Text.Encoding                    as T
import           Data.Traversable                      ( for )

import           Lens.Micro

import           Network.HTTP.Client.MultipartFormData ( partBS, partFile )
import           Network.Reddit.Internal
import           Network.Reddit.Item
import           Network.Reddit.Subreddit
import           Network.Reddit.Types
import           Network.Reddit.Types.Comment
import           Network.Reddit.Types.Flair
import           Network.Reddit.Types.Submission
import           Network.Reddit.Types.Submission       as M'
                 ( Collection(Collection)
                 , CollectionID
                 , CollectionLayout(..)
                 , CrosspostOptions(CrosspostOptions)
                 , Fancypants
                 , GalleryImage(GalleryImage)
                 , InlineMedia(InlineMedia)
                 , InlineMediaType(..)
                 , NewCollection(NewCollection)
                 , NewSubmission(..)
                 , Poll(Poll)
                 , PollData(PollData)
                 , PollOption(PollOption)
                 , PollOptionID
                 , PostedSubmission
                 , ResultID(ResultID)
                 , S3UploadLease(S3UploadLease)
                 , Search(Search)
                 , SearchCategory
                 , SearchOpts(SearchOpts)
                 , SearchSort(..)
                 , SearchSyntax(..)
                 , Submission(Submission)
                 , SubmissionContent(..)
                 , SubmissionID(SubmissionID)
                 , SubmissionOptions(SubmissionOptions)
                 , mkCrosspostOptions
                 , mkGalleryImage
                 , mkPoll
                 , mkSearch
                 , mkSearchCategory
                 , mkSubmissionOptions
                 , writeInlineMedia
                 )
import           Network.Reddit.Utils

import           Paths_heddit

import qualified System.FilePath                       as FP

import           Web.FormUrlEncoded                    ( ToForm(toForm) )
import           Web.HttpApiData                       ( ToHttpApiData(..) )
import           Web.Internal.FormUrlEncoded           ( Form )

-- | Get a information on 'Submission's given a container of 'SubmissionID's
getSubmissions :: (MonadReddit m, Foldable t)
               => ItemOpts
               -> t SubmissionID
               -> m (Seq Submission)
getSubmissions = getMany

-- | Get information on a single submission. Throws an exception if no such
-- 'Submission' exists
getSubmission :: MonadReddit m => SubmissionID -> m Submission
getSubmission sid = getSubmissions defaultItemOpts [ sid ] >>= \case
    sub :<| _ -> pure sub
    _         -> throwM $ InvalidResponse "getSubmission: No results"

-- | Get a 'Submission' from a URL pointing to it, in one of the following forms:
--
--      * http{s}:\/\/redd.it\/\<ID\>
--      * http{s}:\/\/{www.}reddit.com\/comments\/\<ID\>\/
--      * http{s}:\/\/{www.}reddit.com\/r\/\<SUBREDDIT\>\/comments\/\<ID\>\/{NAME}
--      * http{s}:\/\/{www.}reddit.com\/gallery\/\<ID\>
--
getSubmissionByURL :: MonadReddit m => URL -> m Submission
getSubmissionByURL url = getSubmission =<< submissionIDFromURL url

-- | Get a @Listing@ of submissions based on their domain
getSubmissionsByDomain :: MonadReddit m
                       => Domain
                       -> Paginator SubmissionID Submission
                       -> m (Listing SubmissionID Submission)
getSubmissionsByDomain dm paginator =
    runAction defaultAPIAction
              { pathSegments = [ "domain", dm ]
              , requestData  = paginatorToFormData paginator
              }

-- | Get the \"best\" 'Submission's from the frontpage
getBest :: MonadReddit m
        => Paginator SubmissionID Submission
        -> m (Listing SubmissionID Submission)
getBest paginator = runAction defaultAPIAction
                              { pathSegments = [ "best" ]
                              , requestData  = paginatorToFormData paginator
                              }

-- | Get a submission\'s 'ChildComment's
getChildComments :: MonadReddit m => SubmissionID -> m (Seq ChildComment)
getChildComments sid = runAction @WithChildren r <&> wrappedTo
  where
    r = defaultAPIAction { pathSegments = [ "comments", toUrlPiece sid ] }

-- | Get a @Listing@ of 'Submission's that are marked as duplicates of the given
-- submission
getDuplicateSubmissions :: MonadReddit m => SubmissionID -> m (Seq Submission)
getDuplicateSubmissions sid =
    -- This endpoint is very strange. It returns an /array/ of @Listing@s, each of
    -- which contains a single submission in its @children@ field. Having tested it,
    -- it appears that the @after@ field is always @null@. Not sure if this is the
    -- correct way to deal with this, but getting a @Seq@ of subsmissions is a lot
    -- more ergonomic than a nested mess of @Listing@s
    runAction @[Listing SubmissionID Submission] r >>= \case
        []   -> pure Empty
        dups -> pure . mconcat $ dups <&> (^. field @"children")
  where
    r = defaultAPIAction { pathSegments = [ "duplicates", toUrlPiece sid ] }

-- | Save a submission
saveSubmission :: MonadReddit m => SubmissionID -> m ()
saveSubmission = save . SubmissionItemID

-- | Unsave a submission
unsaveSubmission :: MonadReddit m => SubmissionID -> m ()
unsaveSubmission = unsave . SubmissionItemID

-- | Hide the submissions corresponding to a container of 'SubmissionID's. The
-- submissions will no longer appear in your default view of submissions on the
-- subdreddit
hideSubmissions :: (MonadReddit m, Foldable t) => t SubmissionID -> m ()
hideSubmissions = hide "hide"

-- | Hide a single submission
hideSubmission :: MonadReddit m => SubmissionID -> m ()
hideSubmission sid = hideSubmissions [ sid ]

-- | Unhide the submissions corresponding to a container of 'SubmissionID's,
-- returning them to your default view
unhideSubmissions :: (MonadReddit m, Foldable t) => t SubmissionID -> m ()
unhideSubmissions = hide "unhide"

-- | Unhide a single submission
unhideSubmission :: MonadReddit m => SubmissionID -> m ()
unhideSubmission sid = unhideSubmissions [ sid ]

hide :: (MonadReddit m, Foldable t) => Text -> t SubmissionID -> m ()
hide path ss =
    runAction_ defaultAPIAction
               { pathSegments = [ "api", path ]
               , method       = POST
               , requestData  = mkTextFormData [ ("id", fullname ss) ]
               }

-- | Delete a submission that the currently authenticated user has authored
deleteSubmission :: MonadReddit m => SubmissionID -> m ()
deleteSubmission = delete . SubmissionItemID

-- | Edit a submission, receving an updated 'Submission' in response
editSubmission :: MonadReddit m => SubmissionID -> Body -> m Submission
editSubmission (SubmissionItemID -> sid) txt = edit sid txt >>= \case
    SubmissionItem s -> pure s
    CommentItem _    -> throwM
        $ InvalidResponse "editSubmission: Expected a Submission, got a Comment"

-- | Leave a reply on a submission, returning the new 'Comment' that has been
-- created
replyToSubmission :: MonadReddit m => SubmissionID -> Body -> m Comment
replyToSubmission = reply . SubmissionItemID

-- | Submit a new self-post (with no inline media)
submitSelfPost
    :: MonadReddit m => SubredditName -> Title -> Body -> m Submission
submitSelfPost sname t b = submit $ SelfPost b (mkSubmissionOptions sname t)

-- | Submit a new self-post with inline media. The @Body@ must be markdown-
-- formatted text containing placeholders. Each placeholder must correspond to
-- exactly one of the 'InlineMedia's\' @key@ field. For example, given the
-- following:
--
-- >>> gif = InlineMedia InlineGIF "/path/to/a.gif" "gif" Nothing
-- >>> vid = InlineMedia InlineVideo "/path/to/a.mp4" "vid" (Just "my video")
-- >>> body = "Body with an inline gif #gif# and video #vid#"
-- >>> submitWithInlineMedia Nothing body [gif, vid] myOptions
--
-- Will automatically produce (internally) the following markdown body for
-- the submission, after uploading the media to Reddit\'s servers:
--
-- @
--    Body with an inline gif
--
--    ![gif](j2k2xmfl3 \"\")
--
--     and video
--
--    ![video](ccdoe02xu \"my video\")
-- @
--
submitWithInlineMedia
    :: (MonadReddit m, Traversable t)
    => Maybe Char
    -- ^ Delimiter for the placeholders in the body, defaults to @#@
    -> Body -- ^ Should contain the placeholders, as described above
    -> t InlineMedia
    -- ^ The @key@ field in each 'InlineMedia' must correspond to
    -- exactly one of the placeholders in the body
    -> SubmissionOptions
    -> m Submission
submitWithInlineMedia (T.singleton . fromMaybe '#' -> delim) b media sos = do
    uploaded <- for media $ \m@InlineMedia { mediaPath } ->
        inlineMediaToUpload m <$> uploadMedia SelfPostUpload mediaPath
    rtjson <- putOnTheFancypants $ foldr replacePlaceholders b uploaded
    getSubmissionByURL . wrappedTo
        =<< runAction @PostedSubmission
                      (submissionAction . toForm $ WithInlineMedia rtjson sos)
  where
    replacePlaceholders im@InlineMediaUpload { .. } md =
        T.replace (delim <> key <> delim) (writeInlineMedia im) md

putOnTheFancypants :: MonadReddit m => Body -> m Fancypants
putOnTheFancypants b =
    runAction defaultAPIAction
              { pathSegments = [ "api", "convert_rte_body_format" ]
              , method       = POST
              , requestData  = mkTextFormData [ ("output_mode", "rtjson")
                                              , ("markdown_text", b)
                                              ]
              }

-- | Submit a new link post
submitLink :: MonadReddit m => SubredditName -> Title -> URL -> m Submission
submitLink sname t u = submit $ Link u (mkSubmissionOptions sname t)

-- | Post an image submission to the subreddit, uploading the image file. This
-- action does not currently return the posted submission upon success
submitImage :: MonadReddit m
            => FilePath -- ^ Must be a valid image file
            -> SubmissionOptions
            -> m ()
submitImage fp sos = do
    url <- uploadMedia LinkUpload fp
    runAction_ . submissionAction . toForm $ ImagePost url sos

-- | Post an image submission to the subreddit, uploading the image file. This
-- action does not currently return the posted submission upon success
submitVideo :: MonadReddit m
            => Bool -- ^ If @True@, creates a silent \"videogif\"
            -> FilePath -- ^ Must be a valid video file
            -> Maybe FilePath
            -- ^ Must be a valid image file, for the video thumbnail. If
            -- @Nothing@, a PNG of the Haskell logo will be used
            -> SubmissionOptions
            -> m ()
submitVideo videogif fp thmbFP sos = do
    url <- uploadMedia LinkUpload fp
    thmbURL <- uploadMedia LinkUpload =<< maybe defaultPNG pure thmbFP
    runAction_ . submissionAction . toForm
        $ VideoPost url thmbURL videogif sos
  where
    defaultPNG = liftIO $ getDataFileName "assets/haskell.png"

-- | Post a poll to the subreddit. See 'mkPoll' to create a new 'Poll'
submitPoll
    :: (MonadReddit m, Foldable t) => Poll t -> SubmissionOptions -> m ()
submitPoll poll sos =
    runAction_ defaultAPIAction
               { pathSegments = [ "api", "submit_poll_post.json" ]
               , method       = POST
               , requestData  = WithJSON . toJSON $ PollSubmission poll sos
               }

-- | Post a gallery to the subreddit, given a container of 'GalleryImage's. See
-- 'mkGalleryImage' to create the images with default values. This action also
-- ensures that the image container has at least two elements
submitGallery :: (MonadReddit m, Traversable t)
              => t GalleryImage
              -> SubmissionOptions
              -> m ()
submitGallery imgs sos = do
    when (length imgs < 2) . throwM
        $ InvalidRequest --
        "submitGallery: Galleries must consist of at least 2 images"
    gallery <- for imgs $ \img@GalleryImage { imagePath } ->
        galleryImageToUpload img <$> uploadMedia GalleryUpload imagePath
    runAction_ defaultAPIAction
               { pathSegments = [ "api", "submit_gallery_post.json" ]
               , method       = POST
               , requestData  =
                     WithJSON . toJSON $ GallerySubmission gallery sos
               }

-- | Submit a new submission, returning the 'Submission' that has been created.
-- This action allows for more fine-grained control over submission options. You
-- can use 'mkSubmissionOptions' for defaults and update it as needed. See also
-- 'submitImage', 'submitPoll', and 'submitGallery', which should be used when
-- submitting anything beyond a self-text or link post
submit :: MonadReddit m => NewSubmission -> m Submission
submit (toForm -> n) = getSubmissionByURL . wrappedTo
    =<< runAction @PostedSubmission (submissionAction n)

-- | Crosspost an existing submission. You must be a subscriber of the subreddit
-- you are posting into. See also 'mkCrosspostOptions'
crosspost :: MonadReddit m => SubmissionID -> CrosspostOptions -> m Submission
crosspost sid cpos = getSubmission
    =<< (runAction @PostedCrosspost r <&> wrappedTo)
  where
    r = submissionAction
        $ toForm cpos <> mkTextForm [ ("crosspost_fullname", fullname sid) ]

submissionAction :: Form -> APIAction a
submissionAction form = defaultAPIAction
    { pathSegments = [ "api", "submit" ]
    , method       = POST
    , requestData  = WithForm form
    }

-- Calls an undocumented API endpoint to upload media to an s3 bucket. It returns
-- the URL of the uploaded media along with a a websocket URL that can be used
-- to detect the upload completion
uploadMedia :: MonadReddit m => UploadType -> FilePath -> m UploadURL
uploadMedia uty fp = do
    mimetype <- case FP.takeExtension fp of
        ext
            | Just mt <- M.lookup ext mimeMap -> pure mt
            | otherwise ->
                throwM $ InvalidRequest "uploadMedia: invalid media file type"

    S3UploadLease { .. }
        <- runAction defaultAPIAction
                     { pathSegments = [ "api", "media", "asset.json" ]
                     , method       = POST
                     , requestData  = mkTextFormData --
                           [ ("filepath", toQueryParam $ FP.takeFileName fp)
                           , ("mimetype", mimetype)
                           ]
                     }

    (url, ps) <- splitURL action
    void . runActionWith_
        =<< mkRequest url
                      defaultAPIAction
                      { pathSegments = ps
                      , method       = POST
                      , requestData  = WithMultipart
                            $ HM.foldrWithKey mkParts
                                              [ partFile "file" fp ]
                                              fields
                      , rawJSON      = False
                      }
    pure $ case uty of
        LinkUpload -> wrappedFrom $ T.intercalate "/" [ action, key ]
        _          -> assetID
  where
    mimeMap               =
        M.fromList [ (".png", "image/png")
                   , (".mov", "video/quicktime")
                   , (".mp4", "video/mp4")
                   , (".jpg", "image/jpeg")
                   , (".jpeg", "image/jpeg")
                   , (".gif", "image/gif")
                   ]

    mkParts name value ps = partBS name (T.encodeUtf8 value) : ps

-- | Enable/disable inbox replies for a submission
setSubmissionReplies :: MonadReddit m => Bool -> SubmissionID -> m ()
setSubmissionReplies p = setInboxReplies p . SubmissionItemID

-- | Select a 'FlairChoice' for a submission.
selectSubmissionFlair
    :: MonadReddit m => FlairSelection -> SubmissionID -> m ()
selectSubmissionFlair (FlairSelection FlairChoice { .. } _ sname) sid =
    runAction_ defaultAPIAction
               { pathSegments = subAPIPath sname "flairselector"
               , method       = POST
               , requestData  = WithForm
                     $ mkTextForm [ ("link", fullname sid)
                                  , ( "flair_template_id"
                                    , toQueryParam templateID
                                    )
                                  ]
               }

-- | 'Search' through Reddit 'Submission's, either side-wide or constrained to
-- one subreddit. See 'mkSearch' to create the initial 'Search'
search :: MonadReddit m
       => Search
       -> Paginator ResultID Submission
       -> m (Listing ResultID Submission)
search sch@Search { .. } paginator =
    runAction defaultAPIAction { pathSegments, requestData }
  where
    pathSegments = [ "search" ]
        & maybe id (\s -> (<>) [ "r", toUrlPiece s ]) subreddit

    requestData  = WithForm $ toForm paginator <> toForm sch

-- | Upvote a submission
upvoteSubmission :: MonadReddit m => SubmissionID -> m ()
upvoteSubmission = vote Upvote . SubmissionItemID

-- | Downvote a submission
downvoteSubmission :: MonadReddit m => SubmissionID -> m ()
downvoteSubmission = vote Downvote . SubmissionItemID

-- | Remove an existing vote on a submission
unvoteSubmission :: MonadReddit m => SubmissionID -> m ()
unvoteSubmission = vote Unvote . SubmissionItemID

-- | Grant an 'Award' to a submission. See 'mkAward' for creating an award with
-- default values, as well as the pattern synonyms for common awards
awardSubmission
    :: MonadReddit m => Award -> SubmissionID -> m AwardingsSummary
awardSubmission a = award a . SubmissionItemID

-- | Convenience action to anonymously give 'Gold' to the author of a submission,
-- without a message. See 'awardSubmission' if you need more fine-grained control
gildSubmission :: MonadReddit m => SubmissionID -> m AwardingsSummary
gildSubmission = gild . SubmissionItemID

-- | Report a submission to the subreddit\'s mods
reportSubmission :: MonadReddit m => Report -> SubmissionID -> m ()
reportSubmission r = report r . SubmissionItemID

-- | Mark a submission NSFW. The submission author can use this as well as the
-- subreddit moderators
markNSFW :: MonadReddit m => SubmissionID -> m ()
markNSFW sid =
    runAction_ defaultAPIAction
               { pathSegments = [ "api", "marknsfw" ]
               , method       = POST
               , requestData  = mkTextFormData [ ("id", fullname sid) ]
               }

-- | Unmark a submission NSFW. The submission author can use this as well as the
-- subreddit moderators
unmarkNSFW :: MonadReddit m => SubmissionID -> m ()
unmarkNSFW sid =
    runAction_ defaultAPIAction
               { pathSegments = [ "api", "unmarknsfw" ]
               , method       = POST
               , requestData  = mkTextFormData [ ("id", fullname sid) ]
               }

-- | Mark a submission as original content. In order for normal users to use
-- this feature in addition to mods, the beta \"Original Content\" feature must
-- be enabled in the subreddit settings
setOC :: MonadReddit m => SubredditName -> SubmissionID -> m ()
setOC = setUnsetOC True

-- | Unmark a submission as original content. In order for normal users to use
-- this feature in addition to mods, the beta \"Original Content\" feature must
-- be enabled in the subreddit settings
unsetOC :: MonadReddit m => SubredditName -> SubmissionID -> m ()
unsetOC = setUnsetOC False

setUnsetOC :: MonadReddit m => Bool -> SubredditName -> SubmissionID -> m ()
setUnsetOC shouldSet sname sid =
    runAction_ defaultAPIAction
               { pathSegments = [ "api", "set_original_content" ]
               , method       = POST
               , requestData  =
                     mkTextFormData [ ("id", toQueryParam sid)
                                    , ("fullname", fullname sid)
                                    , ( "should_set_oc"
                                      , toQueryParam shouldSet
                                      )
                                    , ("execute", toQueryParam False)
                                    , ("r", toQueryParam sname)
                                    ]
               }

-- | Mark the submission as containing spoilers
setSpoiler :: MonadReddit m => SubmissionID -> m ()
setSpoiler sid =
    runAction_ defaultAPIAction
               { pathSegments = [ "api", "spoiler" ]
               , method       = POST
               , requestData  = mkTextFormData [ ("id", fullname sid) ]
               }

-- | Unmark the submission as containing spoilers
unsetSpoiler :: MonadReddit m => SubmissionID -> m ()
unsetSpoiler sid =
    runAction_ defaultAPIAction
               { pathSegments = [ "api", "unspoiler" ]
               , method       = POST
               , requestData  = mkTextFormData [ ("id", fullname sid) ]
               }
--
-- $vote
-- __Note__: According to Reddit\'s API rules:
--
-- votes must be cast by humans. That is, API clients proxying a human's
-- action one-for-one are OK, but bots deciding how to vote on content or amplifying
-- a human's vote are not. See the reddit rules for more details on what constitutes
-- vote cheating.
--