feat: improve group member events and push delivery
This commit is contained in:
@@ -2950,7 +2950,7 @@ func (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt)
|
||||
// Filter out deleted rows.
|
||||
q += " AND deletedat IS NULL"
|
||||
}
|
||||
limit := a.maxResults
|
||||
limit := 0
|
||||
if opts != nil {
|
||||
// Ignore IfModifiedSince - we must return all entries
|
||||
// Those unmodified will be stripped of Public & Private.
|
||||
@@ -2959,13 +2959,16 @@ func (a *adapter) SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt)
|
||||
q += " AND userid=?"
|
||||
args = append(args, store.DecodeUid(opts.User))
|
||||
}
|
||||
limit = a.maxResults
|
||||
if opts.Limit > 0 && opts.Limit < limit {
|
||||
limit = opts.Limit
|
||||
}
|
||||
}
|
||||
|
||||
q += " LIMIT ?"
|
||||
args = append(args, limit)
|
||||
if limit > 0 {
|
||||
q += " LIMIT ?"
|
||||
args = append(args, limit)
|
||||
}
|
||||
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
|
||||
@@ -1779,8 +1779,9 @@ func (a *adapter) TenantSubsForTopic(id t.TenantID, topic string, keepDeleted bo
|
||||
if !keepDeleted {
|
||||
q += " AND deletedat IS NULL"
|
||||
}
|
||||
limit := a.maxResults
|
||||
limit := 0
|
||||
if opts != nil {
|
||||
limit = a.maxResults
|
||||
if !opts.User.IsZero() {
|
||||
q += " AND userid=?"
|
||||
args = append(args, decodeUID(opts.User))
|
||||
@@ -1789,8 +1790,10 @@ func (a *adapter) TenantSubsForTopic(id t.TenantID, topic string, keepDeleted bo
|
||||
limit = opts.Limit
|
||||
}
|
||||
}
|
||||
q += " LIMIT ?"
|
||||
args = append(args, limit)
|
||||
if limit > 0 {
|
||||
q += " LIMIT ?"
|
||||
args = append(args, limit)
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
|
||||
@@ -2,10 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/logs"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
@@ -15,6 +17,7 @@ func publishIMGroupRealtimeEvent(tenantID types.TenantID, topic string, actor ty
|
||||
if tenantID.IsZero() || topic == "" || eventType == "" {
|
||||
return
|
||||
}
|
||||
payload = enrichIMGroupMemberEventPayload(eventType, payload)
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
logs.Warn.Printf("group realtime: encode event topic=%s event=%s: %v", topic, eventType, err)
|
||||
@@ -24,6 +27,10 @@ func publishIMGroupRealtimeEvent(tenantID types.TenantID, topic string, actor ty
|
||||
if !actor.IsZero() {
|
||||
from = actor.UserId()
|
||||
}
|
||||
if isAdminOnlyIMGroupEvent(eventType) {
|
||||
publishIMGroupEventToAdmins(tenantID, topic, actor, eventType, encoded, payload)
|
||||
return
|
||||
}
|
||||
message := &ServerComMessage{
|
||||
TenantID: tenantID,
|
||||
RcptTo: topic,
|
||||
@@ -47,6 +54,129 @@ func publishIMGroupRealtimeEvent(tenantID types.TenantID, topic string, actor ty
|
||||
publishRealtimeTopicMessage(topic, message)
|
||||
}
|
||||
|
||||
func isAdminOnlyIMGroupEvent(eventType string) bool {
|
||||
switch eventType {
|
||||
case "group.member.updated", "group.member.left", "group.role.updated", "group.mute.updated",
|
||||
"group.invitations.updated", "group.join_request.created", "group.join_request.accepted",
|
||||
"group.join_request.rejected":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func publishIMGroupEventToAdmins(tenantID types.TenantID, topic string, actor types.Uid, eventType string,
|
||||
encoded json.RawMessage, payload any) {
|
||||
recipients := map[types.Uid]struct{}{}
|
||||
if !actor.IsZero() {
|
||||
recipients[actor] = struct{}{}
|
||||
}
|
||||
collectIMGroupEventUserIDs(payload, recipients)
|
||||
for _, role := range []string{types.GroupMemberRoleOwner, types.GroupMemberRoleAdmin} {
|
||||
if store.GroupBusiness == nil {
|
||||
break
|
||||
}
|
||||
cursor := ""
|
||||
for {
|
||||
members, next, err := store.GroupBusiness.ListMembers(tenantID, topic, types.GroupMemberListOptions{
|
||||
Role: role, Cursor: cursor, Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
logs.Warn.Printf("group realtime: list %s recipients topic=%s event=%s: %v", role, topic, eventType, err)
|
||||
break
|
||||
}
|
||||
for _, member := range members {
|
||||
if !member.UserID.IsZero() {
|
||||
recipients[member.UserID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if next == "" || next == cursor {
|
||||
break
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
}
|
||||
for uid := range recipients {
|
||||
message := &ServerComMessage{
|
||||
TenantID: tenantID, RcptTo: uid.UserId(), AsUser: actor.UserId(),
|
||||
Timestamp: time.Now().UTC().Round(time.Millisecond),
|
||||
Info: &MsgServerInfo{Topic: topic, From: actor.UserId(), What: "group", Event: eventType, Payload: encoded},
|
||||
}
|
||||
if globals.sessionStore != nil {
|
||||
globals.sessionStore.DeliverToUser(tenantID, uid, "", message)
|
||||
}
|
||||
publishRealtimeUserMessage(uid, message)
|
||||
}
|
||||
}
|
||||
|
||||
func collectIMGroupEventUserIDs(value any, out map[types.Uid]struct{}) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if ok, exists := typed["ok"].(bool); exists && !ok {
|
||||
return
|
||||
}
|
||||
for key, item := range typed {
|
||||
switch key {
|
||||
case "user_id", "target_id", "target_user_id", "applicant_id", "applicant_user_id", "owner_id":
|
||||
if uid := imGroupEventUID(item); !uid.IsZero() {
|
||||
out[uid] = struct{}{}
|
||||
}
|
||||
}
|
||||
collectIMGroupEventUserIDs(item, out)
|
||||
}
|
||||
case []map[string]any:
|
||||
for _, item := range typed {
|
||||
collectIMGroupEventUserIDs(item, out)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
collectIMGroupEventUserIDs(item, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func imGroupEventUID(value any) types.Uid {
|
||||
if uid := types.ParseUserId(strings.TrimSpace(fmt.Sprint(value))); !uid.IsZero() {
|
||||
return uid
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case int64:
|
||||
return store.EncodeUid(typed)
|
||||
case int:
|
||||
return store.EncodeUid(int64(typed))
|
||||
case float64:
|
||||
return store.EncodeUid(int64(typed))
|
||||
}
|
||||
return types.ZeroUid
|
||||
}
|
||||
|
||||
func enrichIMGroupMemberEventPayload(eventType string, payload any) any {
|
||||
data, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return payload
|
||||
}
|
||||
copyPayload := make(map[string]any, len(data)+2)
|
||||
for key, value := range data {
|
||||
copyPayload[key] = value
|
||||
}
|
||||
switch eventType {
|
||||
case "group.member.left":
|
||||
copyPayload["member_sync"] = "remove"
|
||||
case "group.member.joined", "group.member.updated", "group.role.updated", "group.mute.updated":
|
||||
if _, ok := copyPayload["user_id"]; ok {
|
||||
copyPayload["member_sync"] = "single"
|
||||
} else {
|
||||
copyPayload["member_sync"] = "full"
|
||||
}
|
||||
}
|
||||
if isAdminOnlyIMGroupEvent(eventType) {
|
||||
copyPayload["visibility"] = "admins_and_affected"
|
||||
} else if eventType == "group.member.joined" {
|
||||
copyPayload["visibility"] = "all_members"
|
||||
}
|
||||
return copyPayload
|
||||
}
|
||||
|
||||
func userIDs(uids []types.Uid) []string {
|
||||
ids := make([]string, 0, len(uids))
|
||||
for _, uid := range uids {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/mock_store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
func TestPublishIMGroupRealtimeEventRestrictsMemberRemoval(t *testing.T) {
|
||||
oldGroupBusiness := store.GroupBusiness
|
||||
oldSessions := globals.sessionStore
|
||||
oldHub := globals.hub
|
||||
admin := types.Uid(1)
|
||||
affected := types.Uid(2)
|
||||
regular := types.Uid(3)
|
||||
actor := types.Uid(4)
|
||||
store.GroupBusiness = &testGroupBusinessStore{listMembers: []types.GroupMember{
|
||||
{UserID: admin, Role: types.GroupMemberRoleAdmin, MemberStatus: types.GroupMemberStatusActive},
|
||||
}}
|
||||
globals.sessionStore = NewSessionStore(time.Minute)
|
||||
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 2)}
|
||||
t.Cleanup(func() {
|
||||
store.GroupBusiness = oldGroupBusiness
|
||||
globals.sessionStore = oldSessions
|
||||
globals.hub = oldHub
|
||||
})
|
||||
|
||||
sessions := map[types.Uid]*Session{}
|
||||
for _, uid := range []types.Uid{admin, affected, regular} {
|
||||
sess := &Session{
|
||||
sid: uid.UserId(), uid: uid, tenantID: 7, proto: WEBSOCK,
|
||||
send: make(chan any, 2), subs: make(map[string]*Subscription),
|
||||
}
|
||||
globals.sessionStore.sessCache[sess.sid] = sess
|
||||
sessions[uid] = sess
|
||||
}
|
||||
|
||||
publishIMGroupRealtimeEvent(7, "grpRestricted", actor, "group.member.left",
|
||||
map[string]any{"user_id": affected.UserId()})
|
||||
|
||||
for _, uid := range []types.Uid{admin, affected} {
|
||||
select {
|
||||
case raw := <-sessions[uid].send:
|
||||
msg, ok := raw.(*ServerComMessage)
|
||||
if !ok || msg.Info == nil || msg.Info.Event != "group.member.left" || msg.Info.Topic != "grpRestricted" {
|
||||
t.Fatalf("unexpected event for %s: %#v", uid.UserId(), raw)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(msg.Info.Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["member_sync"] != "remove" || payload["visibility"] != "admins_and_affected" {
|
||||
t.Fatalf("unexpected payload for %s: %#v", uid.UserId(), payload)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("expected event for %s", uid.UserId())
|
||||
}
|
||||
}
|
||||
select {
|
||||
case raw := <-sessions[regular].send:
|
||||
t.Fatalf("regular member received restricted event: %#v", raw)
|
||||
default:
|
||||
}
|
||||
if len(globals.hub.routeSrv) != 0 {
|
||||
t.Fatalf("restricted event was broadcast to topic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishIMGroupRealtimeEventBroadcastsMemberJoin(t *testing.T) {
|
||||
oldHub := globals.hub
|
||||
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)}
|
||||
t.Cleanup(func() { globals.hub = oldHub })
|
||||
|
||||
publishIMGroupRealtimeEvent(7, "grpPublic", types.Uid(1), "group.member.joined",
|
||||
map[string]any{"user_id": types.Uid(2).UserId()})
|
||||
|
||||
select {
|
||||
case msg := <-globals.hub.routeSrv:
|
||||
if msg.Info == nil || msg.Info.Event != "group.member.joined" {
|
||||
t.Fatalf("unexpected joined event: %#v", msg)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(msg.Info.Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["member_sync"] != "single" || payload["visibility"] != "all_members" {
|
||||
t.Fatalf("unexpected joined payload: %#v", payload)
|
||||
}
|
||||
default:
|
||||
t.Fatal("joined event was not broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendPushForStoredGroupMessageIncludesEverySubscription(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
oldTopics := store.Topics
|
||||
oldUsersUpdate := globals.usersUpdate
|
||||
oldCluster := globals.cluster
|
||||
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
||||
store.Topics = topics
|
||||
globals.usersUpdate = make(chan *UserCacheReq, 1)
|
||||
globals.cluster = nil
|
||||
t.Cleanup(func() {
|
||||
store.Topics = oldTopics
|
||||
globals.usersUpdate = oldUsersUpdate
|
||||
globals.cluster = oldCluster
|
||||
})
|
||||
|
||||
subs := make([]types.Subscription, 0, 1500)
|
||||
for i := 1; i <= 1500; i++ {
|
||||
sub := types.Subscription{
|
||||
User: types.Uid(i).String(), Topic: "grpLarge", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
|
||||
}
|
||||
if i == 1500 {
|
||||
sub.Private = map[string]any{"mute_notifications": true}
|
||||
}
|
||||
subs = append(subs, sub)
|
||||
}
|
||||
topics.EXPECT().GetSubs(types.TenantID(7), "grpLarge", nil).Return(subs, nil)
|
||||
msg := &types.Message{
|
||||
ObjHeader: types.ObjHeader{CreatedAt: time.Now().UTC()}, TenantID: 7,
|
||||
Topic: "grpLarge", SeqId: 99, Content: map[string]any{"text": "成员加入了群聊"},
|
||||
}
|
||||
|
||||
sendPushForStoredGroupMessage(7, msg)
|
||||
|
||||
select {
|
||||
case req := <-globals.usersUpdate:
|
||||
if req.PushRcpt == nil || len(req.PushRcpt.To) != len(subs) {
|
||||
t.Fatalf("push recipients: got %d want %d", len(req.PushRcpt.To), len(subs))
|
||||
}
|
||||
for _, recipient := range req.PushRcpt.To {
|
||||
if !recipient.ShouldIncrementUnreadCountInCache {
|
||||
t.Fatal("system group message did not increment per-user unread")
|
||||
}
|
||||
}
|
||||
if !req.PushRcpt.To[types.Uid(1500)].Silent {
|
||||
t.Fatal("muted group recipient must receive a silent push")
|
||||
}
|
||||
default:
|
||||
t.Fatal("group system message did not enqueue push")
|
||||
}
|
||||
}
|
||||
@@ -3967,6 +3967,15 @@ func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) {
|
||||
serveIMGroupMembers(wrt, req, topic)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(tail, "members/") && !strings.HasSuffix(tail, "/profile") {
|
||||
memberID := types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(tail, "members/")))
|
||||
if memberID.IsZero() {
|
||||
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group route not found", nil)
|
||||
return
|
||||
}
|
||||
serveIMGroupMember(wrt, req, topic, memberID)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(tail, "members/") && strings.HasSuffix(tail, "/profile") {
|
||||
memberID, ok := groupMemberProfilePath(tail)
|
||||
if !ok {
|
||||
@@ -4066,11 +4075,60 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
|
||||
"topic": topic,
|
||||
"items": items,
|
||||
"next_cursor": nextCursor,
|
||||
"sync_mode": "full",
|
||||
"permissions": permissions,
|
||||
}
|
||||
writeIMSuccess(wrt, req, payload)
|
||||
}
|
||||
|
||||
func serveIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic string, memberID types.Uid) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !(selfSub.ModeGiven & selfSub.ModeWant).IsReader() {
|
||||
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
|
||||
return
|
||||
}
|
||||
member, err := store.GroupBusiness.GetMember(principal.TenantID, topic, memberID)
|
||||
if err != nil {
|
||||
if errors.Is(err, types.ErrNotFound) {
|
||||
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
|
||||
return
|
||||
}
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
if member == nil || member.MemberStatus != types.GroupMemberStatusActive {
|
||||
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
|
||||
return
|
||||
}
|
||||
memberSub, err := store.Subs.Get(principal.TenantID, topic, memberID, false)
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
if memberSub == nil || !(memberSub.ModeGiven & memberSub.ModeWant).IsJoiner() {
|
||||
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "member not found", nil)
|
||||
return
|
||||
}
|
||||
member.ModeWant = memberSub.ModeWant
|
||||
member.ModeGiven = memberSub.ModeGiven
|
||||
if user, userErr := store.Users.Get(principal.TenantID, memberID); userErr == nil && user != nil {
|
||||
member.Public = user.Public
|
||||
member.Trusted = user.Trusted
|
||||
}
|
||||
remarks := groupMemberRemarkMap(principal.TenantID, principal.Uid, []types.GroupMember{*member})
|
||||
payload := groupBusinessMemberPayload(*member, remarks[member.UserID])
|
||||
payload["topic"] = topic
|
||||
payload["sync_mode"] = "single"
|
||||
writeIMSuccess(wrt, req, payload)
|
||||
}
|
||||
|
||||
func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string) {
|
||||
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true)
|
||||
if !ok {
|
||||
@@ -6861,6 +6919,10 @@ func groupBusinessMemberPayload(member types.GroupMember, remark string) map[str
|
||||
data["group_nickname"] = nickname
|
||||
}
|
||||
data["role"] = groupMemberRole(access)
|
||||
data["member_status"] = member.MemberStatus
|
||||
data["version"] = member.Version
|
||||
data["joined_at"] = member.JoinedAt.UTC().Format(time.RFC3339Nano)
|
||||
data["updated_at"] = member.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
||||
data["mentionable"] = access.IsJoiner() && access.IsReader()
|
||||
data["acs"] = map[string]any{
|
||||
"mode": access.String(),
|
||||
@@ -9941,6 +10003,7 @@ func publishIMConversationSystemNotice(tenantID types.TenantID, topic, msgType,
|
||||
return
|
||||
}
|
||||
broadcastIMConversationSystemMessage(tenantID, msg, now)
|
||||
sendPushForStoredGroupMessage(tenantID, msg)
|
||||
}
|
||||
|
||||
func broadcastIMConversationSystemMessage(tenantID types.TenantID, msg *types.Message, timestamp time.Time) {
|
||||
|
||||
@@ -339,6 +339,9 @@ func (s *testGroupBusinessStore) UpsertMember(_ types.TenantID, member *types.Gr
|
||||
}
|
||||
|
||||
func (s *testGroupBusinessStore) GetMember(_ types.TenantID, topic string, uid types.Uid) (*types.GroupMember, error) {
|
||||
if s.member != nil {
|
||||
return s.member, nil
|
||||
}
|
||||
return &types.GroupMember{Topic: topic, UserID: uid, Role: types.GroupMemberRoleMember, MemberStatus: types.GroupMemberStatusActive, Version: 1}, nil
|
||||
}
|
||||
|
||||
@@ -3034,6 +3037,59 @@ func TestServeIMGroupMembers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMGroupMemberReturnsSingleSnapshot(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldSubs := store.Subs
|
||||
oldUsers := store.Users
|
||||
oldGroupBusiness := store.GroupBusiness
|
||||
oldRelations := store.Relations
|
||||
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
||||
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
||||
store.Subs = subs
|
||||
store.Users = users
|
||||
memberID := types.Uid(101)
|
||||
joinedAt := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC)
|
||||
store.GroupBusiness = &testGroupBusinessStore{member: &types.GroupMember{
|
||||
ID: 101, Topic: "grpMention", UserID: memberID, Role: types.GroupMemberRoleMember,
|
||||
MemberStatus: types.GroupMemberStatusActive, Nickname: "群里小王", Version: 3,
|
||||
JoinedAt: joinedAt, UpdatedAt: joinedAt.Add(time.Minute),
|
||||
}}
|
||||
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
|
||||
TenantID: 7, UserID: 42, PeerID: memberID, Relationship: types.RelationshipNone,
|
||||
}}
|
||||
t.Cleanup(func() {
|
||||
store.Subs = oldSubs
|
||||
store.Users = oldUsers
|
||||
store.GroupBusiness = oldGroupBusiness
|
||||
store.Relations = oldRelations
|
||||
})
|
||||
|
||||
fullSub := func(uid types.Uid) *types.Subscription {
|
||||
return &types.Subscription{TenantID: 7, User: uid.String(), Topic: "grpMention", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull}
|
||||
}
|
||||
subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(fullSub(42), nil)
|
||||
subs.EXPECT().Get(types.TenantID(7), "grpMention", memberID, false).Return(fullSub(memberID), nil)
|
||||
users.EXPECT().Get(types.TenantID(7), memberID).Return(&types.User{
|
||||
ObjHeader: types.ObjHeader{Id: memberID.String()}, Public: map[string]any{"nickname": "Alice", "avatar_file_id": "alice.jpg"},
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members/"+memberID.UserId(), nil)
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
serveIMGroupResource(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
if data["user_id"] != memberID.UserId() || data["sync_mode"] != "single" || data["version"].(float64) != 3 ||
|
||||
data["group_nickname"] != "群里小王" || data["nickname"] != "Alice" {
|
||||
t.Fatalf("unexpected single member response: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMemberPayloadUserIDFormats(t *testing.T) {
|
||||
raw := types.Uid(101)
|
||||
fromRaw := groupMemberPayload(types.Subscription{
|
||||
|
||||
@@ -91,6 +91,8 @@ func (t *Topic) pushForData(fromUid types.Uid, data *MsgServerData, msgMarkedAsR
|
||||
// Number of attached sessions the data message will be delivered to.
|
||||
// Push notifications sent to users with non-zero online sessions will be marked silent.
|
||||
Delivered: online,
|
||||
Silent: t.cat == types.TopicCatGrp &&
|
||||
boolFromAny(groupPrivateMap(pud.private)["mute_notifications"]),
|
||||
// Unread counts are incremented for all recipients,
|
||||
// and for sender only if the message wasnt't marked 'read' by the sender
|
||||
ShouldIncrementUnreadCountInCache: uid != fromUid || !msgMarkedAsReadBySender,
|
||||
@@ -104,6 +106,60 @@ func (t *Topic) pushForData(fromUid types.Uid, data *MsgServerData, msgMarkedAsR
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendPushForStoredGroupMessage handles server-generated group messages which
|
||||
// bypass Topic.handlePubBroadcast but must still update per-user unread counts
|
||||
// and notify offline devices.
|
||||
func sendPushForStoredGroupMessage(tenantID types.TenantID, msg *types.Message) {
|
||||
if msg == nil || globals.usersUpdate == nil || types.GetTopicCat(msg.Topic) != types.TopicCatGrp || store.Topics == nil {
|
||||
return
|
||||
}
|
||||
subs, err := store.Topics.GetSubs(tenantID, msg.Topic, nil)
|
||||
if err != nil {
|
||||
logs.Warn.Printf("group system push: load recipients topic=%s: %v", msg.Topic, err)
|
||||
return
|
||||
}
|
||||
receipt := &push.Receipt{
|
||||
TenantID: tenantID,
|
||||
To: make(map[types.Uid]push.Recipient, len(subs)),
|
||||
Payload: push.Payload{
|
||||
What: push.ActMsg, Topic: msg.Topic, Timestamp: msg.CreatedAt, SeqId: msg.SeqId,
|
||||
Content: msg.Content,
|
||||
},
|
||||
}
|
||||
if mime, ok := msg.Head["mime"].(string); ok {
|
||||
receipt.Payload.ContentType = mime
|
||||
}
|
||||
for _, sub := range subs {
|
||||
uid := types.ParseUid(sub.User)
|
||||
mode := sub.ModeWant & sub.ModeGiven
|
||||
if uid.IsZero() || !mode.IsPresencer() || !mode.IsReader() {
|
||||
continue
|
||||
}
|
||||
delivered := 0
|
||||
if globals.sessionStore != nil {
|
||||
globals.sessionStore.Range(func(_ string, sess *Session) bool {
|
||||
if sess != nil && !sess.isCluster() && !sess.isProxy() && sess.tenantID == tenantID && sess.uid == uid {
|
||||
delivered++
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if presence, presenceErr := store.GetUserPresence(tenantID, uid); presenceErr != nil {
|
||||
logs.Warn.Printf("group system push: read presence topic=%s uid=%s: %v", msg.Topic, uid.UserId(), presenceErr)
|
||||
} else if presence != nil && presence.Online && presence.SessionCount > delivered {
|
||||
delivered = presence.SessionCount
|
||||
}
|
||||
receipt.To[uid] = push.Recipient{
|
||||
Delivered: delivered,
|
||||
Silent: boolFromAny(groupPrivateMap(sub.Private)["mute_notifications"]),
|
||||
ShouldIncrementUnreadCountInCache: true,
|
||||
}
|
||||
}
|
||||
if len(receipt.To) > 0 {
|
||||
sendPush(receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Topic) preparePushForSubReceipt(fromUid types.Uid, now time.Time) *push.Receipt {
|
||||
// P2P push payloads use the canonical p2p... topic. usr... is accepted only
|
||||
// as an input alias.
|
||||
|
||||
@@ -158,7 +158,7 @@ func PrepareV1Notifications(rcpt *push.Receipt, config *configType) ([]*fcmv1.Me
|
||||
for uid, devList := range devices {
|
||||
topic := rcpt.Payload.Topic
|
||||
userData := data
|
||||
if rcpt.To[uid].Delivered > 0 {
|
||||
if rcpt.To[uid].Delivered > 0 || rcpt.To[uid].Silent {
|
||||
userData = clonePayload(data)
|
||||
// Silence the push for user who have received the data interactively.
|
||||
userData["silent"] = "true"
|
||||
|
||||
@@ -351,7 +351,7 @@ func preparePayloads(rcpt *push.Receipt, config *configType) ([]*jpushPayload, [
|
||||
for _, uid := range uids {
|
||||
recipient := rcpt.To[uid]
|
||||
userData := cloneData(data)
|
||||
if recipient.Delivered > 0 {
|
||||
if recipient.Delivered > 0 || recipient.Silent {
|
||||
userData["silent"] = "true"
|
||||
}
|
||||
topic := rcpt.Payload.Topic
|
||||
|
||||
@@ -28,6 +28,8 @@ type Recipient struct {
|
||||
Delivered int `json:"delivered"`
|
||||
// List of user's devices that the packet was delivered to (if known). Len(Devices) >= Delivered
|
||||
Devices []string `json:"devices,omitempty"`
|
||||
// Deliver as a data-only notification without showing an alert.
|
||||
Silent bool `json:"silent,omitempty"`
|
||||
// Unread count to include in the push
|
||||
Unread int `json:"unread"`
|
||||
// Indicates whether unread counter in the cache should be incremented before sending the push.
|
||||
|
||||
@@ -564,6 +564,39 @@ func TestDispatchAlreadySubscribed(t *testing.T) {
|
||||
verifyResponseCodes(&r, []int{http.StatusNotModified}, t)
|
||||
}
|
||||
|
||||
func TestSubscribeP2PUserAliasReachesTopic(t *testing.T) {
|
||||
oldHub := globals.hub
|
||||
globals.hub = &Hub{join: make(chan *ClientComMessage, 1)}
|
||||
t.Cleanup(func() { globals.hub = oldHub })
|
||||
|
||||
self := types.Uid(1)
|
||||
peer := types.Uid(2)
|
||||
canonical := self.P2PName(peer)
|
||||
s := test_makeSession(self)
|
||||
s.subs = map[string]*Subscription{canonical: {}}
|
||||
msg := &ClientComMessage{
|
||||
Sub: &MsgClientSub{Id: "alias-sub", Topic: peer.UserId()},
|
||||
Id: "alias-sub",
|
||||
Original: peer.UserId(),
|
||||
AsUser: self.UserId(),
|
||||
TenantID: s.tenantID,
|
||||
Timestamp: types.TimeNow(),
|
||||
sess: s,
|
||||
}
|
||||
|
||||
s.subscribe(msg)
|
||||
|
||||
select {
|
||||
case routed := <-globals.hub.join:
|
||||
if routed != msg || routed.RcptTo != canonical {
|
||||
t.Fatalf("unexpected alias subscription route: %#v", routed)
|
||||
}
|
||||
s.inflightReqs.Done()
|
||||
default:
|
||||
t.Fatal("p2p user alias was incorrectly handled as an already-subscribed topic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchSubscribeJoinChannelFull(t *testing.T) {
|
||||
uid := types.Uid(1)
|
||||
s := test_makeSession(uid)
|
||||
|
||||
Reference in New Issue
Block a user