From 39b039254540846e326ef80869203599b63cb6b4 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Tue, 20 Oct 2026 22:24:34 +0800 Subject: [PATCH] feat: add trusted customer service friendships --- server/im_http_test.go | 6 ++++++ server/internal_api.go | 30 ++++++++++++++++++++++++++++++ server/internal_api_test.go | 30 ++++++++++++++++++++++++++++++ server/store/store.go | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+) diff --git a/server/im_http_test.go b/server/im_http_test.go index d3124ff..92db245 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -147,6 +147,7 @@ func (s *testHTTPInfrastructureStore) MarkKafkaDeadLetterReplay(_ types.TenantID type testRelationsStore struct { context *types.RelationshipContext err error + directAdded bool created *types.FriendRequest blocked *types.UserBlock deleted bool @@ -964,6 +965,11 @@ func (s *testRelationsStore) CreateFriendRequest(_ types.TenantID, req *types.Fr return req, nil } +func (s *testRelationsStore) AddFriendDirect(_ types.TenantID, _, _ types.Uid, _ time.Time) error { + s.directAdded = true + return s.err +} + func (s *testRelationsStore) ListFriendRequests(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FriendRequestListItem, string, error) { return s.friendRequests, "", nil } diff --git a/server/internal_api.go b/server/internal_api.go index da542c0..6fe1ab7 100644 --- a/server/internal_api.go +++ b/server/internal_api.go @@ -116,6 +116,8 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix serveInternalUserCreate(wrt, req, tenant.ID) case req.Method == http.MethodPost && len(parts) == 5 && parts[2] == "users" && parts[4] == "action": serveInternalUserAction(wrt, req, tenant.ID, parts[3]) + case req.Method == http.MethodPost && len(parts) == 4 && parts[2] == "friends" && parts[3] == "direct": + serveInternalFriendDirectAdd(wrt, req, tenant.ID) case req.Method == http.MethodPost && len(parts) == 4 && parts[2] == "login-lockouts" && parts[3] == "release": serveInternalLoginLockoutRelease(wrt, req, tenant.ID) case req.Method == http.MethodGet && len(parts) == 3 && parts[2] == "users": @@ -166,9 +168,37 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix func isInternalAdminPostRoute(parts []string) bool { return (len(parts) == 3 && parts[2] == "users") || (len(parts) == 5 && parts[2] == "users" && parts[4] == "action") || + (len(parts) == 4 && parts[2] == "friends" && parts[3] == "direct") || (len(parts) == 4 && parts[2] == "login-lockouts" && parts[3] == "release") } +type internalDirectFriendRequest struct { + UserID string `json:"user_id"` + PeerID string `json:"peer_user_id"` +} + +func serveInternalFriendDirectAdd(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) { + var body internalDirectFriendRequest + if err := json.NewDecoder(io.LimitReader(req.Body, 1<<20)).Decode(&body); err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) + return + } + userID := types.ParseUserId(strings.TrimSpace(body.UserID)) + peerID := types.ParseUserId(strings.TrimSpace(body.PeerID)) + if userID.IsZero() || peerID.IsZero() || userID == peerID { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed user IDs", nil) + return + } + if err := store.Relations.AddFriendDirect(tenantID, userID, peerID, time.Now().UTC().Round(time.Millisecond)); err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, map[string]any{ + "relationship": "friend", + "topic": userID.P2PName(peerID), + }) +} + type internalUserCreateRequest struct { Username string `json:"username"` Nickname string `json:"nickname"` diff --git a/server/internal_api_test.go b/server/internal_api_test.go index 4d5df25..9d496c4 100644 --- a/server/internal_api_test.go +++ b/server/internal_api_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -37,6 +38,35 @@ func (internalAdminQueryNotFound) QueryOne(types.TenantID, string, []any) (map[s return nil, types.ErrNotFound } +func TestServeInternalFriendDirectAddCreatesRelationshipWithoutRequest(t *testing.T) { + oldRelations := store.Relations + relations := &testRelationsStore{} + store.Relations = relations + t.Cleanup(func() { store.Relations = oldRelations }) + + req := httptest.NewRequest(http.MethodPost, "/internal/admin-query/tenants/default/friends/direct", strings.NewReader(`{"user_id":"usrAQAAAAAAAAA","peer_user_id":"usrAgAAAAAAAAA"}`)) + wrt := httptest.NewRecorder() + serveInternalFriendDirectAdd(wrt, req, types.TenantID(1)) + + if wrt.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", wrt.Code, http.StatusOK, wrt.Body.String()) + } + if !relations.directAdded || relations.created != nil { + t.Fatalf("direct_added=%v friend_request=%v, want direct add and no request", relations.directAdded, relations.created) + } + var payload struct { + Data struct { + Relationship string `json:"relationship"` + } `json:"data"` + } + if err := json.Unmarshal(wrt.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.Data.Relationship != types.RelationshipFriend { + t.Fatalf("relationship = %q, want %q", payload.Data.Relationship, types.RelationshipFriend) + } +} + func TestServeInternalUserQueryMissingUserReturnsNotFound(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/server/store/store.go b/server/store/store.go index d8afc0f..a546870 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -3065,6 +3065,7 @@ func validUserProfilePatch(patch types.UserProfilePatch) bool { } type RelationsPersistenceInterface interface { + AddFriendDirect(tenantID types.TenantID, userID, peerID types.Uid, now time.Time) error CreateFriendRequest(tenantID types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error) ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error) CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error) @@ -3153,6 +3154,41 @@ func (relationsMapper) CreateFriendRequest(tenantID types.TenantID, req *types.F return tenantAdp.TenantFriendRequestCreate(tenantID, req) } +// AddFriendDirect creates a symmetric friendship for trusted server-side +// workflows, such as opening a customer-service conversation. It deliberately +// does not create a friend-request record, but it still honors either user's +// block list. +func (relationsMapper) AddFriendDirect(tenantID types.TenantID, userID, peerID types.Uid, now time.Time) error { + if userID.IsZero() || peerID.IsZero() || userID == peerID { + return types.ErrMalformed + } + if _, err := Users.Get(tenantID, userID); err != nil { + return err + } + if _, err := Users.Get(tenantID, peerID); err != nil { + return err + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return err + } + relationship, err := relationshipContext(tenantAdp, tenantID, userID, peerID, "") + if err != nil { + return err + } + if relationship.Relationship == types.RelationshipBlockedByMe || relationship.Relationship == types.RelationshipBlockedByPeer { + return types.ErrPermissionDenied + } + if now.IsZero() { + now = time.Now().UTC() + } + if err = tenantAdp.TenantFriendshipUpsertPair(tenantID, userID, peerID, userID.P2PName(peerID), now); err != nil { + return err + } + cacheFriendPair(tenantID, userID, peerID) + return nil +} + func (relationsMapper) ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error) { if uid.IsZero() { return nil, "", types.ErrMalformed