packages feed

nostr-1.3.0.0: lib/Nostr/Client.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Nostr.Client 
  ( -- * Application
    NostrEnv(..)
  , NostrApp
  , runNostrApp
  , connectRelays
  , disconnect
    -- * Event Builder
  , EventBuilder(..)
  , shortNote
  , withKind
  , withTag
  -- * Standard Tag Combinators (NIP-01)
  , withReply
  , withReplyTo
  , withMention
  , withMentionRelay
  , withAddressRef
  , withAddressRefRelay
  -- * Threading Tag Combinators (NIP-10)
  , withReplyToEvent
  , withRootEvent
    -- * Contact Lists (NIP-02)
  , Contact(..)
  , getContacts
  , follow
  , unfollow
    -- * Event Deletion (NIP-09)
  , deleteEvents
    -- * Helpers
  , parsePubKey
    -- * Publishing
  , publish
  , publishEvent
  , publishShortNote
    -- * Relay Authentication (NIP-42)
  , createAuthEvent
  , authenticate
    -- * Querying
  , queryEvents
  ) where

import Control.Concurrent.Async (mapConcurrently)
import Control.Exception (catch, SomeException)
import Control.Monad (forM_)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Reader (ReaderT, runReaderT, asks)
import Data.Function ((&))
import Data.List (find, nub)
import Data.Maybe (catMaybes, mapMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock.POSIX (getPOSIXTime)
import System.IO (hPutStrLn, stderr)
import System.Timeout (timeout)

import Nostr.Event
import qualified Nostr.Event (mkPubKey)
import Nostr.Nip19 (decode, Nip19Object(..))
import Nostr.Relay
import Nostr.Crypto (Keys(..), signEvent)

-- | Nostr Application Environment
data NostrEnv = NostrEnv
  { envRelays :: [RelayConnection]
  }

-- | Nostr Application Monad
type NostrApp = ReaderT NostrEnv IO

-- | Run the Nostr Application
runNostrApp :: NostrEnv -> NostrApp a -> IO a
runNostrApp = flip runReaderT

-- | Connect to a list of relays
connectRelays :: [Text] -> IO NostrEnv
connectRelays urls = do
  conns <- mapMaybeM connectSafe urls
  return $ NostrEnv conns
  where
    connectSafe :: Text -> IO (Maybe RelayConnection)
    connectSafe url = do
      -- connectRelay returns immediately (forks background thread)
      conn <- connectRelay url
      hPutStrLn stderr $ "Initiated connection to " ++ T.unpack url
      return $ Just conn

    mapMaybeM :: Monad m => (a -> m (Maybe b)) -> [a] -> m [b]
    mapMaybeM f xs = catMaybes <$> mapM f xs

-- | Disconnect from all relays
disconnect :: NostrEnv -> IO ()
disconnect env = do
  forM_ (envRelays env) $ \conn -> do
    closeConnection conn

-- | Publish an event to all connected relays
publishEvent :: Event -> NostrApp ()
publishEvent event = do
  relays <- asks envRelays
  liftIO $ forM_ relays $ \conn -> do
    let msg = CMEvent event
    catch (sendMessage conn msg) (\(_ :: SomeException) -> 
      hPutStrLn stderr $ "Failed to send event to " ++ T.unpack (relayUrl conn))

-- | Query events from all relays and aggregate results
queryEvents :: Filter -> NostrApp [Event]
queryEvents filter = do
  relays <- asks envRelays
  -- We use mapConcurrently to query all relays in parallel
  results <- liftIO $ mapConcurrently (queryRelaySafe filter) relays
  -- Deduplicate events based on ID
  return $ nub $ concat results
  where
    queryRelaySafe :: Filter -> RelayConnection -> IO [Event]
    queryRelaySafe f conn = do
      catch (queryRelay conn f) (\e -> do
        hPutStrLn stderr $ "Error querying " ++ T.unpack (relayUrl conn) ++ ": " ++ show (e :: SomeException)
        return [])

-- | Helper to query a single relay
-- Sends REQ, collects events until EOSE or timeout (5 seconds).
queryRelay :: RelayConnection -> Filter -> IO [Event]
queryRelay conn filter = do
  subId <- SubscriptionId . T.pack . show . round <$> getPOSIXTime
  sendMessage conn (CMReq subId [filter])
  -- Set a 5-second timeout for the query
  result <- timeout 5000000 $ collectEvents conn subId []
  case result of
    Just events -> return events
    Nothing -> do
      hPutStrLn stderr $ "Query timed out for " ++ T.unpack (relayUrl conn)
      return []

collectEvents :: RelayConnection -> SubscriptionId -> [Event] -> IO [Event]
collectEvents conn subId acc = do
  msg <- receiveMessage conn
  case msg of
    RMEvent sid event -> 
      if sid == subId 
        then collectEvents conn subId (event : acc)
        else collectEvents conn subId acc -- Ignore other subscriptions
    RMEOSE sid -> 
      if sid == subId 
        then return acc 
        else collectEvents conn subId acc
    _ -> collectEvents conn subId acc -- Ignore other messages

-- ============================================================================
-- Event Builder
-- ============================================================================

-- | Builder for constructing Nostr events
-- Use 'shortNote' to create a builder, then chain with '&' and combinators.
--
-- @
-- import Data.Function ((&))
--
-- publish keys $ shortNote "Hello world"
--   & withKind 30023
--   & withTag ["e", "event-id"]
--   & withTag ["p", "pubkey-hex"]
-- @
data EventBuilder = EventBuilder
  { ebKind    :: Kind
  , ebTags    :: [Tag]
  , ebContent :: Text
  } deriving (Show, Eq)

mkEvent :: EventBuilder 
mkEvent = EventBuilder
  {
    ebKind = 1
  , ebTags = []
  , ebContent = ""
  }
-- | Create an event builder for a short text note (kind 1)
shortNote :: Text -> EventBuilder
shortNote content = mkEvent
  { 
    ebContent = content
  }

-- | Set the event kind
withKind :: Kind -> EventBuilder -> EventBuilder
withKind k eb = eb { ebKind = k }

-- | Append a tag to the event
withTag :: [Text] -> EventBuilder -> EventBuilder
withTag t eb = eb { ebTags = ebTags eb ++ [t] }

-- ============================================================================
-- NIP-01 Standard Tag Combinators
-- ============================================================================

-- | Reply to an event ("e" tag)
-- ["e", <event_id>]
withReply :: Text -> EventBuilder -> EventBuilder
withReply eid = withTag ["e", eid]

-- | Reply to an event with a recommended relay url
-- ["e", <event_id>, <relay_url>]
withReplyTo :: Text -> Text -> EventBuilder -> EventBuilder
withReplyTo eid relay = withTag ["e", eid, relay]

-- | Mention a user ("p" tag)
-- ["p", <pubkey_hex>]
withMention :: PubKey -> EventBuilder -> EventBuilder
withMention pubkey = withTag ["p", unPubKey pubkey]

-- | Mention a user with a recommended relay url
-- ["p", <pubkey_hex>, <relay_url>]
withMentionRelay :: PubKey -> Text -> EventBuilder -> EventBuilder
withMentionRelay pubkey relay = withTag ["p", unPubKey pubkey, relay]

-- ============================================================================
-- NIP-10 Threading Tag Combinators
-- ============================================================================

-- | Reply to an event (NIP-10 marked "e" tag)
-- ["e", <event_id>, <relay_url>, "reply", <pubkey>]
withReplyToEvent :: EventId -> Text -> PubKey -> EventBuilder -> EventBuilder
withReplyToEvent (EventId eid) relay pubkey = withTag ["e", eid, relay, "reply", unPubKey pubkey]

-- | Mark the root of a thread (NIP-10 marked "e" tag)
-- ["e", <event_id>, <relay_url>, "root", <pubkey>]
withRootEvent :: EventId -> Text -> PubKey -> EventBuilder -> EventBuilder
withRootEvent (EventId eid) relay pubkey = withTag ["e", eid, relay, "root", unPubKey pubkey]

-- | Reference an addressable event ("a" tag)
-- ["a", <kind>:<pubkey_hex>:<d-tag>]
withAddressRef :: Kind -> PubKey -> Text -> EventBuilder -> EventBuilder
withAddressRef kind pubkey dTag = 
  let addr = T.pack (show kind) <> ":" <> unPubKey pubkey <> ":" <> dTag
  in withTag ["a", addr]

-- | Reference an addressable event with a recommended relay url
-- ["a", <kind>:<pubkey_hex>:<d-tag>, <relay_url>]
withAddressRefRelay :: Kind -> PubKey -> Text -> Text -> EventBuilder -> EventBuilder
withAddressRefRelay kind pubkey dTag relay = 
  let addr = T.pack (show kind) <> ":" <> unPubKey pubkey <> ":" <> dTag
  in withTag ["a", addr, relay]

-- ============================================================================
-- Contact Lists (NIP-02)
-- ============================================================================

data Contact = Contact
  { contactPubkey  :: Text
  , contactRelay   :: Maybe Text
  , contactPetname :: Maybe Text
  } deriving (Show, Eq)

-- | Fetch the user's latest contact list (Kind 3)
getContacts :: Keys -> NostrApp [Contact]
getContacts keys = do
  let filter = defaultFilter
        { filterKinds   = Just [3]
        , filterAuthors = Just [keysPubKey keys]
        , filterLimit   = Just 1
        }
  events <- queryEvents filter
  
  -- We only care about the latest event if multiple returned (though limit 1 helps)
  -- But since queryEvents aggregates from multiple relays, we might get substitutes.
  -- We should sort by created_at.
  -- For now, just taking the first one if available.
  case events of
    [] -> return []
    (e:_) -> return $ mapMaybe parseContact (eventTags e)
  where
    parseContact :: Tag -> Maybe Contact
    parseContact tag = case tag of
      ("p" : pubkey : rest) -> 
        let relay   = if null rest then Nothing else Just (head rest)
            petname = if length rest > 1 then Just (rest !! 1) else Nothing
        in Just $ Contact pubkey relay petname
      _ -> Nothing

-- | Parse a public key from Hex or NIP-19 (npub) string
parsePubKey :: Text -> Maybe PubKey
parsePubKey t
  | "npub1" `T.isPrefixOf` t = case decode t of
      Right (Nip19Pub pk) -> Just pk
      _                   -> Nothing
  | otherwise = case Nostr.Event.mkPubKey t of
      Right pk -> Just pk
      _        -> Nothing

-- | Follow a user
follow :: Keys -> PubKey -> Maybe Text -> Maybe Text -> NostrApp ()
follow keys targetPubkey relay petname = do
  contacts <- getContacts keys
  
  -- Check if already following
  -- We compare the hex representation of pubkeys since Contact stores Text
  let targetHex = unPubKey targetPubkey
  let startContacts = Prelude.filter (\c -> contactPubkey c /= targetHex) contacts
  let newContact = Contact targetHex relay petname
  let newContacts = startContacts ++ [newContact]
  
  publishContacts keys newContacts

-- | Unfollow a user
unfollow :: Keys -> PubKey -> NostrApp ()
unfollow keys targetPubkey = do
  contacts <- getContacts keys
  let targetHex = unPubKey targetPubkey
  let newContacts = Prelude.filter (\c -> contactPubkey c /= targetHex) contacts
  publishContacts keys newContacts

-- | Helper to publish the contact list
publishContacts :: Keys -> [Contact] -> NostrApp ()
publishContacts keys contacts = do
  let builder = shortNote "" 
              & withKind 3
  
  -- Add all contacts as "p" tags
  let builderWithTags = foldl addContactTag builder contacts
  
  publish keys builderWithTags
  where
    addContactTag :: EventBuilder -> Contact -> EventBuilder
    addContactTag eb c = 
      let tags = ["p", contactPubkey c]
              ++ (case contactRelay c of Just r -> [r]; Nothing -> [""])
              ++ (case contactPetname c of Just p -> [p]; Nothing -> [])
      in withTag tags eb

-- ============================================================================
-- Event Deletion (NIP-09)
-- ============================================================================

-- | Delete events (Kind 5)
-- @
-- deleteEvents keys [eventId1, eventId2] (Just "user request")
-- @
deleteEvents :: Keys -> [EventId] -> Maybe Text -> NostrApp ()
deleteEvents keys eventIds reason = do
  let content = case reason of
        Just r -> r
        Nothing -> ""
        
  let builder = shortNote content
              & withKind 5
  
  -- Add "e" tags for each event ID
  let builderWithTags = foldl (\eb (EventId eid) -> withTag ["e", eid] eb) builder eventIds
  
  publish keys builderWithTags

-- | Build, sign, and publish an event from an EventBuilder
publish :: Keys -> EventBuilder -> NostrApp ()
publish keys eb = do
  now <- liftIO $ round <$> getPOSIXTime
  let unsigned = createUnsignedEvent (keysPubKey keys) now (ebKind eb) (ebTags eb) (ebContent eb)
  signedResult <- liftIO $ signEvent (keysSecKey keys) unsigned
  case signedResult of
    Right event -> publishEvent event
    Left err -> liftIO $ hPutStrLn stderr $ "Failed to sign event: " ++ T.unpack err

-- | Convenience: publish a kind-1 text note (no tags)
publishShortNote :: Keys -> Text -> NostrApp ()
publishShortNote keys content = publish keys (shortNote content)

-- ============================================================================
-- NIP-42 Relay Authentication
-- ============================================================================

-- | Create an authentication event (kind 22242) for NIP-42
-- This creates an unsigned event that should be signed and sent as AUTH
createAuthEvent :: PubKey -> Text -> Text -> Event
createAuthEvent pubkey relayUrl challenge = 
  createUnsignedEvent pubkey 0 kindAuth [["relay", relayUrl], ["challenge", challenge]] ""

-- | Authenticate with a relay using NIP-42
-- Signs and sends an AUTH event with the given challenge to the specified relay
authenticate :: Keys -> Text -> Text -> NostrApp ()
authenticate keys targetRelayUrl challenge = do
  relays <- asks envRelays
  let maybeConn = find (\conn -> relayUrl conn == targetRelayUrl) relays
  case maybeConn of
    Just conn -> do
      now <- liftIO $ round <$> getPOSIXTime
      let unsigned = createAuthEvent (keysPubKey keys) targetRelayUrl challenge
          signed = unsigned { eventCreatedAt = now }
      signedResult <- liftIO $ signEvent (keysSecKey keys) signed
      case signedResult of
        Right event -> liftIO $ catch (sendMessage conn (CMAuth event)) 
          (\(_ :: SomeException) -> hPutStrLn stderr $ "Failed to send auth to " ++ T.unpack targetRelayUrl)
        Left err -> liftIO $ hPutStrLn stderr $ "Failed to sign auth event: " ++ T.unpack err
    Nothing -> liftIO $ hPutStrLn stderr $ "No relay connection found for URL: " ++ T.unpack targetRelayUrl