package postgres import ( "context" "errors" "fmt" "sort" "strings" "github.com/jackc/pgx/v5" "telesrv/internal/domain" "telesrv/internal/store" ) func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { channel, viewer, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID) if err != nil { return domain.ChannelParticipantList{}, err } if offset < 0 { offset = 0 } if offset > domain.MaxChannelParticipantsOffset { offset = domain.MaxChannelParticipantsOffset } if limit <= 0 || limit > domain.MaxChannelParticipantsLimit { limit = domain.MaxChannelParticipantsLimit } count := channel.ParticipantsCount // 广播频道订阅者列表仅管理员可枚举(与隐藏成员同一门控):非管理员只拿到计数, // admins filter 仍放行(消息管理员徽章数据源),bots 返回空。 if channel.MembersListAdminOnly() && !isChannelAdmin(viewer) { switch filter.Kind { case domain.ChannelParticipantsAdmins: case domain.ChannelParticipantsBots: return domain.ChannelParticipantList{Channel: channel, Count: 0}, nil default: return domain.ChannelParticipantList{Channel: channel, Count: channel.ParticipantsCount}, nil } } where := []string{"m.channel_id = $1"} args := []any{channelID} joinUsers := false query := strings.TrimSpace(filter.Query) switch filter.Kind { case "", domain.ChannelParticipantsRecent, domain.ChannelParticipantsContacts, domain.ChannelParticipantsMentions: where = append(where, "m.status = 'active'") case domain.ChannelParticipantsAdmins: // Layer 225 起 admins filter 同时是消息徽章数据源:客户端把整个返回 // (含 rank)灌进 badge 缓存,因此带成员 Tag 的普通成员也必须返回。 where = append(where, "m.status = 'active'", "(m.role IN ('creator','admin') OR m.rank <> '')") count = 0 case domain.ChannelParticipantsKicked: count = channel.KickedCount if !isChannelAdmin(viewer) { return domain.ChannelParticipantList{Channel: channel, Count: channel.KickedCount}, nil } where = append(where, "(m.status = 'kicked' OR (m.banned_rights->>'ViewMessages')::boolean IS TRUE)") case domain.ChannelParticipantsBanned: count = channel.BannedCount if !isChannelAdmin(viewer) { return domain.ChannelParticipantList{Channel: channel, Count: channel.BannedCount}, nil } where = append(where, "m.status <> 'kicked'", `( m.status = 'banned' OR (m.banned_rights->>'SendMessages')::boolean IS TRUE OR (m.banned_rights->>'SendMedia')::boolean IS TRUE OR (m.banned_rights->>'SendStickers')::boolean IS TRUE OR (m.banned_rights->>'SendGifs')::boolean IS TRUE OR (m.banned_rights->>'SendGames')::boolean IS TRUE OR (m.banned_rights->>'SendInline')::boolean IS TRUE OR (m.banned_rights->>'EmbedLinks')::boolean IS TRUE OR (m.banned_rights->>'SendPolls')::boolean IS TRUE OR (m.banned_rights->>'ChangeInfo')::boolean IS TRUE OR (m.banned_rights->>'InviteUsers')::boolean IS TRUE OR (m.banned_rights->>'PinMessages')::boolean IS TRUE OR (m.banned_rights->>'ManageTopics')::boolean IS TRUE OR (m.banned_rights->>'SendPhotos')::boolean IS TRUE OR (m.banned_rights->>'SendVideos')::boolean IS TRUE OR (m.banned_rights->>'SendRoundvideos')::boolean IS TRUE OR (m.banned_rights->>'SendAudios')::boolean IS TRUE OR (m.banned_rights->>'SendVoices')::boolean IS TRUE OR (m.banned_rights->>'SendDocs')::boolean IS TRUE OR (m.banned_rights->>'SendPlain')::boolean IS TRUE OR (m.banned_rights->>'EditRank')::boolean IS TRUE OR (m.banned_rights->>'SendReactions')::boolean IS TRUE )`) case domain.ChannelParticipantsSearch: where = append(where, "m.status = 'active'") count = 0 case domain.ChannelParticipantsBots: return domain.ChannelParticipantList{Channel: channel}, nil default: where = append(where, "m.status = 'active'") } if !isChannelAdmin(viewer) { where = append(where, "NOT (m.role IN ('creator','admin') AND COALESCE((m.admin_rights->>'Anonymous')::boolean, false))") if count == channel.ParticipantsCount { hiddenCount, err := countHiddenAnonymousChannelAdmins(ctx, s.db, channelID) if err != nil { return domain.ChannelParticipantList{}, err } count -= hiddenCount if count < 0 { count = 0 } } } if query != "" { joinUsers = true count = 0 args = append(args, "%"+strings.ToLower(query)+"%") placeholder := fmt.Sprintf("$%d", len(args)) where = append(where, fmt.Sprintf(`( lower(COALESCE(u.first_name, '')) LIKE %s OR lower(COALESCE(u.last_name, '')) LIKE %s OR lower(COALESCE(u.username, '')) LIKE %s OR COALESCE(u.phone, '') LIKE %s OR m.user_id::text LIKE %s )`, placeholder, placeholder, placeholder, placeholder, placeholder)) } args = append(args, offset, limit) offsetArg := fmt.Sprintf("$%d", len(args)-1) limitArg := fmt.Sprintf("$%d", len(args)) from := "FROM channel_members m" if joinUsers { from += " JOIN users u ON u.id = m.user_id" } rows, err := s.db.Query(ctx, ` SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date `+from+` WHERE `+strings.Join(where, " AND ")+` ORDER BY CASE role WHEN 'creator' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, user_id OFFSET `+offsetArg+` LIMIT `+limitArg, args...) if err != nil { return domain.ChannelParticipantList{}, fmt.Errorf("list channel participants: %w", err) } defer rows.Close() out := domain.ChannelParticipantList{Channel: channel, Count: count} for rows.Next() { member, err := scanChannelMember(rows) if err != nil { return domain.ChannelParticipantList{}, err } out.Participants = append(out.Participants, member) } if err := rows.Err(); err != nil { return domain.ChannelParticipantList{}, err } if out.Count == 0 { out.Count = len(out.Participants) } return out, nil } func countHiddenAnonymousChannelAdmins(ctx context.Context, db interface { QueryRow(context.Context, string, ...any) pgx.Row }, channelID int64) (int, error) { var count int if err := db.QueryRow(ctx, ` SELECT COUNT(*)::int FROM channel_members WHERE channel_id = $1 AND status = 'active' AND role IN ('creator','admin') AND COALESCE((admin_rights->>'Anonymous')::boolean, false)`, channelID).Scan(&count); err != nil { return 0, fmt.Errorf("count hidden anonymous channel admins: %w", err) } return count, nil } func (s *ChannelStore) GetParticipant(ctx context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) { _, viewer, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID) if err != nil { return domain.ChannelMember{}, err } if viewerUserID == participantUserID && viewer.Guest { return domain.ChannelMember{}, domain.ErrUserNotParticipant } member, err := s.getChannelMember(ctx, s.db, channelID, participantUserID) if errors.Is(err, domain.ErrChannelPrivate) { return domain.ChannelMember{}, domain.ErrUserNotParticipant } if err == nil && participantUserID == viewerUserID && member.Status == domain.ChannelMemberLeft { return domain.ChannelMember{}, domain.ErrUserNotParticipant } return member, err } func (s *ChannelStore) ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) { if _, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID); err != nil { return nil, err } if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { limit = domain.MaxChannelRealtimeFanout } rows, err := s.db.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id = $1 AND status = 'active' ORDER BY user_id LIMIT $2`, channelID, limit) if err != nil { return nil, fmt.Errorf("list active channel members: %w", err) } defer rows.Close() out := make([]int64, 0, limit) for rows.Next() { var userID int64 if err := rows.Scan(&userID); err != nil { return nil, err } out = append(out, userID) } return out, rows.Err() } func (s *ChannelStore) ListActiveChannelMembers(ctx context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) { channel, viewer, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID) if err != nil { return domain.Channel{}, domain.ChannelMember{}, nil, err } if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { limit = domain.MaxSynchronousChannelDialogFanout } rows, err := s.db.Query(ctx, ` SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date FROM channel_members WHERE channel_id = $1 AND status = 'active' ORDER BY user_id LIMIT $2`, channelID, limit) if err != nil { return domain.Channel{}, domain.ChannelMember{}, nil, fmt.Errorf("list active channel members: %w", err) } defer rows.Close() out := make([]domain.ChannelMember, 0, minInt(limit, channel.ParticipantsCount)) for rows.Next() { member, err := scanChannelMember(rows) if err != nil { return domain.Channel{}, domain.ChannelMember{}, nil, err } out = append(out, member) } if err := rows.Err(); err != nil { return domain.Channel{}, domain.ChannelMember{}, nil, err } return channel, viewer, out, nil } func (s *ChannelStore) ListActiveChannelBotMembers(ctx context.Context, viewerUserID, channelID int64, offset, limit int) (domain.ChannelParticipantList, error) { channel, viewer, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID) if err != nil { return domain.ChannelParticipantList{}, err } if channel.ParticipantsHidden && !isChannelAdmin(viewer) { return domain.ChannelParticipantList{Channel: channel, Count: 0}, nil } if offset < 0 { offset = 0 } if offset > domain.MaxChannelParticipantsOffset { offset = domain.MaxChannelParticipantsOffset } if limit <= 0 || limit > domain.MaxChannelParticipantsLimit { limit = domain.MaxChannelParticipantsLimit } rows, err := s.db.Query(ctx, ` SELECT m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at, m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts, m.history_clear_anchor_id, m.history_clear_anchor_date, m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date, COUNT(*) OVER()::int FROM bots b JOIN channel_members m ON m.user_id = b.bot_user_id WHERE m.channel_id = $1 AND m.status = 'active' ORDER BY m.user_id OFFSET $2 LIMIT $3`, channelID, offset, limit) if err != nil { return domain.ChannelParticipantList{}, fmt.Errorf("list active channel bot members: %w", err) } defer rows.Close() out := domain.ChannelParticipantList{Channel: channel} for rows.Next() { member, count, err := scanChannelMemberWithCount(rows) if err != nil { return domain.ChannelParticipantList{}, err } out.Participants = append(out.Participants, member) out.Count = count } if err := rows.Err(); err != nil { return domain.ChannelParticipantList{}, err } return out, nil } func (s *ChannelStore) ListActiveChannelBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) { if _, _, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, channelID); err != nil { return nil, err } if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { limit = domain.MaxSynchronousChannelDialogFanout } rows, err := s.db.Query(ctx, ` SELECT m.user_id FROM bots b JOIN channel_members m ON m.user_id = b.bot_user_id WHERE m.channel_id = $1 AND m.status = 'active' ORDER BY m.user_id LIMIT $2`, channelID, limit) if err != nil { return nil, fmt.Errorf("list active channel bot member ids: %w", err) } defer rows.Close() out := make([]int64, 0, minInt(limit, domain.MaxChannelParticipantsLimit)) for rows.Next() { var userID int64 if err := rows.Scan(&userID); err != nil { return nil, err } out = append(out, userID) } return out, rows.Err() } func (s *ChannelStore) FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) { if channelID == 0 || len(userIDs) == 0 { return nil, nil } candidates := uniqueChannelUserIDs(userIDs, 0) if len(candidates) == 0 { return nil, nil } out := make([]int64, 0, len(candidates)) for start := 0; start < len(candidates); start += channelMemberFilterBatch { end := start + channelMemberFilterBatch if end > len(candidates) { end = len(candidates) } rows, err := s.db.Query(ctx, ` SELECT user_id FROM channel_members WHERE channel_id = $1 AND user_id = ANY($2::bigint[]) AND status = 'active' ORDER BY user_id`, channelID, candidates[start:end]) if err != nil { return nil, fmt.Errorf("filter active channel members: %w", err) } for rows.Next() { var userID int64 if err := rows.Scan(&userID); err != nil { rows.Close() return nil, err } out = append(out, userID) } if err := rows.Err(); err != nil { rows.Close() return nil, err } rows.Close() } sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) return out, nil } func (s *ChannelStore) FilterActiveChannelMemberPairs(ctx context.Context, userIDsByChannel map[int64][]int64) (map[int64][]int64, error) { channelIDs, userIDs, err := flattenActiveChannelMemberPairs(userIDsByChannel) if err != nil { return nil, err } out := make(map[int64][]int64) if len(channelIDs) == 0 { return out, nil } rows, err := s.db.Query(ctx, ` WITH requested(channel_id, user_id) AS ( SELECT * FROM unnest($1::bigint[], $2::bigint[]) ) SELECT r.channel_id, r.user_id FROM requested r JOIN channel_members m ON m.channel_id = r.channel_id AND m.user_id = r.user_id WHERE m.status = 'active' ORDER BY r.channel_id, r.user_id`, channelIDs, userIDs) if err != nil { return nil, fmt.Errorf("filter active channel member pairs: %w", err) } defer rows.Close() for rows.Next() { var channelID, userID int64 if err := rows.Scan(&channelID, &userID); err != nil { return nil, err } out[channelID] = append(out[channelID], userID) } if err := rows.Err(); err != nil { return nil, err } return out, nil } func flattenActiveChannelMemberPairs(userIDsByChannel map[int64][]int64) ([]int64, []int64, error) { channelIDs := make([]int64, 0) userIDs := make([]int64, 0) seen := make(map[[2]int64]struct{}) for channelID, candidates := range userIDsByChannel { if channelID == 0 { continue } for _, userID := range candidates { if userID == 0 { continue } pair := [2]int64{channelID, userID} if _, ok := seen[pair]; ok { continue } if len(seen) >= store.MaxActiveChannelMemberPairs { return nil, nil, fmt.Errorf("%w: maximum %d", store.ErrActiveChannelMemberPairsLimit, store.MaxActiveChannelMemberPairs) } seen[pair] = struct{}{} channelIDs = append(channelIDs, channelID) userIDs = append(userIDs, userID) } } return channelIDs, userIDs, nil } func (s *ChannelStore) FilterChannelMessageAudienceIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) { if channelID == 0 || len(userIDs) == 0 { return nil, nil } candidates := uniqueChannelUserIDs(userIDs, 0) if len(candidates) == 0 { return nil, nil } out := make([]int64, 0, len(candidates)) for start := 0; start < len(candidates); start += channelMemberFilterBatch { end := start + channelMemberFilterBatch if end > len(candidates) { end = len(candidates) } rows, err := s.db.Query(ctx, ` SELECT candidate.user_id FROM channels c CROSS JOIN unnest($2::bigint[]) AS candidate(user_id) LEFT JOIN channel_members m ON m.channel_id = c.id AND m.user_id = candidate.user_id WHERE c.id = $1 AND NOT c.deleted AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) AND COALESCE(m.status, '') NOT IN ('kicked', 'banned') AND ( m.status = 'active' OR (COALESCE(c.username, '') <> '' AND COALESCE(m.status, 'left') = 'left') ) ORDER BY candidate.user_id`, channelID, candidates[start:end]) if err != nil { return nil, fmt.Errorf("filter channel message audience: %w", err) } for rows.Next() { var userID int64 if err := rows.Scan(&userID); err != nil { rows.Close() return nil, err } out = append(out, userID) } if err := rows.Err(); err != nil { rows.Close() return nil, err } rows.Close() } return out, nil }