packages feed

tpb-0.4.0.0: src/pb-notify/Main.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeOperators #-}

module Main where

import Network.Pushbullet.Api
import Network.Pushbullet.Client
import Network.Pushbullet.Misc
import Network.Pushbullet.Types

import Control.Concurrent ( threadDelay )
import Control.Concurrent.Async
import Control.Concurrent.Chan
import Control.Concurrent.MVar
import Control.Monad ( forever, forM_ )
import Control.Monad.IO.Class
import Data.Aeson ( eitherDecode' )
import qualified Data.ByteString.Char8 as C8
import qualified Data.ByteString.Lazy as LBS
import Data.IORef
import Data.List ( sortBy )
import qualified Data.Map.Strict as M
import Data.Maybe ( listToMaybe )
import Data.Monoid ( (<>) )
import Data.Ord ( comparing )
import qualified Data.Text as T
import Data.Time.Format ( defaultTimeLocale, formatTime )
import Data.Time.LocalTime ( getTimeZone, utcToLocalTime )
import qualified Libnotify as Noti
import Network.HTTP.Client ( newManager )
import Network.HTTP.Client.TLS ( tlsManagerSettings )
import Network.Wai ( Application )
import Network.Wai.Handler.Warp ( run )
import qualified Network.WebSockets as WS
import Servant
import Servant.Client
import System.IO ( hPutStrLn, stderr )
import System.Environment ( getEnv )
import System.Exit ( exitFailure )
import System.Timeout ( timeout )
import Text.Read ( readMaybe )
import Wuss ( runSecureClient )

appName :: String
appName = "pb-notify"

timeoutDelay :: Int
timeoutDelay = 5000000 -- five seconds

-- | A map from text (identifiers in the PushBullet API) to libnotify
-- notification.
newtype NotifyMap
  = NotifyMap
    { unNotifyMap :: M.Map T.Text Noti.Notification }

newtype NotifyMapVar
  = NotifyMapVar
    { unNotifyMapVar :: MVar NotifyMap }

data ClipDataVar
  = ClipDataVar
    { clipDataLock :: MVar Bool
      -- ^ The lock is used to guard access to the IORef and to indicate
      -- whether the IORef contains meaningful data
    , clipData :: IORef T.Text
    }

newClipDataVar :: IO ClipDataVar
newClipDataVar = do
  v <- newMVar False
  r <- newIORef ""
  pure ClipDataVar { clipDataLock = v, clipData = r }

-- | Reads the clip data; if it's empty then Nothing is returned.
readClipVar :: ClipDataVar -> IO (Maybe T.Text)
readClipVar (ClipDataVar lock r) = withMVar lock $ \case
  True -> Just <$> readIORef r
  False -> pure Nothing

newtype HttpChan
  = HttpChan
    { unHttpChan :: Chan HttpReq }

-- | The types of things we can ask the HTTP client thread to do.
data HttpReq
  -- | Send the given text to the stream.
  = SendClip T.Text
  -- | Check for new pushes and convert to notifications.
  | CheckPushes

newHttpChan :: IO HttpChan
newHttpChan = HttpChan <$> newChan

checkPushes :: HttpChan -> IO ()
checkPushes (HttpChan c) = writeChan c CheckPushes

sendClip :: HttpChan -> T.Text -> IO ()
sendClip (HttpChan c) t = writeChan c (SendClip t)

getHttpChan :: HttpChan -> IO HttpReq
getHttpChan (HttpChan c) = readChan c

withNotifyMap :: NotifyMapVar -> (NotifyMap -> IO (NotifyMap, a)) -> IO a
withNotifyMap (NotifyMapVar v) m = do modifyMVar v m

type PbNotifyApi =
  "clip" :> (
    Get '[PlainText] T.Text
  :<|>
    ReqBody '[PlainText] T.Text :> PostAccepted '[PlainText] NoContent
  )

pbNotifyApi :: Proxy PbNotifyApi
pbNotifyApi = Proxy

addNoti
  :: Noti.Mod Noti.Notification
  -> T.Text
  -> NotifyMap
  -> IO NotifyMap
addNoti n t (NotifyMap m)= do
  noti <- Noti.display n
  pure $ NotifyMap (M.insert t noti m)

deleteNoti :: T.Text -> NotifyMap -> IO NotifyMap
deleteNoti t (NotifyMap m) = do
  case M.lookup t m of
    Nothing -> pure (NotifyMap m)
    Just n -> do
      Noti.close n
      pure $ NotifyMap (M.delete t m)

webapp :: ClipDataVar -> HttpChan -> Application
webapp clipvar httpChan = serve pbNotifyApi (server clipvar httpChan)

server :: ClipDataVar -> HttpChan -> Server PbNotifyApi
server (ClipDataVar lock var) httpChan = getClip :<|> postClip where
  getClip :: Handler T.Text
  getClip = do
    liftIO (takeMVar lock) >>= \case
      False -> do
        liftIO (putMVar lock False)
        throwError err404 { errBody = "no clipboard data" }
      True -> do
        t <- liftIO (readIORef var)
        liftIO (putMVar lock True)
        pure t

  postClip :: T.Text -> Handler NoContent
  postClip t = do
    liftIO $ modifyMVar_ lock $ \b -> do
      -- writeIORef var t
      sendClip httpChan t
      -- pure True
      pure b
    pure NoContent

streamUrl :: String
streamUrl = "stream.pushbullet.com"

apiUrl :: String
apiUrl = "api.pushbullet.com"

die :: String -> IO a
die s = hPutStrLn stderr s *> exitFailure

main :: IO ()
main = do
  token <- getEnv "PUSHBULLET_KEY"
  deviceIdS <- getEnv "PBNOTIFY_DEVICE"
  listenPort <- do
    p <- getEnv "PBNOTIFY_PORT"
    maybe (die "PBNOTIFY_PORT must be an integer") pure $ readMaybe p

  let key = PushbulletKey (T.pack token)

  -- strategy for making clipboard ephemerals
  makeClipEphemeral <- do
    uid <- _userId <$> getUser key
    let did = DeviceId (T.pack deviceIdS)

    pure $ \x -> PushEphemeral (Just allEphemeralTargets) Clipboard
      { _ephClipBody = x
      , _ephClipSourceUser = uid
      , _ephClipSourceDevice = did
      }

  httpChan <- newHttpChan
  clipvar <- newClipDataVar

  notiVar <- NotifyMapVar <$> newMVar (NotifyMap M.empty)

  -- run the asynchronous http thread
  -- This thread gets poked over the chan whenever a Tickle event is received
  -- over the websocket. This causes the list of pushes to get updated, and
  -- transformed into notifications.
  -- HTTP requests sent by this thread are retried indefinitely until they
  -- succeed.
  _ <- async (http notiVar httpChan makeClipEphemeral key)

  -- run the http server thread
  -- This thread runs a dead simple API to access the pushbullet clipboard
  -- POST requests to /clip will set the internal variable holding the PB
  -- clipboard and send the clipboard ephemeral over the websocket so other
  -- devices will have their clipboards set.
  -- GET requests will retrieve the value of the internal variable holding the
  -- PB clipboard. If no clipboard data has been received yet via an ephemeral
  -- on the websocket, this produces a 404.
  -- Note that the local clipboard is unaffected by these actions!
  _ <- async (run listenPort (webapp clipvar httpChan))

  let wsclient = runSecureClient streamUrl 443 ("/websocket/" <> token)

  forever $ do
    a <- async $ do
      wsclient (ws httpChan clipvar)

    waitCatch a >>= \case
      Left err -> print err
      Right () -> pure ()

    threadDelay timeoutDelay
    putStrLn "restarting websocket connection..."

getUser :: PushbulletKey -> IO User
getUser key = do
  let auth = pushbulletAuth key

  manager <- newManager tlsManagerSettings
  let url = BaseUrl Https "api.pushbullet.com" 443 ""
  let env = ClientEnv manager url
  let runClient = {- debug -} retryingDelay timeoutDelay . flip runClientM env

  runClient (getMe auth)

http
  :: NotifyMapVar
  -> HttpChan
  -> (T.Text -> Ephemeral)
  -> PushbulletKey
  -> IO ()
http notiVar httpChan mke key = do
  let auth = pushbulletAuth key

  manager <- newManager tlsManagerSettings
  let url = BaseUrl Https "api.pushbullet.com" 443 ""
  let env = ClientEnv manager url
  let runClient = {- debug -} retryingDelay timeoutDelay . flip runClientM env

  -- create a variable holding the last push time we've processed.
  -- Initially, we set it to contain the time of the most recent push, if one
  -- exists; else, we set it to UTC zero.
  lastPushTimeVar <- do
    -- get the most recent push, just for its timestamp
    (Page (ExistingPushes pushes) _) <-
      runClient (getPushes auth Nothing (Just True) (Just 1) Nothing)
    newIORef (maybe minPushbulletTime pushModified (listToMaybe pushes))

  forever $ do
    -- block until a new request
    r <- getHttpChan httpChan

    case r of
      SendClip t -> runClient (createEphemeral auth (mke t)) *> pure ()

      CheckPushes -> do
        lastPushTime <- readIORef lastPushTimeVar
        let f = fmap (fmap unExistingPushes)
        getPushes' <- pure $ \time active n ->
          f . runClient . getPushes auth time active n
        let getPushes'' = getPushes' (Just lastPushTime) (Just True) Nothing
        start <- getPushes'' Nothing
        let next = getPushes'' . Just
        pushes <- sortBy (comparing pushModified)
          <$> getPaginatedLimit All start next

        withNotifyMap notiVar $ \notiMap -> do
          v <- newIORef notiMap

          forM_ pushes $ \Push{..} -> do
            let (PushId pid) = pushId
            writeIORef lastPushTimeVar pushModified

            m <- readIORef v
            let note = preparePushNotification pushData
            let g = if pushActive then addNoti note else deleteNoti
            writeIORef v =<< g pid m

          (,) <$> readIORef v <*> pure ()

preparePushNotification :: PushData 'Existing -> Noti.Mod Noti.Notification
preparePushNotification pushData = mconcat $ case pushData of
  NotePush{..} ->
    [ Noti.summary ("Note: " ++ maybe "[untitled]" T.unpack pushTitle)
    , Noti.body (T.unpack pushBody)
    , Noti.appName appName
    ]
  LinkPush{..} ->
    [ Noti.summary ("Link: " ++ maybe "[untitled]" T.unpack pushTitle)
    , Noti.body $ T.unpack $ formatLinkPush pushUrl pushLinkBody
    , Noti.appName appName
    ]
  FilePush{..} ->
    [ Noti.summary ("File: " ++ T.unpack pushFileName)
    , Noti.body (T.unpack (unUrl pushFileUrl))
    , Noti.appName appName
    ]
  where
    formatLinkPush (Url url) mbody = case mbody of
      Nothing -> url
      Just body -> body <> "\n" <> url

ws
  :: HttpChan
  -- ^ The channel used to wake up the HTTP thread to check for new pushes
  -> ClipDataVar
  -- ^ The variable to store our clipboard buffer in
  -> WS.ClientApp ()
ws httpChan clipvar connection = recv where
  recv :: IO ()
  recv = do
    -- this timeout is set to 35s. Pushbullet is supposed to send us a
    -- heartbeat every 30s.
    rawm <- timeout 35000000 (WS.receiveData connection)
    case rawm of
      Nothing -> putStrLn "websocket receive timed out"
      Just raw -> handle httpChan clipvar raw *> recv

handle :: HttpChan -> ClipDataVar -> C8.ByteString -> IO ()
handle httpChan (ClipDataVar lock clip) raw = do
  let message = eitherDecode' (LBS.fromStrict raw)
  case message of
    Right x -> case x :: Ephemeral of
      Tickle t -> case t of
        PushType -> checkPushes httpChan
        OtherType t' -> putStrLn $ "got other tickle: " ++ T.unpack t'
      PushEphemeral _ p -> case p of
        SmsChanged{..} ->
          forM_ _ephNotifications $ \Notification{..} -> do
            t <- niceTime _notifTime
            Noti.display_ $ mconcat
              [ Noti.summary (T.unpack $ "SMS from " <> _notifTitle)
              , Noti.body (T.unpack (_notifBody <> "\n") <> t)
              , Noti.appName appName
              ]
        Clipboard{..} ->
          modifyMVar_ lock $ const $ do
            writeIORef clip _ephClipBody
            pure True
        _ -> pure ()
      _ -> pure ()
    Left _ -> pure ()

niceTime :: PushbulletTime -> IO String
niceTime (PushbulletTime t) =
  formatTime defaultTimeLocale "%a %d %b %Y @ %H:%M:%S"
    <$> (utcToLocalTime <$> getTimeZone t <*> pure t)

-- | Retries an IO action that can fail with Either indefinitely.
retrying :: IO (Either e a) -> IO a
retrying m = either (const (retrying m)) pure =<< m

-- | Retries an IO action that can fail with Either by delaying a given number
-- of microseconds before retrying, indefinitely.
retryingDelay :: Show e => Int -> IO (Either e a) -> IO a
retryingDelay n m = either loop pure =<< m where
  loop e = do
    putStrLn $ "retrying... " ++ show e
    threadDelay n
    retryingDelay n m

debug :: Show e => IO (Either e a) -> IO a
debug m = do
  a <- m
  case a of
    Left err -> print err *> exitFailure
    Right x -> pure x