chat: RSLV-resolved NameRecord dispatched through prepared row

dispatchResolvedRecord now picks the first nrContactLinks (NTContact) or
nrChannelLinks (NTPublicGroup) entry from the resolved record, decodes it
as AConnShortLink, fetches the short-link data, and eagerly calls
createPreparedContact / createPreparedGroup with the simplex_name set.

Returning CPContactAddress (CAPKnown ct) / CPGroupLink (GLPKnown g ...)
mirrors the local-store-hit branch of connectPlanName: hit and miss
converge on the same plan shape, so the connectWithPlan caller cannot
distinguish where the prepared row came from. Threading uses the
existing Maybe SimplexNameInfo parameter added in c6f26150 for the
local-prepare path -- no new write path or transient carrier.

Pure helper firstNameLink is extracted and exported so the link-picker
contract is testable without a DB / agent. ResolveNameTests gains five
cases covering the per-type selection, the first-link policy, and the
empty-list to CESimplexNameNotFound collapse.
This commit is contained in:
shum
2026-06-04 18:49:57 +00:00
parent 0ec3b101b9
commit 1f9b6e29de
2 changed files with 121 additions and 9 deletions
+73 -7
View File
@@ -107,7 +107,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), patt
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (base64P)
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), MsgFlags (..), NameRecord, NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SMPServer, SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol)
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), MsgFlags (..), NameLink, NameRecord (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SMPServer, SProtocolType (..), SubscriptionMode (..), UserProtocol, unNameLink, userProtocol)
import qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
import qualified Simplex.Messaging.TMap as TM
@@ -128,7 +128,7 @@ import qualified UnliftIO.Exception as E
import UnliftIO.IO (hClose)
import UnliftIO.STM
#if defined(dbPostgres)
import Data.Bifunctor (bimap, second)
import Data.Bifunctor (bimap, first, second)
import Simplex.Messaging.Agent.Client (SubInfo (..), getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, temporaryOrHostError)
#else
import Data.Bifunctor (bimap, first, second)
@@ -4205,7 +4205,7 @@ processChatCommand vr nm = \case
resolveAndDispatch :: CM (ACreatedConnLink, ConnectionPlan)
resolveAndDispatch =
resolveOnUserServers user nameDomain >>= \case
Right nr -> dispatchResolvedRecord user ni nr
Right nr -> dispatchResolvedRecord vr nm user ni nr
Left re -> throwError $ resolveErrorToChatError ni re
connectWithPlan :: User -> IncognitoEnabled -> ACreatedConnLink -> ConnectionPlan -> CM ChatResponse
connectWithPlan user@User {userId} incognito ccLink plan
@@ -4663,10 +4663,76 @@ resolveOnUserServers user@User {userId} domain = do
iterateResolvers srvs $ \srv ->
liftIO . runExceptT $ resolveSimplexName a NRMInteractive userId srv domain
-- | Dispatch a resolved NameRecord through the existing link-based connect plan.
-- Implemented in Task 6.
dispatchResolvedRecord :: User -> SimplexNameInfo -> NameRecord -> CM (ACreatedConnLink, ConnectionPlan)
dispatchResolvedRecord _ _ _ = throwChatError $ CEInternalError "dispatchResolvedRecord not yet implemented (Task 6)"
-- | Dispatch a resolved NameRecord by eagerly preparing a contact/group row
-- with @simplex_name@ set, then returning the same plan shape ('CAPKnown' /
-- 'GLPKnown') the local-store-hit branch of 'connectPlanName' returns. The
-- prepare-then-CAPKnown semantic threads the resolved name into persistence
-- via the existing 'createPreparedContact' / 'createPreparedGroup' simplex_name
-- parameter (introduced for the local-prepare path, see commit c6f26150), so
-- the resolver hit reuses the same DB write path as a local-prepare hit.
dispatchResolvedRecord :: VersionRangeChat -> NetworkRequestMode -> User -> SimplexNameInfo -> NameRecord -> CM (ACreatedConnLink, ConnectionPlan)
dispatchResolvedRecord vr nm user ni@SimplexNameInfo {nameType} NameRecord {nrChannelLinks, nrContactLinks} = do
lnk <- liftEither $ firstNameLink nameType nrChannelLinks nrContactLinks ni
acl <- liftEither $ first (chatErrorAgent . AGENT . A_LINK) $ strDecode (encodeUtf8 lnk)
prepareAndPlan acl
where
prepareAndPlan :: AConnShortLink -> CM (ACreatedConnLink, ConnectionPlan)
prepareAndPlan (ACSL SCMInvitation _) =
-- The resolver returns long-term contact/channel links; an invitation
-- link in a NameRecord is malformed for this flow.
throwError $ chatErrorAgent $ AGENT $ A_LINK "RSLV returned an invitation link"
prepareAndPlan (ACSL SCMContact l) = case l of
CSLContact _ CCTContact _ _ | nameType == NTContact -> prepareContact l
CSLContact _ CCTChannel _ _ | nameType == NTPublicGroup -> prepareGroup l
CSLContact _ CCTGroup _ _ | nameType == NTPublicGroup -> prepareGroup l
_ -> throwError $ chatErrorAgent $ AGENT $ A_LINK "RSLV link kind does not match name type"
prepareContact :: ConnShortLink 'CMContact -> CM (ACreatedConnLink, ConnectionPlan)
prepareContact l = do
let l' = serverShortLink' l
(FixedLinkData {linkConnReq = cReq}, cData) <- getShortLinkConnReq nm user l'
ContactShortLinkData {profile} <-
liftIO (decodeLinkUserData cData) >>= maybe (throwError $ chatErrorAgent $ AGENT $ A_LINK "could not decode contact profile from RSLV link") pure
let ccLink = CCLink cReq (Just l')
accLink = ACCL SCMContact ccLink
(ct, displaced_) <- withStore $ \db -> createPreparedContact db vr user profile accLink Nothing (Just ni)
let Profile {simplexName = pSimplexName} = profile
forM_ ((,) <$> pSimplexName <*> displaced_) $ \(claim, displaced) ->
let Contact {localDisplayName = newLDN} = ct
in toView $ CEvtSimplexNameConflict user claim SNCEContact newLDN displaced
pure (accLink, CPContactAddress (CAPKnown ct))
prepareGroup :: ConnShortLink 'CMContact -> CM (ACreatedConnLink, ConnectionPlan)
prepareGroup l = do
let l' = serverShortLink' l
(FixedLinkData {linkConnReq = cReq}, cData@(ContactLinkData _ UserContactData {direct})) <- getShortLinkConnReq' nm user l'
GroupShortLinkData {groupProfile, publicGroupData = publicGroupData_} <-
liftIO (decodeLinkUserData cData) >>= maybe (throwError $ chatErrorAgent $ AGENT $ A_LINK "could not decode group profile from RSLV link") pure
let publicMemberCount_ = (\PublicGroupData {publicMemberCount} -> publicMemberCount) <$> publicGroupData_
useRelays = not direct
subRole <- if useRelays then asks $ channelSubscriberRole . config else pure GRMember
gVar <- asks random
let ccLink = CCLink cReq (Just l')
(g, _hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user groupProfile False ccLink Nothing useRelays subRole publicMemberCount_ (Just ni)
pure (ACCL SCMContact ccLink, CPGroupLink (GLPKnown g (BoolDef False) Nothing (ListDef [])))
-- Mirror the inline 'serverShortLink' helper defined in 'processChatCommand'
-- where this dispatch is invoked: RSLV-supplied short links may carry the
-- agent's simplex:/ scheme, but the prepared row stores hostname-scheme,
-- and the connect-plan / known-link lookups assume the hostname form.
serverShortLink' :: ConnShortLink m -> ConnShortLink m
serverShortLink' = \case
CSLInvitation _ srv lnkId linkKey -> CSLInvitation SLSServer srv lnkId linkKey
CSLContact _ ct srv linkKey -> CSLContact SLSServer ct srv linkKey
-- | Pick the first link from the @NameRecord@ matching the queried name type.
-- An empty list (record exists but advertises no link of this kind) is
-- treated as "not found" — same UX as a local-store miss.
firstNameLink :: SimplexNameType -> [NameLink] -> [NameLink] -> SimplexNameInfo -> Either ChatError Text
firstNameLink nameType channelLinks contactLinks ni = case links of
l : _ -> Right $ unNameLink l
[] -> Left $ ChatError $ CESimplexNameNotFound ni
where
links = case nameType of
NTPublicGroup -> channelLinks
NTContact -> contactLinks
-- | Map a resolver failure to the corresponding ChatError surfaced to the user.
-- AUTH (NameNotRegistered) collapses to the same UX as a local-store miss, so
+48 -2
View File
@@ -6,12 +6,13 @@ module ResolveNameTests (resolveNameTests) where
import Data.Functor.Identity (Identity (..))
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
import qualified Data.Map.Strict as M
import Data.Text (Text)
import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..))
import Simplex.Chat.Library.Commands (ResolveError (..), iterateResolvers, resolveErrorToChatError)
import Simplex.Chat.Library.Commands (ResolveError (..), firstNameLink, iterateResolvers, resolveErrorToChatError)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Agent.Protocol (AgentErrorType (..), SimplexNameDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..))
import Simplex.Messaging.Client (ProxyClientError (..))
import Simplex.Messaging.Protocol (NameRecord (..), SMPServer, mkNameOwner, pattern SMPServer)
import Simplex.Messaging.Protocol (NameLink, NameRecord (..), SMPServer, mkNameLink, mkNameOwner, pattern SMPServer)
import qualified Simplex.Messaging.Protocol as SMP
import Test.Hspec
@@ -64,6 +65,34 @@ resolveNameTests = do
case resolveErrorToChatError aliceNi (ResolverTransport timeoutErr) of
ChatErrorAgent e _ _ -> e `shouldBe` timeoutErr
other -> expectationFailure $ "expected ChatErrorAgent, got " <> show other
-- firstNameLink is the pure link-picker used by dispatchResolvedRecord:
-- it selects nrContactLinks for NTContact, nrChannelLinks for NTPublicGroup,
-- returning the first entry. Empty lists collapse to CESimplexNameNotFound
-- so the UX is identical to a local-store miss.
describe "firstNameLink" $ do
it "picks the first nrContactLinks entry for NTContact" $
case firstNameLink NTContact [channelOne] [contactOne, contactTwo] aliceNi of
Right lnk -> lnk `shouldBe` "simplex:/contact-alice"
Left e -> expectationFailure $ "expected Right, got " <> show e
it "picks the first nrChannelLinks entry for NTPublicGroup" $
case firstNameLink NTPublicGroup [channelOne, channelTwo] [contactOne] groupNi of
Right lnk -> lnk `shouldBe` "simplex:/channel-team"
Left e -> expectationFailure $ "expected Right, got " <> show e
it "returns CESimplexNameNotFound when nrContactLinks is empty for NTContact" $
case firstNameLink NTContact [channelOne] [] aliceNi of
Left (ChatError (CESimplexNameNotFound ni)) -> ni `shouldBe` aliceNi
other -> expectationFailure $ "expected CESimplexNameNotFound, got " <> show other
it "returns CESimplexNameNotFound when nrChannelLinks is empty for NTPublicGroup" $
case firstNameLink NTPublicGroup [] [contactOne] groupNi of
Left (ChatError (CESimplexNameNotFound ni)) -> ni `shouldBe` groupNi
other -> expectationFailure $ "expected CESimplexNameNotFound, got " <> show other
-- NTContact ignores nrChannelLinks even when nrContactLinks is empty.
-- The resolver-side semantics say each name advertises a per-type link
-- set; cross-type fallback would silently connect to the wrong target.
it "does not fall back to nrChannelLinks for NTContact" $
case firstNameLink NTContact [channelOne] [] aliceNi of
Left (ChatError (CESimplexNameNotFound _)) -> pure ()
other -> expectationFailure $ "expected CESimplexNameNotFound, got " <> show other
-- | Wrap a resolver to record which servers it was called for.
recording :: IORef [SMPServer] -> (SMPServer -> IO (Either AgentErrorType NameRecord)) -> SMPServer -> IO (Either AgentErrorType NameRecord)
@@ -78,6 +107,23 @@ stubAuthThenHit srv = pure $ M.findWithDefault (Right sampleRecord) srv $ M.from
aliceNi :: SimplexNameInfo
aliceNi = SimplexNameInfo NTContact (SimplexNameDomain TLDSimplex "alice" [])
groupNi :: SimplexNameInfo
groupNi = SimplexNameInfo NTPublicGroup (SimplexNameDomain TLDSimplex "team" [])
-- Synthetic links to exercise firstNameLink's selection logic. mkNameLink only
-- enforces the ≤1024-byte upper bound; the strings are deliberately not real
-- AConnShortLink encodings — the link parser is exercised separately by the
-- A_LINK error path inside dispatchResolvedRecord and by AConnShortLink's own
-- round-trip tests in simplexmq.
mkLink :: Text -> NameLink
mkLink = either error id . mkNameLink
channelOne, channelTwo, contactOne, contactTwo :: NameLink
channelOne = mkLink "simplex:/channel-team"
channelTwo = mkLink "simplex:/channel-team-backup"
contactOne = mkLink "simplex:/contact-alice"
contactTwo = mkLink "simplex:/contact-alice-backup"
srv1 :: SMPServer
srv1 = SMPServer "smp1.example" "5223" (C.KeyHash "\1\2\3\4")