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" ) func publishIMGroupRealtimeEvent(tenantID types.TenantID, topic string, actor types.Uid, eventType string, payload any) { topic = strings.TrimSpace(topic) eventType = strings.TrimSpace(eventType) 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) encoded = []byte(`{}`) } from := "" if !actor.IsZero() { from = actor.UserId() } if isAdminOnlyIMGroupEvent(eventType) { publishIMGroupEventToAdmins(tenantID, topic, actor, eventType, encoded, payload) return } message := &ServerComMessage{ TenantID: tenantID, RcptTo: topic, AsUser: from, Timestamp: time.Now().UTC().Round(time.Millisecond), Info: &MsgServerInfo{ Topic: topic, From: from, What: "group", Event: eventType, Payload: json.RawMessage(encoded), }, } if globals.hub != nil { select { case globals.hub.routeSrv <- message: default: logs.Warn.Printf("group realtime: hub queue full topic=%s event=%s", topic, eventType) } } 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 { if !uid.IsZero() { ids = append(ids, uid.UserId()) } } return ids }