never executed always true always false
    1 module PureClaw.Channels.Signal.Transport
    2   ( -- * Transport handle (testability seam for signal-cli)
    3     SignalTransport (..)
    4     -- * Real implementation
    5   , mkSignalCliTransport
    6     -- * Mock implementation (for tests)
    7   , mkMockSignalTransport
    8     -- * Message chunking
    9   , chunkMessage
   10   ) where
   11 
   12 import Control.Concurrent.STM
   13 import Data.Aeson
   14 import Data.ByteString.Char8 qualified as BS8
   15 import Data.ByteString.Lazy qualified as BL
   16 import Data.IORef
   17 import Data.Text (Text)
   18 import Data.Text qualified as T
   19 import System.IO
   20 import System.Process.Typed qualified as P
   21 
   22 import PureClaw.Handles.Log
   23 
   24 -- | Transport abstraction over signal-cli. The real implementation spawns
   25 -- signal-cli as a child process and communicates via JSON-RPC over stdio.
   26 -- The mock implementation uses in-memory queues for testing.
   27 data SignalTransport = SignalTransport
   28   { _st_receive :: IO Value
   29     -- ^ Block until a JSON-RPC message arrives from signal-cli
   30   , _st_send    :: Text -> Text -> IO ()
   31     -- ^ Send a message: recipient (E.164) -> body -> IO ()
   32   , _st_close   :: IO ()
   33     -- ^ Shut down the transport (kill signal-cli, close handles)
   34   }
   35 
   36 -- | Create a real transport that spawns @signal-cli jsonRpc@ as a child process.
   37 -- The process runs for the lifetime of the transport. Call '_st_close' to shut down.
   38 mkSignalCliTransport :: Text -> LogHandle -> IO SignalTransport
   39 mkSignalCliTransport account logger = do
   40   let config = P.setStdin P.createPipe
   41              $ P.setStdout P.createPipe
   42              $ P.setStderr P.inherit
   43              $ P.proc "signal-cli"
   44                  [ "--output=json"
   45                  , "--trust-new-identities=always"
   46                  , "-u", T.unpack account
   47                  , "jsonRpc"
   48                  ]
   49   process <- P.startProcess config
   50   let stdinH  = P.getStdin process
   51       stdoutH = P.getStdout process
   52   hSetBuffering stdinH LineBuffering
   53   hSetBuffering stdoutH LineBuffering
   54   _lh_logInfo logger $ "signal-cli started for account " <> account
   55   reqIdRef <- newIORef (0 :: Int)
   56   let recvLoop = do
   57         line <- BS8.hGetLine stdoutH
   58         case eitherDecode (BL.fromStrict line) of
   59           Left err -> do
   60             _lh_logWarn logger $ "signal-cli parse error: " <> T.pack err
   61             recvLoop  -- Skip unparseable lines and try again
   62           Right val -> pure val
   63   pure SignalTransport
   64     { _st_receive = recvLoop
   65     , _st_send = \recipient body -> do
   66         reqId <- atomicModifyIORef' reqIdRef (\n -> (n + 1, n + 1))
   67         let rpcMsg = object
   68               [ "jsonrpc" .= ("2.0" :: Text)
   69               , "method"  .= ("send" :: Text)
   70               , "id"      .= show reqId
   71               , "params"  .= object
   72                   [ "recipient" .= [recipient]
   73                   , "message"   .= body
   74                   ]
   75               ]
   76         _lh_logInfo logger $ "Signal send to " <> recipient <> " (" <> T.pack (show (T.length body)) <> " chars)"
   77         BL.hPut stdinH (encode rpcMsg <> "\n")
   78         hFlush stdinH
   79     , _st_close = do
   80         _lh_logInfo logger "Stopping signal-cli..."
   81         P.stopProcess process
   82     }
   83 
   84 -- | Create a mock transport backed by in-memory queues. Used in tests.
   85 mkMockSignalTransport
   86   :: TQueue Value         -- ^ Incoming messages (simulates signal-cli stdout)
   87   -> TQueue (Text, Text)  -- ^ Outgoing messages (recipient, body) for assertions
   88   -> SignalTransport
   89 mkMockSignalTransport inQueue outQueue = SignalTransport
   90   { _st_receive = atomically $ readTQueue inQueue
   91   , _st_send    = \recipient body ->
   92       atomically $ writeTQueue outQueue (recipient, body)
   93   , _st_close   = pure ()
   94   }
   95 
   96 -- | Split a long message into chunks that fit within a character limit,
   97 -- breaking on paragraph boundaries (double newline) where possible.
   98 chunkMessage :: Int -> Text -> [Text]
   99 chunkMessage limit text
  100   | T.length text <= limit = [text]
  101   | otherwise = go text
  102   where
  103     go remaining
  104       | T.null remaining = []
  105       | T.length remaining <= limit = [remaining]
  106       | otherwise =
  107           let candidate = T.take limit remaining
  108               -- Try to break at a paragraph boundary
  109               breakPoint = case T.breakOnAll "\n\n" candidate of
  110                 [] -> case T.breakOnAll "\n" candidate of
  111                   [] -> limit  -- no good break point, hard cut
  112                   breaks -> T.length (fst (last breaks)) + 1
  113                 breaks -> T.length (fst (last breaks)) + 2
  114               (chunk, rest) = T.splitAt breakPoint remaining
  115           in T.stripEnd chunk : go (T.stripStart rest)