packages feed

captcha-capmonster-0.1.0.0: src/Captcha/CapMonster/Internal.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}

-- |
-- Module: Captcha.CapMonster.Internal
-- Copyright: (c) 2022 Edward Yang
-- License: MIT
--
-- This module is for internal-use and does not follow pvp versioning policies.
module Captcha.CapMonster.Internal where

import Captcha.CapMonster.Internal.Error (CapMonsterError (CapMonsterResponseError, NetworkError, TimeoutError, UnknownError, UnknownResponseError), CapMonsterErrorCode (CaptchaNotReady))
import qualified Captcha.CapMonster.Internal.Error as CapMonsterError
import Captcha.Internal.Monad (HasCaptchaEnv)
import Captcha.Internal.Monad.Class (CaptchaId (CaptchaId, unCaptchaId), CaptchaRequest (request), CaptchaResponse (parseResult), MonadCaptcha (CaptchaError, createTask, getTask, solve))
import Captcha.Internal.Request (post)
import Captcha.Internal.Types (HasApiKey (apiKey), HasPollingInterval (pollingInterval), HasTimeoutDuration (timeoutDuration))
import Control.Error (note)
import Control.Lens (preview, view, (^.), (^?))
import Control.Monad ((<=<))
import Control.Monad.Except (ExceptT (ExceptT), runExceptT)
import Control.Monad.Reader (MonadReader)
import Data.Aeson (Value)
import Data.Aeson.Lens (key, _Integer, _String, _Value)
import Data.Aeson.QQ (aesonQQ)
import Data.Bifunctor (Bifunctor (bimap))
import Data.ByteString.Lazy (ByteString)
import Data.Either.Extra (fromEither)
import Data.Maybe (fromMaybe)
import Data.String.Interpolate (i, iii)
import Data.Text (Text)
import Network.HTTP.Client (HttpException)
import Network.Wreq (Response, defaults, responseBody)
import Time (Microsecond, Millisecond, Time (Time), toNum)
import Time.Units (threadDelay)
import UnliftIO (MonadUnliftIO, timeout, try)

-- | Used for picking 'MonadCaptcha' instances for CapMonster.
data CapMonster

-- | Parse the http response into the captcha answer, handling any errors found.
parseResponse :: (Value -> Maybe Value) -> Either HttpException (Response ByteString) -> Either CapMonsterError Value
parseResponse f response =
  (flip maybe Right . Left . parseError . Just) <*> f =<< parseBody
  where
    missingResponse =
      [iii|
        The response body is missing.
        This is likely due to a change in CapMonster's API and will need to be fixed.
      |]
    readError responseBody = do
      body <- responseBody
      code <- body ^? key "errorCode" . _String
      description <- body ^? key "errorDescription" . _String
      return (code, description)
    parseError responseBody = fromEither $ do
      (code, description) <- note (CapMonsterResponseError CaptchaNotReady) (readError responseBody)
      note (UnknownResponseError code description) (CapMonsterResponseError <$> CapMonsterError.parseError code)
    parseBody = note (UnknownError missingResponse) =<< bimap NetworkError (preview $ responseBody . _Value) response

instance (HasCaptchaEnv r, MonadReader r m, MonadUnliftIO m) => MonadCaptcha CapMonster r m where
  type CaptchaError CapMonster r m = CapMonsterError

  createTask :: forall ctx. CaptchaRequest CapMonster ctx r m => ctx -> m (Either CapMonsterError (CaptchaId ctx))
  createTask captcha =
    (parseCaptchaId <=< parseResponse (preview $ key "taskId")) <$> try (request @CapMonster @ctx @r @m captcha url)
    where
      url = "https://api.capmonster.cloud/createTask"
      parseCaptchaId captchaId =
        CaptchaId <$> note (UnknownError [i|CaptchaId is not an Integer: #{captchaId}|]) (captchaId ^? _Integer)

  getTask :: forall ctx. CaptchaResponse CapMonster ctx => Text -> CaptchaId ctx -> m (Either CapMonsterError Text)
  getTask apiKey captchaId =
    fmap (view _String) . parseResponse (parseResult @CapMonster @ctx) <$> try (post defaults url payload)
    where
      url = "https://api.capmonster.cloud/getTaskResult"
      payload =
        [aesonQQ|
          {
            clientKey: #{apiKey},
            taskId: #{unCaptchaId captchaId}
          }
        |]

  solve ::
    forall ctx.
    ( CaptchaRequest CapMonster ctx r m,
      CaptchaResponse CapMonster ctx,
      HasApiKey ctx Text,
      HasPollingInterval ctx (Maybe (Time Millisecond)),
      HasTimeoutDuration ctx (Maybe (Time Millisecond))
    ) =>
    ctx ->
    m (Either CapMonsterError Text)
  solve captcha =
    handleTimeout (captcha ^. timeoutDuration) . runExceptT $
      ExceptT . pollResult =<< ExceptT (createTask @CapMonster @r @m captcha)
    where
      handleTimeout (Just duration) f = fromMaybe (Left TimeoutError) <$> timeout (toNum @Microsecond duration) f
      handleTimeout Nothing f = f
      pollResult captchaId =
        threadDelay (fromMaybe (Time @Millisecond 10_000) (captcha ^. pollingInterval))
          *> getTask @CapMonster @r @m @ctx (captcha ^. apiKey) captchaId
          >>= \case
            Left (CapMonsterResponseError CaptchaNotReady) -> pollResult captchaId
            x -> pure x