deduplicate contact requests (#287)

* deprecate XContact

* XInfoId

* xInfoId tests

* merging

* saving on connection

* connectByAddress

* remove old connect

* deduplicate contact requests

* check on contact acceptance

* test

* rename response

* reuse CRContactRequestAlreadyAccepted

* Update src/Simplex/Chat.hs

* createConnReqConnection

* simplify controller logic

* store methods + profile change

* index

* more indices

* unXInfoId

* simplify

* XInfo with ID -> XContact

* sync reply to Connect when contact already exists

* update view for sync CRContactAlreadyExists command response

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
Efim Poberezkin 2022-02-13 13:19:24 +04:00 committed by GitHub
parent 8e34d2fbbc
commit c1c55ca700
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 426 additions and 77 deletions

14
.gitignore vendored
View File

@ -5,12 +5,6 @@
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
@ -42,9 +36,9 @@ cabal.project.local~
.ghc.environment.*
stack.yaml.lock
# Idris
*.ibc
# chat database
# Chat database
*.db
*.db.bak
# Temporary test files
tests/tmp

View File

@ -0,0 +1,19 @@
# Deduplicate contact requests
1. add nullable fields `via_contact_uri_hash` and `xcontact_id` to `connections`
2. when joining (Connect -> SCMContact)
- generate and save random `xcontact_id`
- save hash of `AConnectionRequestUri` when joining via contact uri
(AConnectionRequestUri -> ConnectionRequestUri -> CRContactUri)
- send random identifier in `XContact` as `Maybe XContactId`
- check for repeat join - if connection with such `via_contact_uri_hash` has contact notify user
- check for repeat join - check in connections if such contact uri exists, if yes use same identifier; the rest of request can (should) be regenerated, e.g. new server, profile
can be required
3. add nullable field `xcontact_id` to `contact_requests` and to `contacts` (* for auto-acceptance)
4. on contact request (processUserContactRequest)
- save identifier
- \* check if `xcontact_id` is in `contacts` - then notify this contact already exists
- when saving check if contact request with such identifier exists, if yes update `contact_request`
(`invId`, new profile)
- ? remove old invitation - probably not necessarily, to be done in scope of connection expiration
- return from Store whether request is new or updated (Bool?), new chat response for update or same response

View File

@ -27,6 +27,7 @@ library
Simplex.Chat.Migrations.M20220101_initial
Simplex.Chat.Migrations.M20220122_v1_1
Simplex.Chat.Migrations.M20220205_chat_item_status
Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests
Simplex.Chat.Mobile
Simplex.Chat.Options
Simplex.Chat.Protocol

View File

@ -179,12 +179,12 @@ processChatCommand = \case
gs -> throwChatError $ CEContactGroups ct gs
CTGroup -> pure $ chatCmdError "not implemented"
CTContactRequest -> pure $ chatCmdError "not supported"
APIAcceptContact connReqId -> withUser $ \User {userId, profile} -> do
UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p} <- withStore $ \st ->
getContactRequest st userId connReqId
withChatLock . procCmd $ do
APIAcceptContact connReqId -> withUser $ \User {userId, profile} -> withChatLock $ do
UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p, xContactId} <-
withStore $ \st -> getContactRequest st userId connReqId
procCmd $ do
connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profile
acceptedContact <- withStore $ \st -> createAcceptedContact st userId connId cName profileId p
acceptedContact <- withStore $ \st -> createAcceptedContact st userId connId cName profileId p xContactId
pure $ CRAcceptingContactRequest acceptedContact
APIRejectContact connReqId -> withUser $ \User {userId} -> withChatLock $ do
cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <-
@ -200,15 +200,14 @@ processChatCommand = \case
withStore $ \st -> createDirectConnection st userId connId
pure $ CRInvitation cReq
Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do
connect userId cReq $ XInfo profile
connId <- withAgent $ \a -> joinConnection a cReq . directMessage $ XInfo profile
withStore $ \st -> createDirectConnection st userId connId
pure CRSentConfirmation
Connect (Just (ACR SCMContact cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do
connect userId cReq $ XContact profile Nothing
pure CRSentInvitation
Connect (Just (ACR SCMContact cReq)) -> withUser $ \User {userId, profile} ->
connectViaContact userId cReq profile
Connect Nothing -> throwChatError CEInvalidConnReq
ConnectAdmin -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do
connect userId adminContactReq $ XContact profile Nothing
pure CRSentInvitation
ConnectAdmin -> withUser $ \User {userId, profile} ->
connectViaContact userId adminContactReq profile
DeleteContact cName -> withUser $ \User {userId} -> do
contactId <- withStore $ \st -> getContactIdByName st userId cName
processChatCommand $ APIDeleteChat CTDirect contactId
@ -395,10 +394,17 @@ processChatCommand = \case
-- use function below to make commands "synchronous"
-- procCmd :: m ChatResponse -> m ChatResponse
-- procCmd = id
connect :: UserId -> ConnectionRequestUri c -> ChatMsgEvent -> m ()
connect userId cReq msg = do
connId <- withAgent $ \a -> joinConnection a cReq $ directMessage msg
withStore $ \st -> createDirectConnection st userId connId
connectViaContact :: UserId -> ConnectionRequestUri 'CMContact -> Profile -> m ChatResponse
connectViaContact userId cReq profile = withChatLock $ do
let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq
withStore (\st -> getConnReqContactXContactId st userId cReqHash) >>= \case
(Just contact, _) -> pure $ CRContactAlreadyExists contact
(_, xContactId_) -> procCmd $ do
let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
xContactId <- maybe randomXContactId pure xContactId_
connId <- withAgent $ \a -> joinConnection a cReq $ directMessage (XContact profile $ Just xContactId)
withStore $ \st -> createConnReqConnection st userId connId cReqHash xContactId
pure CRSentInvitation
contactMember :: Contact -> [GroupMember] -> Maybe GroupMember
contactMember Contact {contactId} =
find $ \GroupMember {memberContactId = cId, memberStatus = s} ->
@ -812,8 +818,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
REQ invId connInfo -> do
ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo
case chatMsgEvent of
XContact p _ -> profileContactRequest invId p
XInfo p -> profileContactRequest invId p
XContact p xContactId_ -> profileContactRequest invId p xContactId_
XInfo p -> profileContactRequest invId p Nothing
-- TODO show/log error, other events in contact request
_ -> pure ()
-- TODO print errors
@ -822,11 +828,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
-- TODO add debugging output
_ -> pure ()
where
profileContactRequest :: InvitationId -> Profile -> m ()
profileContactRequest invId p = do
cReq@UserContactRequest {localDisplayName} <- withStore $ \st -> createContactRequest st userId userContactLinkId invId p
toView $ CRReceivedContactRequest cReq
showToast (localDisplayName <> "> ") "wants to connect to you"
profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> m ()
profileContactRequest invId p xContactId_ = do
withStore (\st -> createOrUpdateContactRequest st userId userContactLinkId invId p xContactId_) >>= \case
Left contact -> toView $ CRContactRequestAlreadyAccepted contact
Right cReq@UserContactRequest {localDisplayName} -> do
toView $ CRReceivedContactRequest cReq
showToast (localDisplayName <> "> ") "wants to connect to you"
withAckMessage :: ConnId -> MsgMeta -> m () -> m ()
withAckMessage cId MsgMeta {recipient = (msgId, _)} action =

View File

@ -162,6 +162,8 @@ data ChatResponse
| CRUserContactLinkDeleted
| CRReceivedContactRequest {contactRequest :: UserContactRequest}
| CRAcceptingContactRequest {contact :: Contact}
| CRContactAlreadyExists {contact :: Contact}
| CRContactRequestAlreadyAccepted {contact :: Contact}
| CRLeftMemberUser {groupInfo :: GroupInfo}
| CRGroupDeletedUser {groupInfo :: GroupInfo}
| CRRcvFileAccepted {fileTransfer :: RcvFileTransfer, filePath :: FilePath}

View File

@ -0,0 +1,23 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20220210_deduplicate_contact_requests :: Query
m20220210_deduplicate_contact_requests =
[sql|
-- hash of contact address uri used by contact request sender to connect,
-- null for contact request recipient and for both parties when using one-off invitation
ALTER TABLE connections ADD COLUMN via_contact_uri_hash BLOB;
CREATE INDEX idx_connections_via_contact_uri_hash ON connections (via_contact_uri_hash);
ALTER TABLE connections ADD COLUMN xcontact_id BLOB;
ALTER TABLE contact_requests ADD COLUMN xcontact_id BLOB;
CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests (xcontact_id);
ALTER TABLE contacts ADD COLUMN xcontact_id BLOB;
CREATE INDEX idx_contacts_xcontact_id ON contacts (xcontact_id);
|]

View File

@ -70,7 +70,7 @@ data ChatMsgEvent
| XFile FileInvitation
| XFileAcpt String
| XInfo Profile
| XContact Profile (Maybe MsgContent)
| XContact Profile (Maybe XContactId)
| XGrpInv GroupInvitation
| XGrpAcpt MemberId
| XGrpMemNew MemberInfo
@ -264,7 +264,7 @@ appToChatMessage AppMessage {event, params} = do
XFile_ -> XFile <$> p "file"
XFileAcpt_ -> XFileAcpt <$> p "fileName"
XInfo_ -> XInfo <$> p "profile"
XContact_ -> XContact <$> p "profile" <*> JT.parseEither (.:? "content") params
XContact_ -> XContact <$> p "profile" <*> JT.parseEither (.:? "contactReqId") params
XGrpInv_ -> XGrpInv <$> p "groupInvitation"
XGrpAcpt_ -> XGrpAcpt <$> p "memberId"
XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo"
@ -292,8 +292,8 @@ chatToAppMessage ChatMessage {chatMsgEvent} = AppMessage {event, params}
XMsgNew content -> o ["content" .= content]
XFile fileInv -> o ["file" .= fileInv]
XFileAcpt fileName -> o ["fileName" .= fileName]
XInfo profile -> o ["profile" .= profile]
XContact profile content -> o $ maybe id ((:) . ("content" .=)) content ["profile" .= profile]
XInfo profile -> o $ ["profile" .= profile]
XContact profile xContactId -> o $ maybe id ((:) . ("contactReqId" .=)) xContactId ["profile" .= profile]
XGrpInv groupInv -> o ["groupInvitation" .= groupInv]
XGrpAcpt memId -> o ["memberId" .= memId]
XGrpMemNew memInfo -> o ["memberInfo" .= memInfo]

View File

@ -25,6 +25,8 @@ module Simplex.Chat.Store
getUsers,
setActiveUser,
createDirectConnection,
createConnReqConnection,
getConnReqContactXContactId,
createDirectContact,
getContactGroupNames,
deleteContact,
@ -38,7 +40,7 @@ module Simplex.Chat.Store
getUserContactLinkConnections,
deleteUserContactLink,
getUserContactLink,
createContactRequest,
createOrUpdateContactRequest,
getContactRequest,
getContactRequestIdByName,
deleteContactRequest,
@ -153,6 +155,7 @@ import Simplex.Chat.Messages
import Simplex.Chat.Migrations.M20220101_initial
import Simplex.Chat.Migrations.M20220122_v1_1
import Simplex.Chat.Migrations.M20220205_chat_item_status
import Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests
import Simplex.Chat.Protocol
import Simplex.Chat.Types
import Simplex.Chat.Util (eitherToMaybe)
@ -169,7 +172,8 @@ schemaMigrations :: [(String, Query)]
schemaMigrations =
[ ("20220101_initial", m20220101_initial),
("20220122_v1_1", m20220122_v1_1),
("20220205_chat_item_status", m20220205_chat_item_status)
("20220205_chat_item_status", m20220205_chat_item_status),
("20220210_deduplicate_contact_requests", m20220210_deduplicate_contact_requests)
]
-- | The list of migrations in ascending order by date
@ -247,6 +251,55 @@ setActiveUser st userId = do
DB.execute_ db "UPDATE users SET active_user = 0"
DB.execute db "UPDATE users SET active_user = 1 WHERE user_id = ?" (Only userId)
createConnReqConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> m ()
createConnReqConnection st userId acId cReqHash xContactId = do
liftIO . withTransaction st $ \db -> do
currentTs <- getCurrentTime
DB.execute
db
[sql|
INSERT INTO connections (
user_id, agent_conn_id, conn_status, conn_type,
created_at, updated_at, via_contact_uri_hash, xcontact_id
) VALUES (?,?,?,?,?,?,?,?)
|]
(userId, acId, ConnNew, ConnContact, currentTs, currentTs, cReqHash, xContactId)
getConnReqContactXContactId :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnReqUriHash -> m (Maybe Contact, Maybe XContactId)
getConnReqContactXContactId st userId cReqHash = do
liftIO . withTransaction st $ \db ->
getContact' db >>= \case
c@(Just _) -> pure (c, Nothing)
Nothing -> (Nothing,) <$> getXContactId db
where
getContact' :: DB.Connection -> IO (Maybe Contact)
getContact' db =
fmap toContact . listToMaybe
<$> DB.query
db
[sql|
SELECT
-- Contact
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
FROM contacts ct
JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id
JOIN connections c ON c.contact_id = ct.contact_id
WHERE ct.user_id = ? AND c.via_contact_uri_hash = ?
ORDER BY c.connection_id DESC
LIMIT 1
|]
(userId, cReqHash)
getXContactId :: DB.Connection -> IO (Maybe XContactId)
getXContactId db =
fmap fromOnly . listToMaybe
<$> DB.query
db
"SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1"
(userId, cReqHash)
createDirectConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> m ()
createDirectConnection st userId agentConnId =
liftIO . withTransaction st $ \db -> do
@ -254,7 +307,7 @@ createDirectConnection st userId agentConnId =
void $ createContactConnection_ db userId agentConnId Nothing 0 currentTs
createContactConnection_ :: DB.Connection -> UserId -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection
createContactConnection_ db userId = do createConnection_ db userId ConnContact Nothing
createContactConnection_ db userId = createConnection_ db userId ConnContact Nothing
createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection
createConnection_ db userId connType entityId acId viaContact connLevel currentTs = do
@ -519,28 +572,117 @@ getUserContactLink st userId =
connReq [Only cReq] = Right cReq
connReq _ = Left SEUserContactLinkNotFound
createContactRequest :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> InvitationId -> Profile -> m UserContactRequest
createContactRequest st userId userContactId invId Profile {displayName, fullName} =
createOrUpdateContactRequest :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> m (Either Contact UserContactRequest)
createOrUpdateContactRequest st userId userContactLinkId invId profile xContactId_ =
liftIOEither . withTransaction st $ \db ->
join <$> withLocalDisplayName db userId displayName (createContactRequest' db)
createOrUpdateContactRequest_ db userId userContactLinkId invId profile xContactId_
createOrUpdateContactRequest_ :: DB.Connection -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> IO (Either StoreError (Either Contact UserContactRequest))
createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {displayName, fullName} xContactId_ =
maybeM getContact' xContactId_ >>= \case
Just contact -> pure . Right $ Left contact
Nothing -> Right <$$> createOrUpdate_
where
createContactRequest' db ldn = do
maybeM = maybe (pure Nothing)
createOrUpdate_ :: IO (Either StoreError UserContactRequest)
createOrUpdate_ =
maybeM getContactRequest' xContactId_ >>= \case
Nothing -> createContactRequest
Just UserContactRequest {contactRequestId, profile = oldProfile} ->
updateContactRequest contactRequestId oldProfile
createContactRequest :: IO (Either StoreError UserContactRequest)
createContactRequest = do
currentTs <- getCurrentTime
DB.execute
db
"INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)"
(displayName, fullName, currentTs, currentTs)
profileId <- insertedRowId db
DB.execute
db
[sql|
INSERT INTO contact_requests
(user_contact_link_id, agent_invitation_id, contact_profile_id, local_display_name, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?)
|]
(userContactId, invId, profileId, ldn, userId, currentTs, currentTs)
contactRequestId <- insertedRowId db
getContactRequest_ db userId contactRequestId
join <$> withLocalDisplayName db userId displayName (createContactRequest_ currentTs)
where
createContactRequest_ currentTs ldn = do
DB.execute
db
"INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)"
(displayName, fullName, currentTs, currentTs)
profileId <- insertedRowId db
DB.execute
db
[sql|
INSERT INTO contact_requests
(user_contact_link_id, agent_invitation_id, contact_profile_id, local_display_name, user_id, created_at, updated_at, xcontact_id)
VALUES (?,?,?,?,?,?,?,?)
|]
(userContactLinkId, invId, profileId, ldn, userId, currentTs, currentTs, xContactId_)
contactRequestId <- insertedRowId db
getContactRequest_ db userId contactRequestId
getContact' :: XContactId -> IO (Maybe Contact)
getContact' xContactId =
fmap toContact . listToMaybe
<$> DB.query
db
[sql|
SELECT
-- Contact
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
FROM contacts ct
JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id
LEFT JOIN connections c ON c.contact_id = ct.contact_id
WHERE ct.user_id = ? AND ct.xcontact_id = ?
ORDER BY c.connection_id DESC
LIMIT 1
|]
(userId, xContactId)
getContactRequest' :: XContactId -> IO (Maybe UserContactRequest)
getContactRequest' xContactId =
fmap toContactRequest . listToMaybe
<$> DB.query
db
[sql|
SELECT
cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id,
c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at, cr.xcontact_id
FROM contact_requests cr
JOIN connections c USING (user_contact_link_id)
JOIN contact_profiles p USING (contact_profile_id)
WHERE cr.user_id = ?
AND cr.xcontact_id = ?
LIMIT 1
|]
(userId, xContactId)
updateContactRequest :: Int64 -> Profile -> IO (Either StoreError UserContactRequest)
updateContactRequest cReqId Profile {displayName = oldDisplayName} = do
currentTs <- liftIO getCurrentTime
if displayName == oldDisplayName
then updateContactRequest_ currentTs displayName
else join <$> withLocalDisplayName db userId displayName (updateContactRequest_ currentTs)
where
updateContactRequest_ updatedAt ldn = do
DB.execute
db
[sql|
UPDATE contact_profiles
SET display_name = ?,
full_name = ?,
updated_at = ?
WHERE contact_profile_id IN (
SELECT contact_profile_id
FROM contact_requests
WHERE user_id = ?
AND contact_request_id = ?
)
|]
(ldn, fullName, updatedAt, userId, cReqId)
DB.execute
db
[sql|
UPDATE contact_requests
SET agent_invitation_id = ?,
local_display_name = ?,
updated_at = ?
WHERE user_id = ?
AND contact_request_id = ?
|]
(invId, ldn, updatedAt, userId, cReqId)
getContactRequest_ db userId cReqId
getContactRequest :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m UserContactRequest
getContactRequest st userId contactRequestId =
@ -555,7 +697,7 @@ getContactRequest_ db userId contactRequestId =
[sql|
SELECT
cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id,
c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at
c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at, cr.xcontact_id
FROM contact_requests cr
JOIN connections c USING (user_contact_link_id)
JOIN contact_profiles p USING (contact_profile_id)
@ -564,12 +706,12 @@ getContactRequest_ db userId contactRequestId =
|]
(userId, contactRequestId)
type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, UTCTime)
type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, UTCTime, Maybe XContactId)
toContactRequest :: ContactRequestRow -> UserContactRequest
toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, createdAt) = do
toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, createdAt, xContactId) = do
let profile = Profile {displayName, fullName}
in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, createdAt}
in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, createdAt, xContactId}
getContactRequestIdByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Int64
getContactRequestIdByName st userId cName =
@ -592,15 +734,15 @@ deleteContactRequest st userId contactRequestId =
(userId, userId, contactRequestId)
DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId)
createAcceptedContact :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ContactName -> Int64 -> Profile -> m Contact
createAcceptedContact st userId agentConnId localDisplayName profileId profile =
createAcceptedContact :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> ContactName -> Int64 -> Profile -> Maybe XContactId -> m Contact
createAcceptedContact st userId agentConnId localDisplayName profileId profile xContactId =
liftIO . withTransaction st $ \db -> do
DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
currentTs <- getCurrentTime
DB.execute
db
"INSERT INTO contacts (user_id, local_display_name, contact_profile_id, created_at, updated_at) VALUES (?,?,?,?,?)"
(userId, localDisplayName, profileId, currentTs, currentTs)
"INSERT INTO contacts (user_id, local_display_name, contact_profile_id, created_at, updated_at, xcontact_id) VALUES (?,?,?,?,?,?)"
(userId, localDisplayName, profileId, currentTs, currentTs, xContactId)
contactId <- insertedRowId db
activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing 0 currentTs
pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt = currentTs}
@ -2148,7 +2290,7 @@ getContactRequestChatPreviews_ db User {userId} =
[sql|
SELECT
cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id,
c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at
c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at, cr.xcontact_id
FROM contact_requests cr
JOIN connections c USING (user_contact_link_id)
JOIN contact_profiles p USING (contact_profile_id)

View File

@ -10,7 +10,6 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE UndecidableInstances #-}
module Simplex.Chat.Types where
@ -100,13 +99,52 @@ data UserContactRequest = UserContactRequest
localDisplayName :: ContactName,
profileId :: Int64,
profile :: Profile,
createdAt :: UTCTime
createdAt :: UTCTime,
xContactId :: Maybe XContactId
}
deriving (Eq, Show, Generic, FromJSON)
instance ToJSON UserContactRequest where
toEncoding = J.genericToEncoding J.defaultOptions
newtype XContactId = XContactId ByteString
deriving (Eq, Show)
instance FromField XContactId where fromField f = XContactId <$> fromField f
instance ToField XContactId where toField (XContactId m) = toField m
instance StrEncoding XContactId where
strEncode (XContactId m) = strEncode m
strDecode s = XContactId <$> strDecode s
strP = XContactId <$> strP
instance FromJSON XContactId where
parseJSON = strParseJSON "XContactId"
instance ToJSON XContactId where
toJSON = strToJSON
toEncoding = strToJEncoding
newtype ConnReqUriHash = ConnReqUriHash {unConnReqUriHash :: ByteString}
deriving (Eq, Show)
instance FromField ConnReqUriHash where fromField f = ConnReqUriHash <$> fromField f
instance ToField ConnReqUriHash where toField (ConnReqUriHash m) = toField m
instance StrEncoding ConnReqUriHash where
strEncode (ConnReqUriHash m) = strEncode m
strDecode s = ConnReqUriHash <$> strDecode s
strP = ConnReqUriHash <$> strP
instance FromJSON ConnReqUriHash where
parseJSON = strParseJSON "ConnReqUriHash"
instance ToJSON ConnReqUriHash where
toJSON = strToJSON
toEncoding = strToJEncoding
type ContactName = Text
type GroupName = Text

View File

@ -65,8 +65,10 @@ responseToView cmd testView = \case
CRInvitation cReq -> r' $ viewConnReqInvitation cReq
CRSentConfirmation -> r' ["confirmation sent!"]
CRSentInvitation -> r' ["connection request sent!"]
CRContactDeleted Contact {localDisplayName = c} -> r' [ttyContact c <> ": contact is deleted"]
CRAcceptingContactRequest Contact {localDisplayName = c} -> r' [ttyContact c <> ": accepting contact request..."]
CRContactDeleted c -> r' [ttyContact' c <> ": contact is deleted"]
CRAcceptingContactRequest c -> r' [ttyFullContact c <> ": accepting contact request..."]
CRContactAlreadyExists c -> r [ttyFullContact c <> ": contact already exists"]
CRContactRequestAlreadyAccepted c -> r' [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"]
CRUserContactLinkCreated cReq -> r' $ connReqContact_ "Your new chat address is created!" cReq
CRUserContactLinkDeleted -> r' viewUserContactLinkDeleted
CRUserAcceptedGroupSent _g -> r' [] -- [ttyGroup' g <> ": joining the group..."]

View File

@ -52,6 +52,8 @@ chatTests = do
it "send and receive file to group" testGroupFileTransfer
describe "user contact link" $ do
it "should create and connect via contact link" testUserContactLink
it "should deduplicate contact requests" testDeduplicateContactRequests
it "should deduplicate contact requests with profile change" testDeduplicateContactRequestsProfileChange
it "should reject contact and delete contact link" testRejectContactAndDeleteUserContact
it "should delete connection requests when contact link deleted" testDeleteConnectionRequests
@ -700,7 +702,7 @@ testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $
alice <#? bob
alice #$$> ("/_get chats", [("<@bob", "")])
alice ##> "/ac bob"
alice <## "bob: accepting contact request..."
alice <## "bob (Bob): accepting contact request..."
concurrently_
(bob <## "alice (Alice): contact is connected")
(alice <## "bob (Bob): contact is connected")
@ -711,13 +713,128 @@ testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $
alice <#? cath
alice #$$> ("/_get chats", [("<@cath", ""), ("@bob", "hey")])
alice ##> "/ac cath"
alice <## "cath: accepting contact request..."
alice <## "cath (Catherine): accepting contact request..."
concurrently_
(cath <## "alice (Alice): contact is connected")
(alice <## "cath (Catherine): contact is connected")
alice #$$> ("/_get chats", [("@cath", ""), ("@bob", "hey")])
alice <##> cath
testDeduplicateContactRequests :: IO ()
testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do
alice ##> "/ad"
cLink <- getContactLink alice True
bob ##> ("/c " <> cLink)
alice <#? bob
alice #$$> ("/_get chats", [("<@bob", "")])
bob ##> ("/c " <> cLink)
alice <#? bob
bob ##> ("/c " <> cLink)
alice <#? bob
alice #$$> ("/_get chats", [("<@bob", "")])
alice ##> "/ac bob"
alice <## "bob (Bob): accepting contact request..."
concurrently_
(bob <## "alice (Alice): contact is connected")
(alice <## "bob (Bob): contact is connected")
bob ##> ("/c " <> cLink)
bob <## "alice (Alice): contact already exists"
alice #$$> ("/_get chats", [("@bob", "")])
bob #$$> ("/_get chats", [("@alice", "")])
alice <##> bob
alice #$$> ("/_get chats", [("@bob", "hey")])
bob #$$> ("/_get chats", [("@alice", "hey")])
bob ##> ("/c " <> cLink)
bob <## "alice (Alice): contact already exists"
alice <##> bob
alice #$> ("/_get chat @2 count=100", chat, [(1, "hi"), (0, "hey"), (1, "hi"), (0, "hey")])
bob #$> ("/_get chat @2 count=100", chat, [(0, "hi"), (1, "hey"), (0, "hi"), (1, "hey")])
cath ##> ("/c " <> cLink)
alice <#? cath
alice #$$> ("/_get chats", [("<@cath", ""), ("@bob", "hey")])
alice ##> "/ac cath"
alice <## "cath (Catherine): accepting contact request..."
concurrently_
(cath <## "alice (Alice): contact is connected")
(alice <## "cath (Catherine): contact is connected")
alice #$$> ("/_get chats", [("@cath", ""), ("@bob", "hey")])
alice <##> cath
testDeduplicateContactRequestsProfileChange :: IO ()
testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do
alice ##> "/ad"
cLink <- getContactLink alice True
bob ##> ("/c " <> cLink)
alice <#? bob
alice #$$> ("/_get chats", [("<@bob", "")])
bob ##> "/p bob"
bob <## "user full name removed (your contacts are notified)"
bob ##> ("/c " <> cLink)
bob <## "connection request sent!"
alice <## "bob wants to connect to you!"
alice <## "to accept: /ac bob"
alice <## "to reject: /rc bob (the sender will NOT be notified)"
alice #$$> ("/_get chats", [("<@bob", "")])
bob ##> "/p bob Bob Ross"
bob <## "user full name changed to Bob Ross (your contacts are notified)"
bob ##> ("/c " <> cLink)
alice <#? bob
alice #$$> ("/_get chats", [("<@bob", "")])
bob ##> "/p robert Robert"
bob <## "user profile is changed to robert (Robert) (your contacts are notified)"
bob ##> ("/c " <> cLink)
alice <#? bob
alice #$$> ("/_get chats", [("<@robert", "")])
alice ##> "/ac bob"
alice <## "no contact request from bob"
alice ##> "/ac robert"
alice <## "robert (Robert): accepting contact request..."
concurrently_
(bob <## "alice (Alice): contact is connected")
(alice <## "robert (Robert): contact is connected")
bob ##> ("/c " <> cLink)
bob <## "alice (Alice): contact already exists"
alice #$$> ("/_get chats", [("@robert", "")])
bob #$$> ("/_get chats", [("@alice", "")])
alice <##> bob
alice #$$> ("/_get chats", [("@robert", "hey")])
bob #$$> ("/_get chats", [("@alice", "hey")])
bob ##> ("/c " <> cLink)
bob <## "alice (Alice): contact already exists"
alice <##> bob
alice #$> ("/_get chat @2 count=100", chat, [(1, "hi"), (0, "hey"), (1, "hi"), (0, "hey")])
bob #$> ("/_get chat @2 count=100", chat, [(0, "hi"), (1, "hey"), (0, "hi"), (1, "hey")])
cath ##> ("/c " <> cLink)
alice <#? cath
alice #$$> ("/_get chats", [("<@cath", ""), ("@robert", "hey")])
alice ##> "/ac cath"
alice <## "cath (Catherine): accepting contact request..."
concurrently_
(cath <## "alice (Alice): contact is connected")
(alice <## "cath (Catherine): contact is connected")
alice #$$> ("/_get chats", [("@cath", ""), ("@robert", "hey")])
alice <##> cath
testRejectContactAndDeleteUserContact :: IO ()
testRejectContactAndDeleteUserContact = testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do

View File

@ -84,15 +84,18 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
it "x.file.acpt" $ "{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg"
it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}" #==# XInfo testProfile
it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\"}}}" #==# XInfo Profile {displayName = "alice", fullName = ""}
it "x.contact without content field" $
it "x.contact with xContactId" $
"{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}"
#==# XContact testProfile (Just $ XContactId "\1\2\3\4")
it "x.contact without XContactId" $
"{\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}"
#==# XContact testProfile Nothing
it "x.contact with content null" $
"{\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}"
==# XContact testProfile Nothing
it "x.contact with content" $
it "x.contact with content (ignored)" $
"{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}"
#==# XContact testProfile (Just $ MCText "hello")
==# XContact testProfile Nothing
it "x.grp.inv" $
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile}