package postgres import ( "context" "crypto/rand" "database/sql" "encoding/base64" "encoding/binary" "encoding/json" "errors" "fmt" "sort" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "telesrv/internal/domain" "telesrv/internal/store" "telesrv/internal/store/postgres/sqlcgen" ) const channelDialogQueryLimit = 500 const channelMemberFilterBatch = 1000 const retryableChannelTxAttempts = 3 // ChannelStore 用 PostgreSQL 实现 store.ChannelStore。 type ChannelStore struct { db sqlcgen.DBTX ids store.ChannelIDAllocator pts store.ChannelPtsAllocator msgIDs store.ChannelMessageIDAllocator } // ChannelStoreOption 调整 PostgreSQL ChannelStore 依赖。 type ChannelStoreOption func(*ChannelStore) // WithChannelAllocators 注入 Redis-backed channel id / pts / message id allocator。 func WithChannelAllocators(ids store.ChannelIDAllocator, pts store.ChannelPtsAllocator, msgIDs store.ChannelMessageIDAllocator) ChannelStoreOption { return func(s *ChannelStore) { s.ids = ids s.pts = pts s.msgIDs = msgIDs } } // NewChannelStore 基于 pgx 连接池(或事务)创建 ChannelStore。 func NewChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore { s := &ChannelStore{db: db} for _, opt := range opts { opt(s) } if s.ids == nil { s.ids = pgChannelIDAllocator{db: db} } if s.pts == nil { s.pts = pgChannelPtsAllocator{db: db} } if s.msgIDs == nil { s.msgIDs = pgChannelMessageIDAllocator{db: db} } return s } func (s *ChannelStore) CreateChannel(ctx context.Context, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) { if req.CreatorUserID == 0 || strings.TrimSpace(req.Title) == "" { return domain.CreateChannelResult{}, domain.ErrChannelInvalid } if !req.Broadcast && !req.Megagroup { req.Broadcast = true } beginner, ok := s.db.(txBeginner) if !ok { return domain.CreateChannelResult{}, fmt.Errorf("create channel: db does not support transactions") } channelID, err := s.ids.NextChannelID(ctx) if err != nil { return domain.CreateChannelResult{}, fmt.Errorf("allocate channel id: %w", err) } accessHash, err := randomChannelAccessHash() if err != nil { return domain.CreateChannelResult{}, err } msgID, err := s.msgIDs.NextChannelMessageID(ctx, channelID) if err != nil { return domain.CreateChannelResult{}, fmt.Errorf("allocate channel message id: %w", err) } pts, err := s.pts.NextChannelPts(ctx, channelID) if err != nil { return domain.CreateChannelResult{}, fmt.Errorf("allocate channel pts: %w", err) } date := req.Date if date == 0 { date = nowUnix() } members := []domain.ChannelMember{creatorChannelMember(channelID, req.CreatorUserID, date)} for _, userID := range uniqueChannelUserIDs(req.MemberUserIDs, req.CreatorUserID) { members = append(members, domain.ChannelMember{ ChannelID: channelID, UserID: userID, InviterUserID: req.CreatorUserID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive, JoinedAt: date, }) } channel := domain.Channel{ ID: channelID, AccessHash: accessHash, CreatorUserID: req.CreatorUserID, Title: strings.TrimSpace(req.Title), About: req.About, Broadcast: req.Broadcast, Megagroup: req.Megagroup, Forum: req.Forum, ForumTabs: req.ForumTabs, ParticipantsCount: len(members), AdminsCount: 1, TopMessageID: msgID, Pts: pts, TTLPeriod: req.TTLPeriod, Date: date, } msg := domain.ChannelMessage{ ChannelID: channelID, ID: msgID, SenderUserID: req.CreatorUserID, From: domain.Peer{Type: domain.PeerTypeUser, ID: req.CreatorUserID}, Date: date, Post: channel.Broadcast, Action: &domain.ChannelMessageAction{Type: domain.ChannelActionCreate, Title: channel.Title}, Pts: pts, } event := domain.ChannelUpdateEvent{ ChannelID: channelID, Type: domain.ChannelUpdateNewMessage, Pts: pts, PtsCount: 1, Date: date, Message: msg, SenderUserID: req.CreatorUserID, } tx, err := beginner.Begin(ctx) if err != nil { return domain.CreateChannelResult{}, fmt.Errorf("begin create channel: %w", err) } committed := false var reserved []reservedChannelPts defer func() { if !committed { _ = tx.Rollback(ctx) s.recordChannelPtsGaps(ctx, reserved, req.Date) } }() if err := insertChannelTx(ctx, tx, channel); err != nil { return domain.CreateChannelResult{}, err } for _, member := range members { if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { return domain.CreateChannelResult{}, err } } if err := insertChannelMessageTx(ctx, tx, msg); err != nil { return domain.CreateChannelResult{}, err } if err := insertChannelEventTx(ctx, tx, event); err != nil { return domain.CreateChannelResult{}, err } for _, member := range members { readMax := 0 if member.UserID == req.CreatorUserID { readMax = msgID } if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, readMax, readMax); err != nil { return domain.CreateChannelResult{}, err } } if err := tx.Commit(ctx); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("commit create channel: %w", err) } committed = true return domain.CreateChannelResult{ Channel: channel, Members: append([]domain.ChannelMember(nil), members...), Message: msg, Event: event, Recipients: channelMemberIDs(members), }, nil } func (s *ChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) { channel, member, preview, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID) if err != nil { return domain.ChannelView{}, err } if preview { return domain.ChannelView{ Channel: channel, Self: member, Dialog: previewChannelDialog(viewerUserID, channel, member), }, nil } dialog, err := s.getChannelDialog(ctx, s.db, viewerUserID, channel) if err != nil { return domain.ChannelView{}, err } return domain.ChannelView{Channel: channel, Self: member, Dialog: dialog}, nil } func (s *ChannelStore) SaveChannelDefaultSendAs(ctx context.Context, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.ChannelView{}, domain.ErrChannelInvalid } var sendAsType sql.NullString var sendAsID sql.NullInt64 if req.SendAs != nil { if req.SendAs.Type != domain.PeerTypeUser && req.SendAs.Type != domain.PeerTypeChannel { return domain.ChannelView{}, domain.ErrChannelInvalid } sendAsType = sql.NullString{String: string(req.SendAs.Type), Valid: true} sendAsID = sql.NullInt64{Int64: req.SendAs.ID, Valid: true} } channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ChannelView{}, err } topMessageID := channel.TopMessageID if topMessageID <= member.AvailableMinID { topMessageID = 0 } if _, err := s.db.Exec(ctx, ` INSERT INTO channel_dialogs ( user_id, channel_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, default_send_as_peer_type, default_send_as_peer_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (user_id, channel_id) DO UPDATE SET default_send_as_peer_type = EXCLUDED.default_send_as_peer_type, default_send_as_peer_id = EXCLUDED.default_send_as_peer_id, updated_at = now()`, req.UserID, req.ChannelID, topMessageID, channel.Date, member.ReadInboxMaxID, member.ReadOutboxMaxID, sendAsType, sendAsID, ); err != nil { return domain.ChannelView{}, fmt.Errorf("save channel default send as: %w", err) } dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) if err != nil { return domain.ChannelView{}, err } return domain.ChannelView{Channel: channel, Self: member, Dialog: dialog}, nil } func (s *ChannelStore) GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error) { if channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } return getChannelByID(ctx, s.db, channelID) } func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { channel, viewer, err := s.getChannelForMember(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 if channel.ParticipantsHidden && !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: where = append(where, "m.status = 'active'", "m.role IN ('creator','admin')") count = channel.AdminsCount 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 )`) 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 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, 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 (s *ChannelStore) GetParticipant(ctx context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) { if _, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID); err != nil { return domain.ChannelMember{}, err } return s.getChannelMember(ctx, s.db, channelID, participantUserID) } func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUserID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) { if channelID == 0 || inviterUserID == 0 || len(userIDs) == 0 { return domain.CreateChannelResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.CreateChannelResult{}, fmt.Errorf("invite channel: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.CreateChannelResult{}, fmt.Errorf("begin invite channel: %w", err) } committed := false var reserved []reservedChannelPts defer func() { if !committed { _ = tx.Rollback(ctx) s.recordChannelPtsGaps(ctx, reserved, date) } }() channel, inviter, err := s.getChannelForMember(ctx, tx, inviterUserID, channelID) if err != nil { return domain.CreateChannelResult{}, err } if !canInviteToChannel(channel, inviter) { return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired } if date == 0 { date = nowUnix() } requested := uniqueChannelUserIDs(userIDs, 0) inviteOne := len(requested) == 1 canRestoreKicked := canBanChannelUsers(inviter) invitedIDs := make([]int64, 0, len(requested)) members := make([]domain.ChannelMember, 0, len(requested)) restoredKicked := 0 for _, userID := range requested { if existing, err := s.getChannelMember(ctx, tx, channelID, userID); err == nil { if existing.Status == domain.ChannelMemberActive { if inviteOne { return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant } continue } if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages { if !canRestoreKicked { if inviteOne { return domain.CreateChannelResult{}, domain.ErrUserKicked } continue } if existing.Status == domain.ChannelMemberKicked { restoredKicked++ } } } else if !errors.Is(err, domain.ErrChannelPrivate) { return domain.CreateChannelResult{}, err } member := domain.ChannelMember{ ChannelID: channelID, UserID: userID, InviterUserID: inviterUserID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive, JoinedAt: date, AvailableMinID: channelInitialAvailableMinID(channel), AvailableMinPts: channelInitialAvailableMinPts(channel), ReadInboxMaxID: channel.TopMessageID, } if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { return domain.CreateChannelResult{}, err } if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: channelID, UserID: inviterUserID, Date: date, Type: domain.ChannelAdminLogParticipantInvite, Participant: &member, }); err != nil { return domain.CreateChannelResult{}, err } members = append(members, member) invitedIDs = append(invitedIDs, userID) } if len(members) > 0 { if _, err := tx.Exec(ctx, `UPDATE channels SET participants_count = participants_count + $2, kicked_count = GREATEST(kicked_count - $3, 0), updated_at = now() WHERE id = $1`, channelID, len(members), restoredKicked); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("update channel participants: %w", err) } channel.ParticipantsCount += len(members) channel.KickedCount = maxInt(channel.KickedCount-restoredKicked, 0) } var msg domain.ChannelMessage var event domain.ChannelUpdateEvent if len(members) > 0 && channel.Megagroup { msg, event, err = s.insertServiceMessage(ctx, tx, channel, inviterUserID, date, domain.ChannelMessageAction{ Type: domain.ChannelActionChatAddUser, UserIDs: invitedIDs, }, &reserved) if err != nil { return domain.CreateChannelResult{}, err } channel.TopMessageID = msg.ID channel.Pts = event.Pts } for _, member := range members { if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { return domain.CreateChannelResult{}, err } } if err := tx.Commit(ctx); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("commit invite channel: %w", err) } committed = true recipients, _ := s.ListActiveChannelMemberIDs(ctx, inviterUserID, channelID, 0) return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil } func (s *ChannelStore) JoinChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) { if channelID == 0 || userID == 0 { return domain.CreateChannelResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.CreateChannelResult{}, fmt.Errorf("join channel: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.CreateChannelResult{}, fmt.Errorf("begin join channel: %w", err) } committed := false var reserved []reservedChannelPts defer func() { if !committed { _ = tx.Rollback(ctx) s.recordChannelPtsGaps(ctx, reserved, date) } }() channel, err := getChannelByID(ctx, tx, channelID) if err != nil { return domain.CreateChannelResult{}, err } if existing, err := s.getChannelMember(ctx, tx, channelID, userID); err == nil { switch { case existing.Status == domain.ChannelMemberActive: return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant case existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages: return domain.CreateChannelResult{}, domain.ErrChannelUserBanned } } if date == 0 { date = nowUnix() } if channel.JoinRequest { if err := s.recordPublicJoinRequestTx(ctx, tx, channel, userID, date); err != nil { return domain.CreateChannelResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("commit public channel join request: %w", err) } committed = true return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent } preJoinTopID := channel.TopMessageID minID := channelInitialAvailableMinID(channel) member := domain.ChannelMember{ChannelID: channelID, UserID: userID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive, JoinedAt: date, AvailableMinID: minID, AvailableMinPts: channelInitialAvailableMinPts(channel), ReadInboxMaxID: maxInt(minID, preJoinTopID)} if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { return domain.CreateChannelResult{}, err } if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: channelID, UserID: userID, Date: date, Type: domain.ChannelAdminLogParticipantJoin, }); err != nil { return domain.CreateChannelResult{}, err } if _, err := tx.Exec(ctx, `UPDATE channels SET participants_count = participants_count + 1, updated_at = now() WHERE id = $1`, channelID); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("update channel participants: %w", err) } channel.ParticipantsCount++ var msg domain.ChannelMessage var event domain.ChannelUpdateEvent if channel.Megagroup { msg, event, err = s.insertServiceMessage(ctx, tx, channel, userID, date, domain.ChannelMessageAction{ Type: domain.ChannelActionChatJoined, UserIDs: []int64{userID}, }, &reserved) if err != nil { return domain.CreateChannelResult{}, err } channel.TopMessageID = msg.ID channel.Pts = event.Pts } member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID) if msg.ID != 0 && msg.SenderUserID == userID { member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID) } if _, err := tx.Exec(ctx, ` UPDATE channel_members SET read_inbox_max_id = GREATEST(read_inbox_max_id, $3), read_outbox_max_id = GREATEST(read_outbox_max_id, $4), unread_mark = false, updated_at = now() WHERE channel_id = $1 AND user_id = $2`, channelID, userID, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("update joined channel read watermarks: %w", err) } if err := upsertChannelDialogTx(ctx, tx, userID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { return domain.CreateChannelResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("commit join channel: %w", err) } committed = true recipients, _ := s.ListActiveChannelMemberIDs(ctx, userID, channelID, 0) return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event, Recipients: recipients}, nil } func (s *ChannelStore) LeaveChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) { if channelID == 0 || userID == 0 { return domain.CreateChannelResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.CreateChannelResult{}, fmt.Errorf("leave channel: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.CreateChannelResult{}, fmt.Errorf("begin leave channel: %w", err) } committed := false var reserved []reservedChannelPts defer func() { if !committed { _ = tx.Rollback(ctx) s.recordChannelPtsGaps(ctx, reserved, date) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.CreateChannelResult{}, err } if date == 0 { date = nowUnix() } if _, err := tx.Exec(ctx, `UPDATE channel_members SET status = 'left', left_at = $3, updated_at = now() WHERE channel_id = $1 AND user_id = $2`, channelID, userID, date); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("leave channel member: %w", err) } if _, err := tx.Exec(ctx, `UPDATE channels SET participants_count = GREATEST(participants_count - 1, 0), updated_at = now() WHERE id = $1`, channelID); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("update channel participants: %w", err) } member.Status = domain.ChannelMemberLeft member.LeftAt = date if err := upsertUserChannelMemberIndexTx(ctx, tx, channel, member); err != nil { return domain.CreateChannelResult{}, err } if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: channelID, UserID: userID, Date: date, Type: domain.ChannelAdminLogParticipantLeave, }); err != nil { return domain.CreateChannelResult{}, err } channel.ParticipantsCount-- if channel.ParticipantsCount < 0 { channel.ParticipantsCount = 0 } var msg domain.ChannelMessage var event domain.ChannelUpdateEvent if channel.Megagroup { msg, event, err = s.insertServiceMessage(ctx, tx, channel, userID, date, domain.ChannelMessageAction{ Type: domain.ChannelActionChatDelete, UserIDs: []int64{userID}, }, &reserved) if err != nil { return domain.CreateChannelResult{}, err } channel.TopMessageID = msg.ID channel.Pts = event.Pts } if err := tx.Commit(ctx); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("commit leave channel: %w", err) } committed = true recipients, _ := s.ListActiveChannelMemberIDs(ctx, userID, channelID, 0) recipients = append(recipients, userID) return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event, Recipients: recipients}, nil } func (s *ChannelStore) EditChannelTitle(ctx context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) { if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Title) == "" { return domain.EditChannelTitleResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.EditChannelTitleResult{}, fmt.Errorf("edit channel title: db does not support transactions") } if req.Date == 0 { req.Date = nowUnix() } title := strings.TrimSpace(req.Title) tx, err := beginner.Begin(ctx) if err != nil { return domain.EditChannelTitleResult{}, fmt.Errorf("begin edit channel title: %w", err) } committed := false var reserved []reservedChannelPts defer func() { if !committed { _ = tx.Rollback(ctx) s.recordChannelPtsGaps(ctx, reserved, req.Date) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.EditChannelTitleResult{}, err } if !canChangeChannelInfo(member) { return domain.EditChannelTitleResult{}, domain.ErrChannelAdminRequired } if channel.Title == title { return domain.EditChannelTitleResult{}, domain.ErrChannelNotModified } prevTitle := channel.Title if _, err := tx.Exec(ctx, `UPDATE channels SET title = $2, updated_at = now() WHERE id = $1`, req.ChannelID, title); err != nil { return domain.EditChannelTitleResult{}, fmt.Errorf("update channel title: %w", err) } channel.Title = title msg, event, err := s.insertServiceMessage(ctx, tx, channel, req.UserID, req.Date, domain.ChannelMessageAction{ Type: domain.ChannelActionEditTitle, Title: title, }, &reserved) if err != nil { return domain.EditChannelTitleResult{}, err } channel.TopMessageID = msg.ID channel.Pts = event.Pts if err := upsertChannelDialogTx(ctx, tx, req.UserID, channel, msg, msg.ID, msg.ID); err != nil { return domain.EditChannelTitleResult{}, err } if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: req.ChannelID, UserID: req.UserID, Date: req.Date, Type: domain.ChannelAdminLogChangeTitle, PrevString: prevTitle, NewString: title, }); err != nil { return domain.EditChannelTitleResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.EditChannelTitleResult{}, fmt.Errorf("commit edit channel title: %w", err) } committed = true recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) return domain.EditChannelTitleResult{Channel: channel, Message: msg, Event: event, Recipients: recipients}, nil } func (s *ChannelStore) EditChannelAbout(ctx context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("edit channel about: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin edit channel about: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.Channel{}, err } if !canChangeChannelInfo(member) { return domain.Channel{}, domain.ErrChannelAdminRequired } if _, err := tx.Exec(ctx, `UPDATE channels SET about = $2, updated_at = now() WHERE id = $1`, req.ChannelID, req.About); err != nil { return domain.Channel{}, fmt.Errorf("update channel about: %w", err) } channel.About = req.About if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit edit channel about: %w", err) } committed = true return channel, nil } func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 { return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.EditChannelAdminResult{}, fmt.Errorf("edit channel admin: db does not support transactions") } if req.Date == 0 { req.Date = nowUnix() } tx, err := beginner.Begin(ctx) if err != nil { return domain.EditChannelAdminResult{}, fmt.Errorf("begin edit channel admin: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, actor, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.EditChannelAdminResult{}, err } if !canAddChannelAdmins(actor) { return domain.EditChannelAdminResult{}, domain.ErrChannelAdminRequired } if actor.Role != domain.ChannelRoleCreator && !adminRightsSubset(req.AdminRights, actor.AdminRights) { return domain.EditChannelAdminResult{}, domain.ErrChannelRightForbidden } previous, err := s.getChannelMember(ctx, tx, req.ChannelID, req.MemberID) if err != nil { if !errors.Is(err, domain.ErrChannelPrivate) { return domain.EditChannelAdminResult{}, err } previous = domain.ChannelMember{ ChannelID: req.ChannelID, UserID: req.MemberID, InviterUserID: req.UserID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive, JoinedAt: req.Date, AvailableMinID: channelInitialAvailableMinID(channel), AvailableMinPts: channelInitialAvailableMinPts(channel), ReadInboxMaxID: channel.TopMessageID, } } if previous.Role == domain.ChannelRoleCreator { return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator } member := previous member.InviterUserID = req.UserID member.Status = domain.ChannelMemberActive member.LeftAt = 0 member.Rank = req.Rank if previous.Status != domain.ChannelMemberActive { if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts { member.AvailableMinPts = minPts } } member.AdminRights = req.AdminRights if zeroChannelAdminRights(req.AdminRights) { member.Role = domain.ChannelRoleMember member.Rank = "" } else { member.Role = domain.ChannelRoleAdmin } if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { return domain.EditChannelAdminResult{}, err } logType := domain.ChannelAdminLogParticipantPromote if member.Role != domain.ChannelRoleAdmin { logType = domain.ChannelAdminLogParticipantDemote } if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: req.ChannelID, UserID: req.UserID, Date: req.Date, Type: logType, PrevParticipant: &previous, NewParticipant: &member, }); err != nil { return domain.EditChannelAdminResult{}, err } channel, err = refreshChannelCountsTx(ctx, tx, channel) if err != nil { return domain.EditChannelAdminResult{}, err } event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date) msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID) if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { return domain.EditChannelAdminResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.EditChannelAdminResult{}, fmt.Errorf("commit edit channel admin: %w", err) } committed = true recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) recipients = append(recipients, req.MemberID) return domain.EditChannelAdminResult{Channel: channel, Previous: previous, Participant: member, Event: event, Recipients: recipients, Date: req.Date}, nil } func (s *ChannelStore) EditChannelBanned(ctx context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.Participant.Type != domain.PeerTypeUser || req.Participant.ID == 0 { return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.EditChannelBannedResult{}, fmt.Errorf("edit channel banned: db does not support transactions") } if req.Date == 0 { req.Date = nowUnix() } tx, err := beginner.Begin(ctx) if err != nil { return domain.EditChannelBannedResult{}, fmt.Errorf("begin edit channel banned: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, actor, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.EditChannelBannedResult{}, err } if !canBanChannelUsers(actor) { return domain.EditChannelBannedResult{}, domain.ErrChannelAdminRequired } previous, err := s.getChannelMember(ctx, tx, req.ChannelID, req.Participant.ID) if err != nil { if !errors.Is(err, domain.ErrChannelPrivate) { return domain.EditChannelBannedResult{}, err } previous = domain.ChannelMember{ ChannelID: req.ChannelID, UserID: req.Participant.ID, InviterUserID: req.UserID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberLeft, } } if previous.Role == domain.ChannelRoleCreator { return domain.EditChannelBannedResult{}, domain.ErrChannelUserCreator } member := previous member.BannedRights = req.BannedRights member.Role = domain.ChannelRoleMember switch { case req.BannedRights.ViewMessages: member.InviterUserID = req.UserID member.Status = domain.ChannelMemberKicked member.LeftAt = req.Date case zeroChannelBannedRights(req.BannedRights): if previous.Status == domain.ChannelMemberActive { member.Status = domain.ChannelMemberActive } else { member.Status = domain.ChannelMemberLeft } member.LeftAt = 0 default: member.InviterUserID = req.UserID if previous.Status == domain.ChannelMemberActive { member.Status = domain.ChannelMemberActive } else { member.Status = domain.ChannelMemberBanned } } if member.JoinedAt == 0 && member.Status == domain.ChannelMemberActive { member.JoinedAt = req.Date } if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { return domain.EditChannelBannedResult{}, err } if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: req.ChannelID, UserID: req.UserID, Date: req.Date, Type: adminLogBanType(previous, member), PrevParticipant: &previous, NewParticipant: &member, }); err != nil { return domain.EditChannelBannedResult{}, err } channel, err = refreshChannelCountsTx(ctx, tx, channel) if err != nil { return domain.EditChannelBannedResult{}, err } event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date) if member.Status == domain.ChannelMemberActive { msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID) if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { return domain.EditChannelBannedResult{}, err } } if err := tx.Commit(ctx); err != nil { return domain.EditChannelBannedResult{}, fmt.Errorf("commit edit channel banned: %w", err) } committed = true recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) recipients = append(recipients, req.Participant.ID) return domain.EditChannelBannedResult{Channel: channel, Previous: previous, Participant: member, Event: event, Recipients: recipients, Date: req.Date}, nil } func (s *ChannelStore) EditChannelDefaultBannedRights(ctx context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } channel, actor, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.Channel{}, err } if !canBanChannelUsers(actor) { return domain.Channel{}, domain.ErrChannelAdminRequired } if channel.DefaultBannedRights == req.BannedRights { return domain.Channel{}, domain.ErrChannelNotModified } rights, err := marshalJSON(req.BannedRights, "{}") if err != nil { return domain.Channel{}, err } if _, err := s.db.Exec(ctx, ` UPDATE channels SET default_banned_rights = $2::jsonb, updated_at = now() WHERE id = $1 AND NOT deleted`, req.ChannelID, rights); err != nil { return domain.Channel{}, fmt.Errorf("edit channel default banned rights: %w", err) } channel.DefaultBannedRights = req.BannedRights return channel, nil } func (s *ChannelStore) DeleteChannel(ctx context.Context, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.DeleteChannelResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.DeleteChannelResult{}, fmt.Errorf("delete channel: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.DeleteChannelResult{}, fmt.Errorf("begin delete channel: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.DeleteChannelResult{}, err } if member.Role != domain.ChannelRoleCreator { return domain.DeleteChannelResult{}, domain.ErrChannelAdminRequired } recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) if _, err := tx.Exec(ctx, `UPDATE channels SET deleted = true, updated_at = now() WHERE id = $1`, req.ChannelID); err != nil { return domain.DeleteChannelResult{}, fmt.Errorf("mark channel deleted: %w", err) } if err := markUserChannelMemberIndexDeletedTx(ctx, tx, req.ChannelID, true); err != nil { return domain.DeleteChannelResult{}, err } channel.Deleted = true if err := tx.Commit(ctx); err != nil { return domain.DeleteChannelResult{}, fmt.Errorf("commit delete channel: %w", err) } committed = true return domain.DeleteChannelResult{Channel: channel, Recipients: recipients}, nil } func (s *ChannelStore) CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error) { if userID == 0 || channelID == 0 || strings.TrimSpace(username) == "" { return false, domain.ErrChannelInvalid } if _, _, err := s.getChannelForMember(ctx, s.db, userID, channelID); err != nil { return false, err } usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) var existingChannelID int64 err := s.db.QueryRow(ctx, `SELECT channel_id FROM channel_usernames WHERE username_lower = $1`, usernameLower).Scan(&existingChannelID) if err == nil { return existingChannelID == channelID, nil } if !errors.Is(err, pgx.ErrNoRows) { return false, fmt.Errorf("check channel username: %w", err) } var userIDWithUsername int64 err = s.db.QueryRow(ctx, `SELECT id FROM users WHERE lower(username) = $1 AND username <> '' LIMIT 1`, usernameLower).Scan(&userIDWithUsername) if err == nil { return false, nil } if !errors.Is(err, pgx.ErrNoRows) { return false, fmt.Errorf("check channel username user collision: %w", err) } return true, nil } func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("update channel username: db does not support transactions") } username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@")) usernameLower := strings.ToLower(username) tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin update channel username: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.Channel{}, err } if member.Role != domain.ChannelRoleCreator { return domain.Channel{}, domain.ErrChannelAdminRequired } if strings.EqualFold(channel.Username, username) { return domain.Channel{}, domain.ErrChannelNotModified } if usernameLower != "" { var userIDWithUsername int64 err := tx.QueryRow(ctx, `SELECT id FROM users WHERE lower(username) = $1 AND username <> '' LIMIT 1`, usernameLower).Scan(&userIDWithUsername) if err == nil { return domain.Channel{}, domain.ErrUsernameOccupied } if !errors.Is(err, pgx.ErrNoRows) { return domain.Channel{}, fmt.Errorf("check user username collision: %w", err) } var existingChannelID int64 err = tx.QueryRow(ctx, `SELECT channel_id FROM channel_usernames WHERE username_lower = $1 FOR UPDATE`, usernameLower).Scan(&existingChannelID) if err == nil && existingChannelID != req.ChannelID { return domain.Channel{}, domain.ErrUsernameOccupied } if err != nil && !errors.Is(err, pgx.ErrNoRows) { return domain.Channel{}, fmt.Errorf("lock channel username: %w", err) } } if _, err := tx.Exec(ctx, `DELETE FROM channel_usernames WHERE channel_id = $1`, req.ChannelID); err != nil { return domain.Channel{}, fmt.Errorf("delete old channel username: %w", err) } if usernameLower != "" { if _, err := tx.Exec(ctx, ` INSERT INTO channel_usernames (username_lower, channel_id) VALUES ($1,$2) ON CONFLICT (username_lower) DO UPDATE SET channel_id = EXCLUDED.channel_id, updated_at = now() WHERE channel_usernames.channel_id = EXCLUDED.channel_id`, usernameLower, req.ChannelID); err != nil { if isUniqueViolation(err) { return domain.Channel{}, domain.ErrUsernameOccupied } return domain.Channel{}, fmt.Errorf("insert channel username: %w", err) } } if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, req.ChannelID, username); err != nil { return domain.Channel{}, fmt.Errorf("update channel username: %w", err) } prevUsername := channel.Username channel.Username = username if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: req.ChannelID, UserID: req.UserID, Date: nowUnix(), Type: domain.ChannelAdminLogChangeUsername, PrevString: prevUsername, NewString: username, }); err != nil { return domain.Channel{}, err } if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit update channel username: %w", err) } committed = true return channel, nil } func (s *ChannelStore) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { if userID == 0 { return nil, nil } rows, err := s.db.Query(ctx, ` SELECT `+channelColumns+` FROM channel_members m JOIN channels c ON c.id = m.channel_id AND NOT c.deleted WHERE m.user_id = $1 AND m.status = 'active' AND m.role IN ('creator','admin') AND COALESCE(c.username, '') <> '' ORDER BY c.id DESC LIMIT $2`, userID, domain.MaxAdminedPublicChannels) if err != nil { return nil, fmt.Errorf("list admined public channels: %w", err) } defer rows.Close() out := make([]domain.Channel, 0) for rows.Next() { ch, err := scanChannel(rows) if err != nil { return nil, err } out = append(out, ch) } if err := rows.Err(); err != nil { return nil, err } return out, nil } func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) { if viewerUserID == 0 { return domain.Channel{}, false, domain.ErrChannelInvalid } usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) if usernameLower == "" { return domain.Channel{}, false, nil } ch, err := scanChannel(s.db.QueryRow(ctx, ` SELECT `+channelColumns+` FROM channel_usernames u JOIN channels c ON c.id = u.channel_id WHERE u.username_lower = $1 AND NOT c.deleted AND (c.broadcast OR c.megagroup) AND COALESCE(c.username, '') <> '' LIMIT 1`, usernameLower)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.Channel{}, false, nil } return domain.Channel{}, false, fmt.Errorf("resolve public channel username: %w", err) } return ch, true, nil } func (s *ChannelStore) SearchPublicChannels(ctx context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error) { if viewerUserID == 0 { return domain.PublicChannelSearchResult{}, domain.ErrChannelInvalid } if limit <= 0 || limit > domain.MaxPublicChannelSearchLimit { limit = domain.MaxPublicChannelSearchLimit } queryLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(query, "@"))) if queryLower == "" { return domain.PublicChannelSearchResult{}, nil } queryPrefix := escapeLike(queryLower) + "%" queryLike := "%" + escapeLike(queryLower) + "%" rows, err := s.db.Query(ctx, ` SELECT `+channelColumns+`, EXISTS ( SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.user_id = $1 AND m.status = 'active' ) AS viewer_member FROM channels c WHERE NOT c.deleted AND (c.broadcast OR c.megagroup) AND COALESCE(c.username, '') <> '' AND ( lower(c.username) = $2 OR lower(c.username) LIKE $3 ESCAPE '\' OR lower(c.title) LIKE $3 ESCAPE '\' OR lower(c.username) LIKE $4 ESCAPE '\' OR lower(c.title) LIKE $4 ESCAPE '\' ) ORDER BY CASE WHEN lower(c.username) = $2 THEN 0 WHEN lower(c.username) LIKE $3 ESCAPE '\' THEN 1 WHEN lower(c.username) LIKE $4 ESCAPE '\' THEN 2 WHEN lower(c.title) LIKE $3 ESCAPE '\' THEN 3 ELSE 4 END, viewer_member DESC, c.participants_count DESC, c.date DESC, c.id DESC LIMIT $5`, viewerUserID, queryLower, queryPrefix, queryLike, limit) if err != nil { return domain.PublicChannelSearchResult{}, fmt.Errorf("search public channels: %w", err) } defer rows.Close() out := domain.PublicChannelSearchResult{ MyResults: make([]domain.Channel, 0), Results: make([]domain.Channel, 0, limit), } for rows.Next() { ch, viewerMember, err := scanChannelWithViewerMember(rows) if err != nil { return domain.PublicChannelSearchResult{}, err } if viewerMember { out.MyResults = append(out.MyResults, ch) } else { out.Results = append(out.Results, ch) } } if err := rows.Err(); err != nil { return domain.PublicChannelSearchResult{}, err } return out, nil } func (s *ChannelStore) SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { if userID == 0 || channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("toggle channel signatures: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin toggle channel signatures: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if !canChangeChannelInfo(member) { return domain.Channel{}, domain.ErrChannelAdminRequired } prev := channel.Signatures if _, err := tx.Exec(ctx, `UPDATE channels SET signatures = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { return domain.Channel{}, fmt.Errorf("update channel signatures: %w", err) } channel.Signatures = enabled if prev != enabled { if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: channelID, UserID: userID, Date: nowUnix(), Type: domain.ChannelAdminLogToggleSignatures, PrevBool: prev, NewBool: enabled, }); err != nil { return domain.Channel{}, err } } if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit toggle channel signatures: %w", err) } committed = true return channel, nil } // SetChannelPhoto 设置/清除频道头像(反范式列)。photo==nil 表示清除。 func (s *ChannelStore) SetChannelPhoto(ctx context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error) { if userID == 0 || channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("set channel photo: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin set channel photo: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if !canChangeChannelInfo(member) { return domain.Channel{}, domain.ErrChannelAdminRequired } var ( photoID int64 dcID int stripped []byte ) if photo != nil && photo.ID != 0 { photoID = photo.ID dcID = photo.DCID stripped = domain.StrippedFromSizes(photo.Sizes) } if stripped == nil { stripped = []byte{} } if _, err := tx.Exec(ctx, `UPDATE channels SET photo_id = $2, photo_dc_id = $3, photo_stripped = $4, updated_at = now() WHERE id = $1`, channelID, photoID, dcID, stripped); err != nil { return domain.Channel{}, fmt.Errorf("update channel photo: %w", err) } channel.PhotoID = photoID channel.PhotoDCID = dcID channel.PhotoStripped = stripped if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit set channel photo: %w", err) } committed = true return channel, nil } func (s *ChannelStore) SetPreHistoryHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { if userID == 0 || channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("toggle channel prehistory: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin toggle channel prehistory: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if member.Role != domain.ChannelRoleCreator { return domain.Channel{}, domain.ErrChannelAdminRequired } prev := channel.PreHistoryHidden if _, err := tx.Exec(ctx, `UPDATE channels SET pre_history_hidden = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { return domain.Channel{}, fmt.Errorf("update channel prehistory: %w", err) } channel.PreHistoryHidden = enabled if prev != enabled { if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: channelID, UserID: userID, Date: nowUnix(), Type: domain.ChannelAdminLogTogglePreHistoryHidden, PrevBool: prev, NewBool: enabled, }); err != nil { return domain.Channel{}, err } } if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit toggle channel prehistory: %w", err) } committed = true return channel, nil } func (s *ChannelStore) SetParticipantsHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { if userID == 0 || channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("toggle channel participants hidden: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin toggle channel participants hidden: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if !channel.Megagroup || !canBanChannelUsers(member) { return domain.Channel{}, domain.ErrChannelAdminRequired } if _, err := tx.Exec(ctx, `UPDATE channels SET participants_hidden = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { return domain.Channel{}, fmt.Errorf("update channel participants hidden: %w", err) } channel.ParticipantsHidden = enabled if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit toggle channel participants hidden: %w", err) } committed = true return channel, nil } func (s *ChannelStore) SetForum(ctx context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error) { if userID == 0 || channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("toggle channel forum: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin toggle channel forum: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if !channel.Megagroup || channel.Broadcast { return domain.Channel{}, domain.ErrChannelNotModified } if member.Role != domain.ChannelRoleCreator { return domain.Channel{}, domain.ErrChannelAdminRequired } if enabled && channel.LinkedChatID != 0 { return domain.Channel{}, domain.ErrChatDiscussionUnallowed } prevForum := channel.Forum prevTabs := channel.ForumTabs nextTabs := enabled && tabs if _, err := tx.Exec(ctx, ` UPDATE channels SET forum = $2, forum_tabs = $3, updated_at = now() WHERE id = $1`, channelID, enabled, nextTabs); err != nil { return domain.Channel{}, fmt.Errorf("update channel forum: %w", err) } channel.Forum = enabled channel.ForumTabs = nextTabs if prevForum != channel.Forum || prevTabs != channel.ForumTabs { if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: channelID, UserID: userID, Date: nowUnix(), Type: domain.ChannelAdminLogToggleForum, PrevBool: prevForum, NewBool: enabled, }); err != nil { return domain.Channel{}, err } } if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit toggle channel forum: %w", err) } committed = true return channel, nil } func (s *ChannelStore) SetAutotranslation(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { if userID == 0 || channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("toggle channel autotranslation: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin toggle channel autotranslation: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if !canChangeChannelInfo(member) { return domain.Channel{}, domain.ErrChannelAdminRequired } prev := channel.Autotranslation if _, err := tx.Exec(ctx, `UPDATE channels SET autotranslation = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { return domain.Channel{}, fmt.Errorf("update channel autotranslation: %w", err) } channel.Autotranslation = enabled if prev != enabled { if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: channelID, UserID: userID, Date: nowUnix(), Type: domain.ChannelAdminLogToggleAutotranslation, PrevBool: prev, NewBool: enabled, }); err != nil { return domain.Channel{}, err } } if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit toggle channel autotranslation: %w", err) } committed = true return channel, nil } func (s *ChannelStore) SetRestrictedSponsored(ctx context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) { if userID == 0 || channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("toggle channel restricted sponsored: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin toggle channel restricted sponsored: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if !canChangeChannelInfo(member) { return domain.Channel{}, domain.ErrChannelAdminRequired } if _, err := tx.Exec(ctx, `UPDATE channels SET restricted_sponsored = $2, updated_at = now() WHERE id = $1`, channelID, restricted); err != nil { return domain.Channel{}, fmt.Errorf("update channel restricted sponsored: %w", err) } channel.RestrictedSponsored = restricted if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit toggle channel restricted sponsored: %w", err) } committed = true return channel, nil } func (s *ChannelStore) SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error) { if userID == 0 || channelID == 0 || stars < 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("update channel paid messages price: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin update channel paid messages price: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if !canChangeChannelInfo(member) { return domain.Channel{}, domain.ErrChannelAdminRequired } broadcastAllowed := channel.Broadcast && broadcastMessagesAllowed if _, err := tx.Exec(ctx, `UPDATE channels SET send_paid_messages_stars = $2, broadcast_messages_allowed = $3, updated_at = now() WHERE id = $1`, channelID, stars, broadcastAllowed); err != nil { return domain.Channel{}, fmt.Errorf("update channel paid messages price: %w", err) } channel.SendPaidMessagesStars = stars channel.BroadcastMessagesAllowed = broadcastAllowed if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit update channel paid messages price: %w", err) } committed = true return channel, nil } func (s *ChannelStore) SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { if userID == 0 || channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("toggle channel antispam: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin toggle channel antispam: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if !channel.Megagroup || !canChangeChannelInfo(member) { return domain.Channel{}, domain.ErrChannelAdminRequired } prev := channel.AntiSpam if _, err := tx.Exec(ctx, `UPDATE channels SET antispam = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { return domain.Channel{}, fmt.Errorf("update channel antispam: %w", err) } channel.AntiSpam = enabled if prev != enabled { if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: channelID, UserID: userID, Date: nowUnix(), Type: domain.ChannelAdminLogToggleAntiSpam, PrevBool: prev, NewBool: enabled, }); err != nil { return domain.Channel{}, err } } if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit toggle channel antispam: %w", err) } committed = true return channel, nil } func (s *ChannelStore) SetSlowMode(ctx context.Context, userID, channelID int64, seconds int) (domain.Channel, error) { if userID == 0 || channelID == 0 || !domain.ValidChannelSlowModeSeconds(seconds) { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("toggle channel slowmode: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin toggle channel slowmode: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if !channel.Megagroup || !canChangeChannelInfo(member) { return domain.Channel{}, domain.ErrChannelAdminRequired } prev := channel.SlowmodeSeconds if _, err := tx.Exec(ctx, `UPDATE channels SET slowmode_seconds = $2, updated_at = now() WHERE id = $1`, channelID, seconds); err != nil { return domain.Channel{}, fmt.Errorf("update channel slowmode: %w", err) } channel.SlowmodeSeconds = seconds if prev != seconds { if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: channelID, UserID: userID, Date: nowUnix(), Type: domain.ChannelAdminLogToggleSlowMode, PrevInt: prev, NewInt: seconds, }); err != nil { return domain.Channel{}, err } } if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit toggle channel slowmode: %w", err) } committed = true return channel, nil } func (s *ChannelStore) SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { if userID == 0 || channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("toggle channel noforwards: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin toggle channel noforwards: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if !canChangeChannelInfo(member) { return domain.Channel{}, domain.ErrChannelAdminRequired } if _, err := tx.Exec(ctx, `UPDATE channels SET noforwards = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { return domain.Channel{}, fmt.Errorf("update channel noforwards: %w", err) } channel.NoForwards = enabled if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit toggle channel noforwards: %w", err) } committed = true return channel, nil } func (s *ChannelStore) SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { if userID == 0 || channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("toggle channel join_to_send: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin toggle channel join_to_send: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if !channel.Megagroup || !canExportChannelInvite(member) { return domain.Channel{}, domain.ErrChannelAdminRequired } if _, err := tx.Exec(ctx, `UPDATE channels SET join_to_send = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { return domain.Channel{}, fmt.Errorf("update channel join_to_send: %w", err) } channel.JoinToSend = enabled if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit toggle channel join_to_send: %w", err) } committed = true return channel, nil } func (s *ChannelStore) SetJoinRequest(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { if userID == 0 || channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("toggle channel join_request: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin toggle channel join_request: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if !channel.Megagroup || !canExportChannelInvite(member) { return domain.Channel{}, domain.ErrChannelAdminRequired } if enabled && strings.TrimSpace(channel.Username) == "" { return domain.Channel{}, domain.ErrChatPublicRequired } if _, err := tx.Exec(ctx, `UPDATE channels SET join_request = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { return domain.Channel{}, fmt.Errorf("update channel join_request: %w", err) } channel.JoinRequest = enabled if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit toggle channel join_request: %w", err) } committed = true return channel, nil } func (s *ChannelStore) SetAvailableReactions(ctx context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error) { if userID == 0 || channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("set channel available reactions: db does not support transactions") } policyJSON, err := marshalJSON(policy, "{}") if err != nil { return domain.Channel{}, err } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin set channel available reactions: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if !canChangeChannelInfo(member) { return domain.Channel{}, domain.ErrChannelAdminRequired } if _, err := tx.Exec(ctx, `UPDATE channels SET available_reactions = $2, updated_at = now() WHERE id = $1`, channelID, policyJSON); err != nil { return domain.Channel{}, fmt.Errorf("update channel available reactions: %w", err) } channel.ReactionPolicy = policy if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit set channel available reactions: %w", err) } committed = true return channel, nil } func (s *ChannelStore) SetColor(ctx context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) { if userID == 0 || channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("set channel color: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin set channel color: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if !canChangeChannelInfo(member) { return domain.Channel{}, domain.ErrChannelAdminRequired } if forProfile { if _, err := tx.Exec(ctx, `UPDATE channels SET profile_color_set = $2, profile_color = $3, profile_color_background_emoji_id = $4, updated_at = now() WHERE id = $1`, channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil { return domain.Channel{}, fmt.Errorf("update channel profile color: %w", err) } channel.ProfileColor = color } else { if _, err := tx.Exec(ctx, `UPDATE channels SET color_set = $2, color = $3, color_background_emoji_id = $4, updated_at = now() WHERE id = $1`, channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil { return domain.Channel{}, fmt.Errorf("update channel color: %w", err) } channel.Color = color } if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit set channel color: %w", err) } committed = true return channel, nil } func (s *ChannelStore) SetEmojiStatus(ctx context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) { if userID == 0 || channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } if status.DocumentID == 0 { status.Until = 0 } beginner, ok := s.db.(txBeginner) if !ok { return domain.Channel{}, fmt.Errorf("set channel emoji status: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.Channel{}, fmt.Errorf("begin set channel emoji status: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) if err != nil { return domain.Channel{}, err } if !canChangeChannelInfo(member) { return domain.Channel{}, domain.ErrChannelAdminRequired } if _, err := tx.Exec(ctx, `UPDATE channels SET emoji_status_document_id = $2, emoji_status_until = $3, updated_at = now() WHERE id = $1`, channelID, status.DocumentID, status.Until); err != nil { return domain.Channel{}, fmt.Errorf("update channel emoji status: %w", err) } channel.EmojiStatus = status if err := tx.Commit(ctx); err != nil { return domain.Channel{}, fmt.Errorf("commit set channel emoji status: %w", err) } committed = true return channel, nil } func (s *ChannelStore) ListAdminLog(ctx context.Context, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.MaxID < 0 || req.MinID < 0 { return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid } channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ChannelAdminLogResult{}, err } if !isChannelAdmin(member) { return domain.ChannelAdminLogResult{}, domain.ErrChannelAdminRequired } limit := req.Limit if limit <= 0 || limit > domain.MaxChannelAdminLogLimit { limit = domain.MaxChannelAdminLogLimit } where := []string{"channel_id = $1"} args := []any{req.ChannelID} nextArg := func(v any) string { args = append(args, v) return fmt.Sprintf("$%d", len(args)) } if req.MaxID > 0 { where = append(where, "id < "+nextArg(req.MaxID)) } if req.MinID > 0 { where = append(where, "id > "+nextArg(req.MinID)) } if len(req.AdminUserIDs) > 0 { where = append(where, "actor_user_id = ANY("+nextArg(int64s(req.AdminUserIDs))+"::bigint[])") } if types := adminLogEventTypesForFilter(req.Filter); len(types) > 0 { where = append(where, "event_type = ANY("+nextArg(types)+"::text[])") } else if !req.Filter.Empty() { return domain.ChannelAdminLogResult{Channel: channel}, nil } query := strings.ToLower(strings.TrimSpace(req.Query)) if query != "" { like := adminLogLikePattern(query) where = append(where, `(lower(prev_string) LIKE `+nextArg(like)+` ESCAPE '\' OR lower(new_string) LIKE `+nextArg(like)+` ESCAPE '\' OR lower(query) LIKE `+nextArg(like)+` ESCAPE '\')`) } args = append(args, limit) rows, err := s.db.Query(ctx, ` SELECT channel_id, id, actor_user_id, event_date, event_type, prev_string, new_string, prev_bool, new_bool, prev_int, new_int, prev_participant::text, new_participant::text, participant::text, message::text, prev_message::text, new_message::text, query FROM channel_admin_log_events WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT $`+fmt.Sprint(len(args)), args...) if err != nil { return domain.ChannelAdminLogResult{}, fmt.Errorf("list channel admin log: %w", err) } defer rows.Close() events := make([]domain.ChannelAdminLogEvent, 0, limit) for rows.Next() { event, err := scanChannelAdminLogEvent(rows) if err != nil { return domain.ChannelAdminLogResult{}, err } events = append(events, event) } if err := rows.Err(); err != nil { return domain.ChannelAdminLogResult{}, err } return domain.ChannelAdminLogResult{Channel: channel, Events: events}, nil } func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero()) { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } if req.Date == 0 { req.Date = nowUnix() } var lastErr error for attempt := 0; attempt < retryableChannelTxAttempts; attempt++ { res, err := s.sendChannelMessageOnce(ctx, req) if err == nil || !isRetryablePostgresTxError(err) || ctx.Err() != nil { return res, err } lastErr = err } return domain.SendChannelMessageResult{}, lastErr } func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { if req.RandomID != 0 { if dup, found, err := s.duplicateChannelMessage(ctx, req.ChannelID, req.UserID, req.RandomID); err != nil { return domain.SendChannelMessageResult{}, err } else if found { return dup, nil } } beginner, ok := s.db.(txBeginner) if !ok { return domain.SendChannelMessageResult{}, fmt.Errorf("send channel message: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("begin send channel: %w", err) } var reserved []reservedChannelPts committed := false defer func() { if !committed { _ = tx.Rollback(ctx) if len(reserved) > 0 { s.recordChannelPtsGaps(ctx, reserved, req.Date) } } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.SendChannelMessageResult{}, err } if !canSendChannelMessage(channel, member) { return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden } replyTo, err := s.resolveChannelReply(ctx, tx, req, member, channel) if err != nil { return domain.SendChannelMessageResult{}, err } if _, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, replyTo, req.Forward); err != nil { return domain.SendChannelMessageResult{}, err } if wait := channelSlowModeWait(channel, member, req.Date); wait > 0 { return domain.SendChannelMessageResult{}, domain.NewSlowModeWaitError(wait) } var sendAs *domain.Peer if req.SendAs != nil { p := *req.SendAs sendAs = &p } msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.ChannelID) if err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("allocate channel message id: %w", err) } pts, err := s.pts.NextChannelPts(ctx, req.ChannelID) if err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("allocate channel pts: %w", err) } reserved = []reservedChannelPts{{channelID: req.ChannelID, pts: pts, count: 1}} var discussion *domain.SendChannelDiscussionResult var discussionRef *domain.ChannelDiscussionRef if channel.Broadcast && channel.LinkedChatID != 0 { linked, err := getChannelByID(ctx, tx, channel.LinkedChatID) if err == nil && !linked.Deleted && linked.Megagroup { discussionMsgID, err := s.msgIDs.NextChannelMessageID(ctx, linked.ID) if err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("allocate discussion message id: %w", err) } discussionPts, err := s.pts.NextChannelPts(ctx, linked.ID) if err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("allocate discussion pts: %w", err) } reserved = append(reserved, reservedChannelPts{channelID: linked.ID, pts: discussionPts, count: 1}) discussionRef = &domain.ChannelDiscussionRef{ChannelID: linked.ID, MessageID: discussionMsgID} discussionMsg := domain.ChannelMessage{ ChannelID: linked.ID, ID: discussionMsgID, SenderUserID: req.UserID, From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Date: req.Date, Silent: req.Silent, NoForwards: req.NoForwards || channel.NoForwards || linked.NoForwards, Body: req.Message, Entities: append([]domain.MessageEntity(nil), req.Entities...), Media: req.Media, Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Date: req.Date, ChannelPost: msgID, SavedFrom: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, SavedFromMsgID: msgID}, Pts: discussionPts, } discussionEvent := domain.ChannelUpdateEvent{ ChannelID: linked.ID, Type: domain.ChannelUpdateNewMessage, Pts: discussionPts, PtsCount: 1, Date: req.Date, Message: discussionMsg, } if err := insertChannelMessageTx(ctx, tx, discussionMsg); err != nil { return domain.SendChannelMessageResult{}, err } if err := insertChannelEventTx(ctx, tx, discussionEvent); err != nil { return domain.SendChannelMessageResult{}, err } if err := insertChannelUnreadMentionsTx(ctx, tx, linked.ID, discussionMsg, req.UserID, req.MentionUserIDs); err != nil { return domain.SendChannelMessageResult{}, err } if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, linked.ID, discussionMsgID, discussionPts); err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("update discussion channel top: %w", err) } linked.TopMessageID = discussionMsgID linked.Pts = discussionPts if err := upsertChannelDialogsForMessageTx(ctx, tx, linked, discussionMsg, 0); err != nil { return domain.SendChannelMessageResult{}, err } discussion = &domain.SendChannelDiscussionResult{ Channel: linked, Message: discussionMsg, Event: discussionEvent, } } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { return domain.SendChannelMessageResult{}, err } } msg := domain.ChannelMessage{ ChannelID: req.ChannelID, ID: msgID, RandomID: req.RandomID, SenderUserID: req.UserID, From: domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}, Date: req.Date, Post: channel.Broadcast, Silent: req.Silent, NoForwards: req.NoForwards || channel.NoForwards, Body: req.Message, Entities: append([]domain.MessageEntity(nil), req.Entities...), Media: req.Media, ReplyTo: replyTo, Forward: cloneMessageForward(req.Forward), SendAs: sendAs, Discussion: discussionRef, Action: cloneChannelMessageAction(req.Action), Pts: pts, } if discussionRef != nil { msg.Replies = &domain.ChannelMessageReplies{Comments: true, ChannelID: discussionRef.ChannelID, RepliesPts: discussion.Event.Pts} } event := domain.ChannelUpdateEvent{ ChannelID: req.ChannelID, Type: domain.ChannelUpdateNewMessage, Pts: pts, PtsCount: 1, Date: req.Date, Message: msg, SenderUserID: req.UserID, } if err := insertChannelMessageTx(ctx, tx, msg); err != nil { if isUniqueViolation(err) { dup, found, dupErr := s.duplicateChannelMessage(ctx, req.ChannelID, req.UserID, req.RandomID) if dupErr != nil || !found { return domain.SendChannelMessageResult{}, dupErr } dup.Duplicate = true return dup, nil } return domain.SendChannelMessageResult{}, err } if err := insertChannelEventTx(ctx, tx, event); err != nil { return domain.SendChannelMessageResult{}, err } if err := insertChannelUnreadMentionsTx(ctx, tx, req.ChannelID, msg, req.UserID, req.MentionUserIDs); err != nil { return domain.SendChannelMessageResult{}, err } if err := updateForumTopicTopMessageTx(ctx, tx, req.ChannelID, msg); err != nil { return domain.SendChannelMessageResult{}, err } if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, req.ChannelID, msgID, pts); err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("update channel top: %w", err) } channel.TopMessageID = msgID channel.Pts = pts if _, err := tx.Exec(ctx, ` UPDATE channel_members SET slowmode_last_send_date = $3, read_inbox_max_id = GREATEST(read_inbox_max_id, $4), unread_mark = false, updated_at = now() WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, req.Date, msgID); err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("update channel member slowmode send date: %w", err) } if err := upsertChannelDialogsForMessageTx(ctx, tx, channel, msg, req.UserID); err != nil { return domain.SendChannelMessageResult{}, err } if channel.Broadcast { if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: req.ChannelID, UserID: req.UserID, Date: req.Date, Type: domain.ChannelAdminLogSendMessage, Message: &msg, Query: msg.Body, }); err != nil { return domain.SendChannelMessageResult{}, err } } if err := tx.Commit(ctx); err != nil { return domain.SendChannelMessageResult{}, fmt.Errorf("commit send channel: %w", err) } committed = true recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) if discussion != nil { discussion.Recipients, _ = s.ListActiveChannelMemberIDs(ctx, req.UserID, discussion.Channel.ID, 0) } return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: recipients, Discussion: discussion}, nil } func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 || strings.TrimSpace(req.Message) == "" { return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.EditChannelMessageResult{}, fmt.Errorf("edit channel message: db does not support transactions") } pts, err := s.pts.NextChannelPts(ctx, req.ChannelID) if err != nil { return domain.EditChannelMessageResult{}, fmt.Errorf("allocate channel edit pts: %w", err) } reserved := []reservedChannelPts{{channelID: req.ChannelID, pts: pts, count: 1}} if req.EditDate == 0 { req.EditDate = nowUnix() } entities, err := encodeMessageEntities(req.Entities) if err != nil { s.recordChannelPtsGaps(ctx, reserved, req.EditDate) return domain.EditChannelMessageResult{}, err } tx, err := beginner.Begin(ctx) if err != nil { s.recordChannelPtsGaps(ctx, reserved, req.EditDate) return domain.EditChannelMessageResult{}, fmt.Errorf("begin edit channel message: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) s.recordChannelPtsGaps(ctx, reserved, req.EditDate) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.EditChannelMessageResult{}, err } msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.ID) if err != nil { return domain.EditChannelMessageResult{}, err } if msg.Deleted || msg.Action != nil { return domain.EditChannelMessageResult{}, domain.ErrMessageIDInvalid } if msg.SenderUserID != req.UserID && !canEditChannelMessage(member) { return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired } if msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified } prevMsg := msg if _, err := tx.Exec(ctx, ` UPDATE channel_messages SET body = $4, entities = $5, edit_date = $6, pts = $7, updated_at = now() WHERE channel_id = $1 AND id = $2 AND NOT deleted AND sender_user_id = $3 OR ( channel_id = $1 AND id = $2 AND NOT deleted AND $8 )`, req.ChannelID, req.ID, req.UserID, req.Message, entities, req.EditDate, pts, canEditChannelMessage(member)); err != nil { return domain.EditChannelMessageResult{}, fmt.Errorf("update channel edit: %w", err) } msg.Body = req.Message msg.Entities = append([]domain.MessageEntity(nil), req.Entities...) msg.EditDate = req.EditDate msg.Pts = pts event := domain.ChannelUpdateEvent{ ChannelID: req.ChannelID, Type: domain.ChannelUpdateEditMessage, Pts: pts, PtsCount: 1, Date: req.EditDate, Message: msg, SenderUserID: req.UserID, } if err := insertChannelEventTx(ctx, tx, event); err != nil { return domain.EditChannelMessageResult{}, err } if _, err := tx.Exec(ctx, `UPDATE channels SET pts = $2, updated_at = now() WHERE id = $1`, req.ChannelID, pts); err != nil { return domain.EditChannelMessageResult{}, fmt.Errorf("update channel edit pts: %w", err) } channel.Pts = pts if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: req.ChannelID, UserID: req.UserID, Date: req.EditDate, Type: domain.ChannelAdminLogEditMessage, PrevMessage: &prevMsg, NewMessage: &msg, Query: msg.Body, }); err != nil { return domain.EditChannelMessageResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.EditChannelMessageResult{}, fmt.Errorf("commit edit channel message: %w", err) } committed = true recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) return domain.EditChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: recipients}, nil } func (s *ChannelStore) DeleteChannelMessages(ctx context.Context, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) { if req.UserID == 0 || req.ChannelID == 0 || len(req.IDs) == 0 { return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid } if len(req.IDs) > domain.MaxDeleteMessageIDs { return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid } if req.Date == 0 { req.Date = nowUnix() } beginner, ok := s.db.(txBeginner) if !ok { return domain.DeleteChannelMessagesResult{}, fmt.Errorf("delete channel messages: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.DeleteChannelMessagesResult{}, fmt.Errorf("begin delete channel messages: %w", err) } committed := false var reserved []reservedChannelPts defer func() { if !committed { _ = tx.Rollback(ctx) s.recordChannelPtsGaps(ctx, reserved, req.Date) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.DeleteChannelMessagesResult{}, err } deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, req.IDs, req.UserID, req.Date, &reserved) if err != nil { return domain.DeleteChannelMessagesResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.DeleteChannelMessagesResult{}, fmt.Errorf("commit delete channel messages: %w", err) } committed = true recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) return domain.DeleteChannelMessagesResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients}, nil } func (s *ChannelStore) DeleteChannelHistory(ctx context.Context, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid } if req.Date == 0 { req.Date = nowUnix() } beginner, ok := s.db.(txBeginner) if !ok { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("delete channel history: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("begin delete channel history: %w", err) } committed := false var reserved []reservedChannelPts defer func() { if !committed { _ = tx.Rollback(ctx) s.recordChannelPtsGaps(ctx, reserved, req.Date) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.DeleteChannelHistoryResult{}, err } maxID := req.MaxID if maxID <= 0 || maxID > channel.TopMessageID { maxID = channel.TopMessageID } if !req.ForEveryone { appliedMinID := maxInt(member.AvailableMinID, maxID) topID, topDate, err := visibleChannelTopAfter(ctx, tx, req.ChannelID, appliedMinID, channel.Date) if err != nil { return domain.DeleteChannelHistoryResult{}, err } if _, err := tx.Exec(ctx, ` UPDATE channel_members SET available_min_id = GREATEST(available_min_id, $3), read_inbox_max_id = GREATEST(read_inbox_max_id, $3), unread_mark = false, updated_at = now() WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, appliedMinID); err != nil { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("update channel local clear member: %w", err) } if err := deleteChannelUnreadMentionsUpToTx(ctx, tx, req.UserID, req.ChannelID, appliedMinID); err != nil { return domain.DeleteChannelHistoryResult{}, err } if err := refreshChannelUnreadReactionsCountTx(ctx, tx, req.UserID, req.ChannelID); err != nil { return domain.DeleteChannelHistoryResult{}, err } if _, err := tx.Exec(ctx, ` INSERT INTO channel_dialogs ( user_id, channel_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark ) VALUES ($1,$2,$3,$4,$5,0,0,false) ON CONFLICT (user_id, channel_id) DO UPDATE SET top_message_id = EXCLUDED.top_message_id, top_message_date = EXCLUDED.top_message_date, read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id), unread_count = 0, unread_mark = false, updated_at = now()`, req.UserID, req.ChannelID, topID, topDate, appliedMinID); err != nil { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("upsert channel local clear dialog: %w", err) } if err := tx.Commit(ctx); err != nil { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit local clear channel history: %w", err) } committed = true return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil } if !canDeleteAnyChannelMessage(member) { return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired } rows, err := tx.Query(ctx, ` SELECT id FROM channel_messages WHERE channel_id = $1 AND id <= $2 AND NOT deleted ORDER BY id DESC LIMIT $3`, req.ChannelID, maxID, domain.MaxDeleteHistoryBatch) if err != nil { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("list channel history delete ids: %w", err) } ids := make([]int, 0, domain.MaxDeleteHistoryBatch) for rows.Next() { var id int if err := rows.Scan(&id); err != nil { rows.Close() return domain.DeleteChannelHistoryResult{}, err } ids = append(ids, id) } if err := rows.Err(); err != nil { rows.Close() return domain.DeleteChannelHistoryResult{}, err } rows.Close() deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, ids, req.UserID, req.Date, &reserved) if err != nil { return domain.DeleteChannelHistoryResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit delete channel history: %w", err) } committed = true recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) offset := 0 if len(deleted) == domain.MaxDeleteHistoryBatch { offset = 1 } return domain.DeleteChannelHistoryResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, Offset: offset}, nil } func (s *ChannelStore) DeleteChannelParticipantHistory(ctx context.Context, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid } if req.Date == 0 { req.Date = nowUnix() } beginner, ok := s.db.(txBeginner) if !ok { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("delete participant channel history: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("begin delete participant channel history: %w", err) } committed := false var reserved []reservedChannelPts defer func() { if !committed { _ = tx.Rollback(ctx) s.recordChannelPtsGaps(ctx, reserved, req.Date) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.DeleteChannelHistoryResult{}, err } if !canDeleteAnyChannelMessage(member) { return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired } rows, err := tx.Query(ctx, ` SELECT id FROM channel_messages WHERE channel_id = $1 AND sender_user_id = $2 AND NOT deleted ORDER BY id DESC LIMIT $3`, req.ChannelID, req.ParticipantUserID, domain.MaxDeleteHistoryBatch) if err != nil { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("list participant channel history delete ids: %w", err) } ids := make([]int, 0, domain.MaxDeleteHistoryBatch) for rows.Next() { var id int if err := rows.Scan(&id); err != nil { rows.Close() return domain.DeleteChannelHistoryResult{}, err } ids = append(ids, id) } if err := rows.Err(); err != nil { rows.Close() return domain.DeleteChannelHistoryResult{}, err } rows.Close() deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, ids, req.UserID, req.Date, &reserved) if err != nil { return domain.DeleteChannelHistoryResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit delete participant channel history: %w", err) } committed = true recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) offset := 0 if len(deleted) == domain.MaxDeleteHistoryBatch { offset = 1 } return domain.DeleteChannelHistoryResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, Offset: offset}, nil } func (s *ChannelStore) UpdatePinnedMessage(ctx context.Context, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("pin channel message: db does not support transactions") } if req.Date == 0 { req.Date = nowUnix() } pts, err := s.pts.NextChannelPts(ctx, req.ChannelID) if err != nil { return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("allocate channel pin pts: %w", err) } reserved := []reservedChannelPts{{channelID: req.ChannelID, pts: pts, count: 1}} tx, err := beginner.Begin(ctx) if err != nil { s.recordChannelPtsGaps(ctx, reserved, req.Date) return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("begin pin channel message: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) s.recordChannelPtsGaps(ctx, reserved, req.Date) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.UpdateChannelPinnedMessageResult{}, err } if !canPinChannelMessages(channel, member) { return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelAdminRequired } msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) if err != nil || msg.Deleted { return domain.UpdateChannelPinnedMessageResult{}, domain.ErrMessageIDInvalid } pinnedID := 0 if req.Pinned { pinnedID = req.MessageID } if channel.PinnedMessageID == pinnedID { return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelNotModified } if _, err := tx.Exec(ctx, ` UPDATE channels SET pinned_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, req.ChannelID, pinnedID, pts); err != nil { return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("update channel pinned message: %w", err) } channel.PinnedMessageID = pinnedID channel.Pts = pts event := domain.ChannelUpdateEvent{ ChannelID: req.ChannelID, Type: domain.ChannelUpdatePinnedMessages, Pts: pts, PtsCount: 1, Date: req.Date, MessageIDs: []int{req.MessageID}, SenderUserID: req.UserID, Pinned: req.Pinned, } if err := insertChannelEventTx(ctx, tx, event); err != nil { return domain.UpdateChannelPinnedMessageResult{}, err } if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: req.ChannelID, UserID: req.UserID, Date: req.Date, Type: domain.ChannelAdminLogUpdatePinned, Message: &msg, Query: msg.Body, }); err != nil { return domain.UpdateChannelPinnedMessageResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("commit pin channel message: %w", err) } committed = true recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) return domain.UpdateChannelPinnedMessageResult{Channel: channel, Event: event, Recipients: recipients}, nil } func (s *ChannelStore) ExportInvite(ctx context.Context, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.ExportChannelInviteResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.ExportChannelInviteResult{}, fmt.Errorf("export channel invite: db does not support transactions") } if req.Date == 0 { req.Date = nowUnix() } tx, err := beginner.Begin(ctx) if err != nil { return domain.ExportChannelInviteResult{}, fmt.Errorf("begin export channel invite: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.ExportChannelInviteResult{}, err } if !canExportChannelInvite(member) { return domain.ExportChannelInviteResult{}, domain.ErrChannelAdminRequired } if req.LegacyRevokePermanent { if _, err := tx.Exec(ctx, ` UPDATE channel_invites SET revoked = true, updated_at = now() WHERE channel_id = $1 AND admin_user_id = $2 AND permanent AND NOT revoked`, req.ChannelID, req.UserID); err != nil { return domain.ExportChannelInviteResult{}, fmt.Errorf("revoke permanent channel invite: %w", err) } } inviteID, err := randomPositiveInt64() if err != nil { return domain.ExportChannelInviteResult{}, err } hash, err := randomInviteHash() if err != nil { return domain.ExportChannelInviteResult{}, err } invite := domain.ChannelInvite{ ChannelID: req.ChannelID, InviteID: inviteID, Hash: hash, AdminUserID: req.UserID, Title: req.Title, Permanent: req.ExpireDate == 0 && req.UsageLimit == 0 && !req.RequestNeeded && req.Title == "", RequestNeeded: req.RequestNeeded, ExpireDate: req.ExpireDate, UsageLimit: req.UsageLimit, Date: req.Date, } if err := insertChannelInviteTx(ctx, tx, invite); err != nil { return domain.ExportChannelInviteResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.ExportChannelInviteResult{}, fmt.Errorf("commit export channel invite: %w", err) } committed = true return domain.ExportChannelInviteResult{Channel: channel, Invite: invite}, nil } func (s *ChannelStore) CheckInvite(ctx context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error) { if userID == 0 || strings.TrimSpace(hash) == "" { return domain.CheckChannelInviteResult{}, domain.ErrInviteHashEmpty } if date == 0 { date = nowUnix() } channel, invite, err := s.getInviteByHash(ctx, s.db, strings.TrimSpace(hash)) if err != nil { return domain.CheckChannelInviteResult{}, err } if invite.ExpireDate > 0 && invite.ExpireDate < date { return domain.CheckChannelInviteResult{}, domain.ErrInviteHashExpired } member, err := s.getChannelMember(ctx, s.db, channel.ID, userID) already := false if err == nil { if member.Status == domain.ChannelMemberKicked || member.Status == domain.ChannelMemberBanned || member.BannedRights.ViewMessages { return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid } already = member.Status == domain.ChannelMemberActive } else if !errors.Is(err, domain.ErrChannelPrivate) { return domain.CheckChannelInviteResult{}, err } return domain.CheckChannelInviteResult{Channel: channel, Invite: invite, Already: already, Self: member}, nil } func (s *ChannelStore) ImportInvite(ctx context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) { if req.UserID == 0 || strings.TrimSpace(req.Hash) == "" { return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty } if req.Date == 0 { req.Date = nowUnix() } beginner, ok := s.db.(txBeginner) if !ok { return domain.CreateChannelResult{}, fmt.Errorf("import channel invite: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.CreateChannelResult{}, fmt.Errorf("begin import channel invite: %w", err) } committed := false var reserved []reservedChannelPts defer func() { if !committed { _ = tx.Rollback(ctx) s.recordChannelPtsGaps(ctx, reserved, req.Date) } }() channel, invite, err := s.getInviteByHashForUpdate(ctx, tx, strings.TrimSpace(req.Hash)) if err != nil { return domain.CreateChannelResult{}, err } if invite.ExpireDate > 0 && invite.ExpireDate < req.Date { return domain.CreateChannelResult{}, domain.ErrInviteHashExpired } if invite.RequestNeeded { if err := s.recordPendingInviteImporterTx(ctx, tx, invite, req.UserID, req.Date); err != nil { return domain.CreateChannelResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("commit pending channel invite request: %w", err) } committed = true return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent } result, err := s.approveInviteImporterTx(ctx, tx, channel, invite, req.UserID, 0, req.Date, &reserved) if err != nil { return domain.CreateChannelResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("commit import channel invite: %w", err) } committed = true recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, result.Channel.ID, 0) result.Recipients = recipients return result, nil } func (s *ChannelStore) approveInviteImporterTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, invite domain.ChannelInvite, userID, approvedBy int64, date int, reserved *[]reservedChannelPts) (domain.CreateChannelResult, error) { if invite.InviteID != 0 && invite.UsageLimit > 0 && invite.UsageCount >= invite.UsageLimit { return domain.CreateChannelResult{}, domain.ErrUsersTooMuch } channelID := channel.ID if channelID == 0 { channelID = invite.ChannelID } if existing, err := s.getChannelMember(ctx, tx, channelID, userID); err == nil { if existing.Status == domain.ChannelMemberActive { return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant } if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid } } else if !errors.Is(err, domain.ErrChannelPrivate) { return domain.CreateChannelResult{}, err } preJoinTopID := channel.TopMessageID minID := channelInitialAvailableMinID(channel) inviterID := invite.AdminUserID if inviterID == 0 { inviterID = approvedBy } member := domain.ChannelMember{ ChannelID: channelID, UserID: userID, InviterUserID: inviterID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive, JoinedAt: date, AvailableMinID: minID, AvailableMinPts: channelInitialAvailableMinPts(channel), ReadInboxMaxID: maxInt(minID, preJoinTopID), } if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { return domain.CreateChannelResult{}, err } if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: channelID, UserID: userID, Date: date, Type: domain.ChannelAdminLogParticipantJoin, }); err != nil { return domain.CreateChannelResult{}, err } if invite.InviteID != 0 { if _, err := tx.Exec(ctx, ` UPDATE channel_invites SET requested_count = GREATEST(requested_count - 1, 0), updated_at = now() WHERE channel_id = $1 AND invite_id = ( SELECT invite_id FROM channel_invite_importers WHERE channel_id = $1 AND user_id = $2 AND requested )`, channelID, userID); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("clear pending channel invite request: %w", err) } if _, err := tx.Exec(ctx, ` UPDATE channel_invites SET usage_count = usage_count + 1, updated_at = now() WHERE channel_id = $1 AND invite_id = $2`, channelID, invite.InviteID); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("increment channel invite usage: %w", err) } } if _, err := tx.Exec(ctx, ` INSERT INTO channel_invite_importers (channel_id, invite_id, user_id, date, requested, approved_by) VALUES ($1, $2, $3, $4, false, $5) ON CONFLICT (channel_id, user_id) DO UPDATE SET invite_id = EXCLUDED.invite_id, date = EXCLUDED.date, requested = false, approved_by = EXCLUDED.approved_by, updated_at = now()`, channelID, invite.InviteID, userID, date, approvedBy); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("upsert channel invite importer: %w", err) } channel, err := refreshChannelCountsTx(ctx, tx, channel) if err != nil { return domain.CreateChannelResult{}, err } var msg domain.ChannelMessage var event domain.ChannelUpdateEvent if channel.Megagroup { msg, event, err = s.insertServiceMessage(ctx, tx, channel, userID, date, domain.ChannelMessageAction{ Type: domain.ChannelActionChatJoined, UserIDs: []int64{userID}, }, reserved) if err != nil { return domain.CreateChannelResult{}, err } channel.TopMessageID = msg.ID channel.Pts = event.Pts } member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID) if msg.ID != 0 { member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID) } if _, err := tx.Exec(ctx, ` UPDATE channel_members SET read_inbox_max_id = $3, read_outbox_max_id = $4, updated_at = now() WHERE channel_id = $1 AND user_id = $2`, channel.ID, userID, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("update imported member read state: %w", err) } if err := upsertChannelDialogTx(ctx, tx, userID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { return domain.CreateChannelResult{}, err } return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event}, nil } func (s *ChannelStore) recordPendingInviteImporterTx(ctx context.Context, tx pgx.Tx, invite domain.ChannelInvite, userID int64, date int) error { if existing, err := s.getChannelMember(ctx, tx, invite.ChannelID, userID); err == nil { if existing.Status == domain.ChannelMemberActive { return domain.ErrUserAlreadyParticipant } if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { return domain.ErrInviteHashInvalid } } else if !errors.Is(err, domain.ErrChannelPrivate) { return err } tag, err := tx.Exec(ctx, ` INSERT INTO channel_invite_importers (channel_id, invite_id, user_id, date, requested) VALUES ($1, $2, $3, $4, true) ON CONFLICT (channel_id, user_id) DO UPDATE SET invite_id = EXCLUDED.invite_id, date = EXCLUDED.date, requested = true, approved_by = 0, updated_at = now() WHERE NOT channel_invite_importers.requested`, invite.ChannelID, invite.InviteID, userID, date) if err != nil { return fmt.Errorf("record pending channel invite importer: %w", err) } if tag.RowsAffected() == 0 { return domain.ErrInviteRequestSent } if _, err := tx.Exec(ctx, ` UPDATE channel_invites SET requested_count = requested_count + 1, updated_at = now() WHERE channel_id = $1 AND invite_id = $2`, invite.ChannelID, invite.InviteID); err != nil { return fmt.Errorf("increment channel invite requested count: %w", err) } return nil } func (s *ChannelStore) recordPublicJoinRequestTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, userID int64, date int) error { if existing, err := s.getChannelMember(ctx, tx, channel.ID, userID); err == nil { if existing.Status == domain.ChannelMemberActive { return domain.ErrUserAlreadyParticipant } if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { return domain.ErrInviteHashInvalid } } else if !errors.Is(err, domain.ErrChannelPrivate) { return err } if existing, err := s.getPendingInviteImporterTx(ctx, tx, channel.ID, userID, true); err == nil && existing.Requested { return domain.ErrInviteRequestSent } else if err != nil && !errors.Is(err, domain.ErrHideRequesterMissing) { return err } if _, err := tx.Exec(ctx, ` INSERT INTO channel_invite_importers (channel_id, invite_id, user_id, date, requested) VALUES ($1, 0, $2, $3, true) ON CONFLICT (channel_id, user_id) DO UPDATE SET invite_id = 0, date = EXCLUDED.date, requested = true, approved_by = 0, updated_at = now()`, channel.ID, userID, date); err != nil { return fmt.Errorf("insert public channel join request: %w", err) } return nil } func (s *ChannelStore) ListExportedInvites(ctx context.Context, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error) { if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 { return domain.ChannelInviteList{}, domain.ErrChannelInvalid } _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ChannelInviteList{}, err } if !canExportChannelInvite(member) { return domain.ChannelInviteList{}, domain.ErrChannelAdminRequired } var total int if err := s.db.QueryRow(ctx, ` SELECT COUNT(*)::int FROM channel_invites WHERE channel_id = $1 AND admin_user_id = $2 AND revoked = $3`, req.ChannelID, req.AdminUserID, req.Revoked).Scan(&total); err != nil { return domain.ChannelInviteList{}, err } limit := req.Limit if limit <= 0 || limit > domain.MaxChannelInviteListLimit { limit = domain.MaxChannelInviteListLimit } rows, err := s.db.Query(ctx, ` SELECT channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, COALESCE(expire_date, 0), COALESCE(usage_limit, 0), usage_count, requested_count, EXTRACT(EPOCH FROM created_at)::int FROM channel_invites WHERE channel_id = $1 AND admin_user_id = $2 AND revoked = $3 AND (($4::int = 0 AND $5::text = '') OR (EXTRACT(EPOCH FROM created_at)::int, hash) < ($4, $5)) ORDER BY EXTRACT(EPOCH FROM created_at)::int DESC, hash DESC LIMIT $6`, req.ChannelID, req.AdminUserID, req.Revoked, req.OffsetDate, req.OffsetHash, limit) if err != nil { return domain.ChannelInviteList{}, err } defer rows.Close() invites := make([]domain.ChannelInvite, 0, limit) for rows.Next() { invite, err := scanChannelInvite(rows) if err != nil { return domain.ChannelInviteList{}, err } invites = append(invites, invite) } if err := rows.Err(); err != nil { return domain.ChannelInviteList{}, err } return domain.ChannelInviteList{Count: total, Invites: invites}, nil } func (s *ChannelStore) GetExportedInvite(ctx context.Context, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error) { if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { return domain.ChannelInvite{}, domain.ErrInviteHashEmpty } _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ChannelInvite{}, err } if !canExportChannelInvite(member) { return domain.ChannelInvite{}, domain.ErrChannelAdminRequired } return s.getInviteByChannelHash(ctx, s.db, req.ChannelID, req.Hash, false) } func (s *ChannelStore) EditExportedInvite(ctx context.Context, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error) { if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { return domain.EditChannelInviteResult{}, domain.ErrInviteHashEmpty } if req.Date == 0 { req.Date = nowUnix() } beginner, ok := s.db.(txBeginner) if !ok { return domain.EditChannelInviteResult{}, fmt.Errorf("edit channel invite: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.EditChannelInviteResult{}, fmt.Errorf("begin edit channel invite: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() if _, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID); err != nil { return domain.EditChannelInviteResult{}, err } else if !canExportChannelInvite(member) { return domain.EditChannelInviteResult{}, domain.ErrChannelAdminRequired } invite, err := s.getInviteByChannelHash(ctx, tx, req.ChannelID, req.Hash, true) if err != nil { return domain.EditChannelInviteResult{}, err } if req.Revoked { if invite.Revoked { return domain.EditChannelInviteResult{}, domain.ErrInviteRevokedMissing } if _, err := tx.Exec(ctx, `UPDATE channel_invites SET revoked = true, updated_at = now() WHERE channel_id = $1 AND invite_id = $2`, invite.ChannelID, invite.InviteID); err != nil { return domain.EditChannelInviteResult{}, fmt.Errorf("revoke channel invite: %w", err) } invite.Revoked = true result := domain.EditChannelInviteResult{Invite: invite} if invite.Permanent { newInvite, err := s.newPostgresReplacementInvite(invite, req.Date) if err != nil { return domain.EditChannelInviteResult{}, err } if err := insertChannelInviteTx(ctx, tx, newInvite); err != nil { return domain.EditChannelInviteResult{}, err } result.NewInvite = &newInvite } if err := tx.Commit(ctx); err != nil { return domain.EditChannelInviteResult{}, fmt.Errorf("commit edit channel invite: %w", err) } committed = true return result, nil } if invite.Revoked { return domain.EditChannelInviteResult{}, domain.ErrInviteRevokedMissing } if invite.Permanent && ((req.HasExpireDate && req.ExpireDate > 0) || (req.HasUsageLimit && req.UsageLimit > 0) || (req.HasRequestNeeded && req.RequestNeeded)) { return domain.EditChannelInviteResult{}, domain.ErrInvitePermanent } if req.HasExpireDate { invite.ExpireDate = req.ExpireDate } if req.HasUsageLimit { invite.UsageLimit = req.UsageLimit } if req.HasRequestNeeded { invite.RequestNeeded = req.RequestNeeded } if req.HasTitle { invite.Title = req.Title } invite.Permanent = invite.ExpireDate == 0 && invite.UsageLimit == 0 && !invite.RequestNeeded && invite.Title == "" if _, err := tx.Exec(ctx, ` UPDATE channel_invites SET title = $3, expire_date = NULLIF($4, 0), usage_limit = NULLIF($5, 0), request_needed = $6, permanent = $7, updated_at = now() WHERE channel_id = $1 AND invite_id = $2`, invite.ChannelID, invite.InviteID, invite.Title, invite.ExpireDate, invite.UsageLimit, invite.RequestNeeded, invite.Permanent); err != nil { return domain.EditChannelInviteResult{}, fmt.Errorf("update channel invite: %w", err) } if err := tx.Commit(ctx); err != nil { return domain.EditChannelInviteResult{}, fmt.Errorf("commit edit channel invite: %w", err) } committed = true return domain.EditChannelInviteResult{Invite: invite}, nil } func (s *ChannelStore) DeleteExportedInvite(ctx context.Context, req domain.DeleteChannelInviteRequest) error { if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { return domain.ErrInviteHashEmpty } if _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID); err != nil { return err } else if !canExportChannelInvite(member) { return domain.ErrChannelAdminRequired } tag, err := s.db.Exec(ctx, ` WITH deleted AS ( DELETE FROM channel_invites WHERE channel_id = $1 AND hash = $2 RETURNING hash ) DELETE FROM channel_invite_hashes h USING deleted d WHERE h.hash = d.hash`, req.ChannelID, strings.TrimSpace(req.Hash)) if err != nil { return fmt.Errorf("delete channel invite: %w", err) } if tag.RowsAffected() == 0 { return domain.ErrInviteRevokedMissing } return nil } func (s *ChannelStore) DeleteRevokedExportedInvites(ctx context.Context, req domain.DeleteRevokedChannelInvitesRequest) error { if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 { return domain.ErrChannelInvalid } if _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID); err != nil { return err } else if !canExportChannelInvite(member) { return domain.ErrChannelAdminRequired } limit := req.Limit if limit <= 0 || limit > domain.MaxChannelHideJoinRequests { limit = domain.MaxChannelHideJoinRequests } if _, err := s.db.Exec(ctx, ` WITH deleted AS ( DELETE FROM channel_invites WHERE ctid IN ( SELECT ctid FROM channel_invites WHERE channel_id = $1 AND admin_user_id = $2 AND revoked ORDER BY updated_at ASC LIMIT $3 ) RETURNING hash ) DELETE FROM channel_invite_hashes h USING deleted d WHERE h.hash = d.hash`, req.ChannelID, req.AdminUserID, limit); err != nil { return fmt.Errorf("delete revoked channel invites: %w", err) } return nil } func (s *ChannelStore) ListAdminsWithInvites(ctx context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error) { if userID == 0 || channelID == 0 { return nil, domain.ErrChannelInvalid } if _, member, err := s.getChannelForMember(ctx, s.db, userID, channelID); err != nil { return nil, err } else if !canExportChannelInvite(member) { return nil, domain.ErrChannelAdminRequired } rows, err := s.db.Query(ctx, ` SELECT admin_user_id, COUNT(*) FILTER (WHERE NOT revoked)::int, COUNT(*) FILTER (WHERE revoked)::int FROM channel_invites WHERE channel_id = $1 GROUP BY admin_user_id ORDER BY admin_user_id ASC`, channelID) if err != nil { return nil, err } defer rows.Close() out := make([]domain.ChannelAdminInviteCount, 0) for rows.Next() { var count domain.ChannelAdminInviteCount if err := rows.Scan(&count.AdminUserID, &count.InvitesCount, &count.RevokedInvitesCount); err != nil { return nil, err } out = append(out, count) } return out, rows.Err() } func (s *ChannelStore) ListInviteImporters(ctx context.Context, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid } if _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID); err != nil { return domain.ChannelInviteImporterList{}, err } else if !canExportChannelInvite(member) { return domain.ChannelInviteImporterList{}, domain.ErrChannelAdminRequired } var inviteID int64 if req.Hash != "" { invite, err := s.getInviteByChannelHash(ctx, s.db, req.ChannelID, req.Hash, false) if err != nil { return domain.ChannelInviteImporterList{}, err } inviteID = invite.InviteID } limit := req.Limit if limit <= 0 || limit > domain.MaxChannelInviteListLimit { limit = domain.MaxChannelInviteListLimit } args := []any{req.ChannelID, req.Requested, inviteID, req.Query, req.OffsetDate, req.OffsetUserID, limit} where := []string{ "i.channel_id = $1", "i.requested = $2", "($3::bigint = 0 OR i.invite_id = $3)", "($4::text = '' OR lower(trim(u.username || ' ' || u.first_name || ' ' || u.last_name)) LIKE '%' || lower($4) || '%')", "(($5::int = 0 AND $6::bigint = 0) OR (i.date, i.user_id) < ($5, $6))", } whereSQL := strings.Join(where, " AND ") var total int if err := s.db.QueryRow(ctx, ` SELECT COUNT(*)::int FROM channel_invite_importers i JOIN users u ON u.id = i.user_id WHERE `+whereSQL, args[:6]...).Scan(&total); err != nil { return domain.ChannelInviteImporterList{}, err } rows, err := s.db.Query(ctx, ` SELECT i.channel_id, i.invite_id, i.user_id, i.date, i.requested, i.approved_by, i.via_chatlist, i.about FROM channel_invite_importers i JOIN users u ON u.id = i.user_id WHERE `+whereSQL+` ORDER BY i.date DESC, i.user_id DESC LIMIT $7`, args...) if err != nil { return domain.ChannelInviteImporterList{}, err } defer rows.Close() importers := make([]domain.ChannelInviteImporter, 0, limit) for rows.Next() { var importer domain.ChannelInviteImporter if err := rows.Scan(&importer.ChannelID, &importer.InviteID, &importer.UserID, &importer.Date, &importer.Requested, &importer.ApprovedBy, &importer.ViaChatlist, &importer.About); err != nil { return domain.ChannelInviteImporterList{}, err } importers = append(importers, importer) } if err := rows.Err(); err != nil { return domain.ChannelInviteImporterList{}, err } return domain.ChannelInviteImporterList{Count: total, Importers: importers}, nil } func (s *ChannelStore) PendingJoinRequests(ctx context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error) { if channelID == 0 { return domain.ChannelPendingJoinRequests{}, domain.ErrChannelInvalid } if limit <= 0 || limit > domain.MaxChannelPendingJoinRecentRequesters { limit = domain.MaxChannelPendingJoinRecentRequesters } rows, err := s.db.Query(ctx, ` SELECT user_id, COUNT(*) OVER()::int FROM channel_invite_importers WHERE channel_id = $1 AND requested ORDER BY date DESC, user_id DESC LIMIT $2`, channelID, limit) if err != nil { return domain.ChannelPendingJoinRequests{}, fmt.Errorf("list pending channel join requests: %w", err) } defer rows.Close() out := domain.ChannelPendingJoinRequests{ ChannelID: channelID, RecentRequesters: make([]int64, 0, limit), } for rows.Next() { var userID int64 var count int if err := rows.Scan(&userID, &count); err != nil { return domain.ChannelPendingJoinRequests{}, err } out.Count = count out.RecentRequesters = append(out.RecentRequesters, userID) } if err := rows.Err(); err != nil { return domain.ChannelPendingJoinRequests{}, err } return out, nil } func (s *ChannelStore) HideChatJoinRequest(ctx context.Context, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.TargetUserID == 0 { return domain.CreateChannelResult{}, domain.ErrChannelInvalid } if req.Date == 0 { req.Date = nowUnix() } beginner, ok := s.db.(txBeginner) if !ok { return domain.CreateChannelResult{}, fmt.Errorf("hide channel join request: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.CreateChannelResult{}, fmt.Errorf("begin hide channel join request: %w", err) } committed := false var reserved []reservedChannelPts defer func() { if !committed { _ = tx.Rollback(ctx) s.recordChannelPtsGaps(ctx, reserved, req.Date) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.CreateChannelResult{}, err } if !canExportChannelInvite(member) { return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired } importer, err := s.getPendingInviteImporterTx(ctx, tx, req.ChannelID, req.TargetUserID, true) if err != nil { return domain.CreateChannelResult{}, err } invite := domain.ChannelInvite{ChannelID: req.ChannelID, AdminUserID: req.UserID} if importer.InviteID != 0 { invite, err = s.getInviteByID(ctx, tx, req.ChannelID, importer.InviteID, true) if err != nil { return domain.CreateChannelResult{}, err } } var result domain.CreateChannelResult if req.Approved { result, err = s.approveInviteImporterTx(ctx, tx, channel, invite, req.TargetUserID, req.UserID, req.Date, &reserved) if err != nil { return domain.CreateChannelResult{}, err } } else if err := deletePendingInviteImporterTx(ctx, tx, invite, req.TargetUserID); err != nil { return domain.CreateChannelResult{}, err } else { result = domain.CreateChannelResult{Channel: channel} } if err := tx.Commit(ctx); err != nil { return domain.CreateChannelResult{}, fmt.Errorf("commit hide channel join request: %w", err) } committed = true recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) result.Recipients = recipients return result, nil } func (s *ChannelStore) HideAllChatJoinRequests(ctx context.Context, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.CreateChannelResult{}, domain.ErrChannelInvalid } if req.Date == 0 { req.Date = nowUnix() } var inviteID int64 if req.Hash != "" { invite, err := s.GetExportedInvite(ctx, domain.GetChannelInviteRequest{UserID: req.UserID, ChannelID: req.ChannelID, Hash: req.Hash}) if err != nil { return domain.CreateChannelResult{}, err } inviteID = invite.InviteID } limit := req.Limit if limit <= 0 || limit > domain.MaxChannelHideJoinRequests { limit = domain.MaxChannelHideJoinRequests } rows, err := s.db.Query(ctx, ` SELECT user_id FROM channel_invite_importers WHERE channel_id = $1 AND requested AND ($2::bigint = 0 OR invite_id = $2) ORDER BY date ASC, user_id ASC LIMIT $3`, req.ChannelID, inviteID, limit) if err != nil { return domain.CreateChannelResult{}, err } targets := make([]int64, 0, limit) for rows.Next() { var userID int64 if err := rows.Scan(&userID); err != nil { rows.Close() return domain.CreateChannelResult{}, err } targets = append(targets, userID) } if err := rows.Err(); err != nil { rows.Close() return domain.CreateChannelResult{}, err } rows.Close() var result domain.CreateChannelResult for _, target := range targets { next, err := s.HideChatJoinRequest(ctx, domain.HideChannelJoinRequestRequest{ UserID: req.UserID, ChannelID: req.ChannelID, TargetUserID: target, Approved: req.Approved, Date: req.Date, }) if err != nil { return domain.CreateChannelResult{}, err } result = next } if result.Channel.ID == 0 { ch, _, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.CreateChannelResult{}, err } result.Channel = ch recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) result.Recipients = recipients } return result, nil } func channelDialogDynamicUnreadCountSQL(readInboxExpr, topIDExpr string) string { return fmt.Sprintf(`( SELECT COUNT(*)::int FROM channel_messages cm_unread WHERE cm_unread.channel_id = c.id AND cm_unread.id > GREATEST(%s, m.available_min_id) AND cm_unread.id <= %s AND NOT cm_unread.deleted AND cm_unread.sender_user_id <> m.user_id )`, readInboxExpr, topIDExpr) } func channelDialogDynamicUnreadExistsSQL(readInboxExpr, topIDExpr string) string { return fmt.Sprintf(`EXISTS ( SELECT 1 FROM channel_messages cm_unread WHERE cm_unread.channel_id = c.id AND cm_unread.id > GREATEST(%s, m.available_min_id) AND cm_unread.id <= %s AND NOT cm_unread.deleted AND cm_unread.sender_user_id <> m.user_id )`, readInboxExpr, topIDExpr) } func channelDialogVisibleUnreadCountSQL(readInboxExpr, topIDExpr string) string { dynamicCount := channelDialogDynamicUnreadCountSQL(readInboxExpr, topIDExpr) return fmt.Sprintf(`CASE WHEN c.broadcast OR c.participants_count > %d THEN %s ELSE COALESCE(d.unread_count, %s) END`, domain.MaxSynchronousChannelDialogFanout, dynamicCount, dynamicCount) } func channelDialogHasUnreadSQL(readInboxExpr, topIDExpr string) string { dynamicUnread := channelDialogDynamicUnreadExistsSQL(readInboxExpr, topIDExpr) return fmt.Sprintf(`CASE WHEN c.broadcast OR c.participants_count > %d THEN %s ELSE COALESCE(d.unread_count > 0, %s) END`, domain.MaxSynchronousChannelDialogFanout, dynamicUnread, dynamicUnread) } func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) { if viewerUserID == 0 { return domain.ChannelDialogList{}, nil } limit := filter.Limit if limit <= 0 || limit > 100 { limit = 100 } visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE 0 END" visibleReadInbox := "COALESCE(d.read_inbox_max_id, m.read_inbox_max_id)" visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) args := []any{viewerUserID} where := []string{"m.user_id = $1", "m.status = 'active'"} if filter.HasFolderID && filter.FolderID < domain.DialogCustomFolderMinID { args = append(args, filter.FolderID) where = append(where, fmt.Sprintf("COALESCE(d.folder_id, 0) = $%d", len(args))) } if filter.PinnedOnly { where = append(where, "COALESCE(d.pinned, false)") } if filter.ExcludePinned { where = append(where, "NOT COALESCE(d.pinned, false)") } switch { case filter.OffsetDate > 0: args = append(args, filter.OffsetDate, filter.OffsetID) dateArg := fmt.Sprintf("$%d", len(args)-1) idArg := fmt.Sprintf("$%d", len(args)) if filter.HasOffsetPeer && filter.OffsetPeer.Type == domain.PeerTypeChannel && filter.OffsetPeer.ID > 0 { args = append(args, filter.OffsetPeer.ID) peerArg := fmt.Sprintf("$%d", len(args)) where = append(where, fmt.Sprintf("(%s < %s OR (%s = %s AND %s < %s) OR (%s = %s AND %s = %s AND c.id < %s))", visibleTopDate, dateArg, visibleTopDate, dateArg, visibleTopID, idArg, visibleTopDate, dateArg, visibleTopID, idArg, peerArg)) } else { where = append(where, fmt.Sprintf("(%s < %s OR (%s = %s AND %s < %s))", visibleTopDate, dateArg, visibleTopDate, dateArg, visibleTopID, idArg)) } case filter.OffsetID > 0: args = append(args, filter.OffsetID) idArg := fmt.Sprintf("$%d", len(args)) if filter.HasOffsetPeer && filter.OffsetPeer.Type == domain.PeerTypeChannel && filter.OffsetPeer.ID > 0 { args = append(args, filter.OffsetPeer.ID) peerArg := fmt.Sprintf("$%d", len(args)) where = append(where, fmt.Sprintf("(%s < %s OR (%s = %s AND c.id < %s))", visibleTopID, idArg, visibleTopID, idArg, peerArg)) } else { where = append(where, fmt.Sprintf("%s < %s", visibleTopID, idArg)) } case filter.HasOffsetPeer && filter.OffsetPeer.Type == domain.PeerTypeChannel && filter.OffsetPeer.ID > 0: args = append(args, filter.OffsetPeer.ID) where = append(where, fmt.Sprintf("c.id <> $%d", len(args))) } if filter.Folder != nil { folder := filter.Folder if folder.ExcludeArchived { where = append(where, fmt.Sprintf("COALESCE(d.folder_id, 0) <> %d", domain.DialogArchiveFolderID)) } if folder.ExcludeRead { where = append(where, fmt.Sprintf(`(COALESCE(d.unread_mark, m.unread_mark) OR %s)`, channelDialogHasUnreadSQL(visibleReadInbox, visibleTopID))) } if excludeIDs := channelFolderPeerIDs(folder.ExcludePeers); len(excludeIDs) > 0 { args = append(args, excludeIDs) where = append(where, fmt.Sprintf("NOT (c.id = ANY($%d::bigint[]))", len(args))) } includeIDs := channelFolderPeerIDs(folder.IncludePeers, folder.PinnedPeers) include := make([]string, 0, 3) if len(includeIDs) > 0 { args = append(args, includeIDs) include = append(include, fmt.Sprintf("c.id = ANY($%d::bigint[])", len(args))) } if folder.Groups { include = append(include, "c.megagroup") } if folder.Broadcasts { include = append(include, "c.broadcast") } if len(include) > 0 { where = append(where, "("+strings.Join(include, " OR ")+")") } } args = append(args, channelDialogQueryLimit) limitArg := fmt.Sprintf("$%d", len(args)) rows, err := s.db.Query(ctx, ` SELECT `+channelColumns+`, `+visibleTopID+`, `+visibleTopDate+`, COALESCE(d.folder_id, 0), `+visibleReadInbox+`, COALESCE(d.read_outbox_max_id, m.read_outbox_max_id), `+visibleUnreadCount+`, COALESCE(d.pinned, false), COALESCE(d.pinned_order, 0), COALESCE(d.unread_mark, m.unread_mark), COALESCE(d.unread_mentions_count, 0), COALESCE(d.unread_reactions_count, 0), COALESCE(d.view_forum_as_messages, false) FROM channel_members m JOIN channels c ON c.id = m.channel_id AND NOT c.deleted LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id WHERE `+strings.Join(where, " AND ")+` ORDER BY COALESCE(d.pinned, false) DESC, COALESCE(d.pinned_order, 0) DESC, `+visibleTopDate+` DESC, `+visibleTopID+` DESC, c.id DESC LIMIT `+limitArg, args...) if err != nil { return domain.ChannelDialogList{}, fmt.Errorf("list channel dialogs: %w", err) } defer rows.Close() type item struct { channel domain.Channel dialog domain.Dialog } items := make([]item, 0, limit) for rows.Next() { ch, dialog, err := scanChannelDialogRow(rows, viewerUserID) if err != nil { return domain.ChannelDialogList{}, err } if !channelDialogMatchesFilter(dialog, ch, filter) { continue } items = append(items, item{channel: ch, dialog: dialog}) } if err := rows.Err(); err != nil { return domain.ChannelDialogList{}, err } sort.SliceStable(items, func(i, j int) bool { if items[i].dialog.Pinned != items[j].dialog.Pinned { return items[i].dialog.Pinned } if items[i].dialog.PinnedOrder != items[j].dialog.PinnedOrder { return items[i].dialog.PinnedOrder > items[j].dialog.PinnedOrder } if items[i].dialog.TopMessageDate != items[j].dialog.TopMessageDate { return items[i].dialog.TopMessageDate > items[j].dialog.TopMessageDate } if items[i].dialog.TopMessage != items[j].dialog.TopMessage { return items[i].dialog.TopMessage > items[j].dialog.TopMessage } return items[i].dialog.Peer.ID > items[j].dialog.Peer.ID }) out := domain.ChannelDialogList{Count: len(items)} if len(items) > limit { items = items[:limit] } for _, item := range items { msg, _ := s.getChannelMessage(ctx, s.db, item.channel.ID, item.dialog.TopMessage) if msg.ID != 0 { item.dialog.TopMessageDate = msg.Date out.Messages = append(out.Messages, msg) } out.Dialogs = append(out.Dialogs, item.dialog) out.Channels = append(out.Channels, item.channel) } return out, nil } func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) { out := domain.ChannelDialogList{} seen := make(map[int64]struct{}, len(channelIDs)) for _, channelID := range channelIDs { if channelID == 0 { continue } if _, ok := seen[channelID]; ok { continue } seen[channelID] = struct{}{} channel, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) if err != nil { if errors.Is(err, domain.ErrChannelInvalid) || errors.Is(err, domain.ErrChannelPrivate) { continue } return domain.ChannelDialogList{}, err } dialog, err := s.getChannelDialog(ctx, s.db, viewerUserID, channel) if err != nil { return domain.ChannelDialogList{}, err } msg, _ := s.getChannelMessage(ctx, s.db, channelID, dialog.TopMessageID) if msg.ID != 0 { dialog.TopMessageDate = msg.Date out.Messages = append(out.Messages, msg) } out.Dialogs = append(out.Dialogs, channelDialogToDialog(dialog)) out.Channels = append(out.Channels, channel) } out.Count = len(out.Dialogs) return out, nil } func (s *ChannelStore) ListCommonChannels(ctx context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) { if req.UserID == 0 || req.TargetUserID == 0 || req.UserID == req.TargetUserID || req.MaxID < 0 { return domain.CommonChannelsResult{}, domain.ErrChannelInvalid } limit := req.Limit if limit <= 0 || limit > domain.MaxCommonChannelsLimit { limit = domain.MaxCommonChannelsLimit } var count int if err := s.db.QueryRow(ctx, ` SELECT COUNT(*)::int FROM user_channel_member_index selfm JOIN user_channel_member_index targetm ON targetm.channel_id = selfm.channel_id WHERE selfm.user_id = $1 AND targetm.user_id = $2 AND selfm.status = 'active' AND targetm.status = 'active' AND selfm.megagroup AND NOT selfm.broadcast AND NOT selfm.deleted AND targetm.megagroup AND NOT targetm.broadcast AND NOT targetm.deleted`, req.UserID, req.TargetUserID).Scan(&count); err != nil { return domain.CommonChannelsResult{}, fmt.Errorf("count common channels: %w", err) } out := domain.CommonChannelsResult{Count: count} if req.CountOnly { return out, nil } rows, err := s.db.Query(ctx, ` SELECT selfm.channel_id FROM user_channel_member_index selfm JOIN user_channel_member_index targetm ON targetm.channel_id = selfm.channel_id WHERE selfm.user_id = $1 AND targetm.user_id = $2 AND selfm.status = 'active' AND targetm.status = 'active' AND selfm.megagroup AND NOT selfm.broadcast AND NOT selfm.deleted AND targetm.megagroup AND NOT targetm.broadcast AND NOT targetm.deleted AND ($3::bigint = 0 OR selfm.channel_id > $3) ORDER BY selfm.channel_id ASC LIMIT $4`, req.UserID, req.TargetUserID, req.MaxID, limit) if err != nil { return domain.CommonChannelsResult{}, fmt.Errorf("list common channels: %w", err) } defer rows.Close() ids := make([]int64, 0, limit) for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return domain.CommonChannelsResult{}, err } ids = append(ids, id) } if err := rows.Err(); err != nil { return domain.CommonChannelsResult{}, err } channels, err := listChannelsByIDs(ctx, s.db, ids) if err != nil { return domain.CommonChannelsResult{}, err } out.Channels = channels return out, nil } func (s *ChannelStore) ListLeftChannels(ctx context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) { if userID == 0 || offset < 0 || offset > domain.MaxLeftChannelsOffset { return domain.LeftChannelsResult{}, domain.ErrChannelInvalid } if limit <= 0 || limit > domain.MaxLeftChannelsLimit { limit = domain.MaxLeftChannelsLimit } var count int if err := s.db.QueryRow(ctx, ` SELECT COUNT(*)::int FROM channel_members m JOIN channels c ON c.id = m.channel_id WHERE m.user_id = $1 AND m.status = 'left' AND (c.broadcast OR c.megagroup) AND NOT c.deleted`, userID).Scan(&count); err != nil { return domain.LeftChannelsResult{}, fmt.Errorf("count left channels: %w", err) } rows, err := s.db.Query(ctx, ` SELECT `+channelColumns+`, 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.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date FROM channel_members m JOIN channels c ON c.id = m.channel_id WHERE m.user_id = $1 AND m.status = 'left' AND (c.broadcast OR c.megagroup) AND NOT c.deleted ORDER BY m.left_at DESC, c.id DESC OFFSET $2 LIMIT $3`, userID, offset, limit) if err != nil { return domain.LeftChannelsResult{}, fmt.Errorf("list left channels: %w", err) } defer rows.Close() out := domain.LeftChannelsResult{Count: count, Channels: make([]domain.LeftChannel, 0, limit)} for rows.Next() { ch, member, err := scanChannelWithMember(rows) if err != nil { return domain.LeftChannelsResult{}, err } out.Channels = append(out.Channels, domain.LeftChannel{Channel: ch, Self: member}) } return out, rows.Err() } func (s *ChannelStore) ListInactiveChannels(ctx context.Context, userID int64, limit int) (domain.ChannelDialogList, error) { if userID == 0 { return domain.ChannelDialogList{}, domain.ErrChannelInvalid } if limit <= 0 || limit > domain.MaxInactiveChannelsLimit { limit = domain.MaxInactiveChannelsLimit } visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE GREATEST(c.date, m.joined_at) END" visibleReadInbox := "COALESCE(d.read_inbox_max_id, m.read_inbox_max_id)" visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) rows, err := s.db.Query(ctx, ` SELECT `+channelColumns+`, `+visibleTopID+`, `+visibleTopDate+`, COALESCE(d.folder_id, 0), `+visibleReadInbox+`, COALESCE(d.read_outbox_max_id, m.read_outbox_max_id), `+visibleUnreadCount+`, COALESCE(d.pinned, false), COALESCE(d.pinned_order, 0), COALESCE(d.unread_mark, m.unread_mark), COALESCE(d.unread_mentions_count, 0), COALESCE(d.unread_reactions_count, 0), COALESCE(d.view_forum_as_messages, false) FROM channel_members m JOIN channels c ON c.id = m.channel_id AND NOT c.deleted LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id WHERE m.user_id = $1 AND m.status = 'active' AND (c.broadcast OR c.megagroup) ORDER BY `+visibleTopDate+` ASC, `+visibleTopID+` ASC, c.id ASC LIMIT $2`, userID, limit) if err != nil { return domain.ChannelDialogList{}, fmt.Errorf("list inactive channels: %w", err) } defer rows.Close() out := domain.ChannelDialogList{Dialogs: make([]domain.Dialog, 0, limit), Channels: make([]domain.Channel, 0, limit)} for rows.Next() { ch, dialog, err := scanChannelDialogRow(rows, userID) if err != nil { return domain.ChannelDialogList{}, err } out.Dialogs = append(out.Dialogs, dialog) out.Channels = append(out.Channels, ch) } if err := rows.Err(); err != nil { return domain.ChannelDialogList{}, err } out.Count = len(out.Dialogs) return out, nil } func (s *ChannelStore) ListChannelRecommendations(ctx context.Context, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) { if req.UserID == 0 || req.SourceChannelID < 0 { return domain.ChannelRecommendationsResult{}, domain.ErrChannelInvalid } limit := req.Limit if limit <= 0 || limit > domain.MaxChannelRecommendationsLimit { limit = domain.DefaultChannelRecommendationsLimit } args := []any{req.UserID, req.SourceChannelID} where := []string{ "($1::bigint <> 0)", "c.broadcast", "NOT c.megagroup", "NOT c.deleted", "COALESCE(c.username, '') <> ''", "($2::bigint = 0 OR c.id <> $2)", } if req.SourceChannelID == 0 { where = append(where, `NOT EXISTS ( SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.user_id = $1 AND m.status = 'active' )`) } whereSQL := strings.Join(where, " AND ") var count int if err := s.db.QueryRow(ctx, ` SELECT COUNT(*)::int FROM channels c WHERE `+whereSQL, args...).Scan(&count); err != nil { return domain.ChannelRecommendationsResult{}, fmt.Errorf("count channel recommendations: %w", err) } args = append(args, limit) rows, err := s.db.Query(ctx, ` SELECT `+channelColumns+` FROM channels c WHERE `+whereSQL+` ORDER BY c.participants_count DESC, c.date DESC, c.id DESC LIMIT $3`, args...) if err != nil { return domain.ChannelRecommendationsResult{}, fmt.Errorf("list channel recommendations: %w", err) } defer rows.Close() out := domain.ChannelRecommendationsResult{Count: count, Channels: make([]domain.Channel, 0, limit)} for rows.Next() { ch, err := scanChannel(rows) if err != nil { return domain.ChannelRecommendationsResult{}, err } out.Channels = append(out.Channels, ch) } if err := rows.Err(); err != nil { return domain.ChannelRecommendationsResult{}, err } return out, nil } func (s *ChannelStore) ListDiscussionGroups(ctx context.Context, userID int64, limit int) ([]domain.Channel, error) { if userID == 0 { return nil, domain.ErrChannelInvalid } if limit <= 0 || limit > domain.MaxDiscussionGroupsLimit { limit = domain.MaxDiscussionGroupsLimit } rows, err := s.db.Query(ctx, ` SELECT `+channelColumns+` FROM channel_members m JOIN channels c ON c.id = m.channel_id WHERE m.user_id = $1 AND m.status = 'active' AND c.megagroup AND NOT c.broadcast AND NOT c.forum AND NOT c.deleted AND ( m.role = 'creator' OR (m.role = 'admin' AND COALESCE((m.admin_rights->>'PinMessages')::boolean, false)) ) ORDER BY c.id DESC LIMIT $2`, userID, limit) if err != nil { return nil, fmt.Errorf("list discussion groups: %w", err) } defer rows.Close() out := make([]domain.Channel, 0, limit) for rows.Next() { ch, err := scanChannel(rows) if err != nil { return nil, err } out = append(out, ch) } return out, rows.Err() } func (s *ChannelStore) SetDiscussionGroup(ctx context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) { if userID == 0 { return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelInvalid } if broadcastID == 0 && groupID == 0 { return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified } beginner, ok := s.db.(txBeginner) if !ok { return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("set discussion group: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("begin set discussion group: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() changed := make(map[int64]domain.Channel) markChanged := func(channel domain.Channel) { if channel.ID != 0 { changed[channel.ID] = channel } } setLinked := func(channel domain.Channel, linkedID int64) (domain.Channel, error) { if channel.LinkedChatID == linkedID { return channel, nil } if _, err := tx.Exec(ctx, `UPDATE channels SET linked_chat_id = $2, updated_at = now() WHERE id = $1`, channel.ID, linkedID); err != nil { return domain.Channel{}, fmt.Errorf("update linked chat: %w", err) } channel.LinkedChatID = linkedID markChanged(channel) return channel, nil } logLinkChange := func(channelID, prev, next int64) error { if prev == next || channelID == 0 { return nil } return s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: channelID, UserID: userID, Date: nowUnix(), Type: domain.ChannelAdminLogChangeLinkedChat, PrevInt: int(prev), NewInt: int(next), }) } if broadcastID == 0 { group, groupMember, err := s.getChannelForMember(ctx, tx, userID, groupID) if err != nil || !validDiscussionGroup(group) { return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid } if !canManageDiscussionGroup(groupMember) { return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired } oldBroadcastID := group.LinkedChatID if oldBroadcastID == 0 { return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified } oldBroadcast, err := getChannelByID(ctx, tx, oldBroadcastID) if err == nil && oldBroadcast.LinkedChatID == groupID { updated, err := setLinked(oldBroadcast, 0) if err != nil { return domain.DiscussionGroupUpdateResult{}, err } if err := logLinkChange(updated.ID, groupID, 0); err != nil { return domain.DiscussionGroupUpdateResult{}, err } } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { return domain.DiscussionGroupUpdateResult{}, err } if _, err := setLinked(group, 0); err != nil { return domain.DiscussionGroupUpdateResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("commit set discussion group: %w", err) } committed = true return discussionGroupUpdateResult(changed), nil } broadcast, broadcastMember, err := s.getChannelForMember(ctx, tx, userID, broadcastID) if err != nil || !broadcast.Broadcast || broadcast.Megagroup { return domain.DiscussionGroupUpdateResult{}, domain.ErrBroadcastIDInvalid } if !canManageDiscussionBroadcast(broadcastMember) { return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired } oldGroupID := broadcast.LinkedChatID if groupID == 0 { if oldGroupID == 0 { return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified } updated, err := setLinked(broadcast, 0) if err != nil { return domain.DiscussionGroupUpdateResult{}, err } if err := logLinkChange(updated.ID, oldGroupID, 0); err != nil { return domain.DiscussionGroupUpdateResult{}, err } oldGroup, err := getChannelByID(ctx, tx, oldGroupID) if err == nil && oldGroup.LinkedChatID == broadcastID { if _, err := setLinked(oldGroup, 0); err != nil { return domain.DiscussionGroupUpdateResult{}, err } } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { return domain.DiscussionGroupUpdateResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("commit set discussion group: %w", err) } committed = true return discussionGroupUpdateResult(changed), nil } group, groupMember, err := s.getChannelForMember(ctx, tx, userID, groupID) if err != nil || !validDiscussionGroup(group) { return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid } if group.PreHistoryHidden { return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupPrehistoryHidden } if !canManageDiscussionGroup(groupMember) { return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired } if oldGroupID == groupID && group.LinkedChatID == broadcastID { return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified } oldBroadcastID := group.LinkedChatID if oldGroupID != 0 && oldGroupID != groupID { oldGroup, err := getChannelByID(ctx, tx, oldGroupID) if err == nil && oldGroup.LinkedChatID == broadcastID { if _, err := setLinked(oldGroup, 0); err != nil { return domain.DiscussionGroupUpdateResult{}, err } } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { return domain.DiscussionGroupUpdateResult{}, err } } if oldBroadcastID != 0 && oldBroadcastID != broadcastID { oldBroadcast, err := getChannelByID(ctx, tx, oldBroadcastID) if err == nil && oldBroadcast.LinkedChatID == groupID { updated, err := setLinked(oldBroadcast, 0) if err != nil { return domain.DiscussionGroupUpdateResult{}, err } if err := logLinkChange(updated.ID, groupID, 0); err != nil { return domain.DiscussionGroupUpdateResult{}, err } } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { return domain.DiscussionGroupUpdateResult{}, err } } updatedBroadcast, err := setLinked(broadcast, groupID) if err != nil { return domain.DiscussionGroupUpdateResult{}, err } if _, err := setLinked(group, broadcastID); err != nil { return domain.DiscussionGroupUpdateResult{}, err } if err := logLinkChange(updatedBroadcast.ID, oldGroupID, groupID); err != nil { return domain.DiscussionGroupUpdateResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("commit set discussion group: %w", err) } committed = true return discussionGroupUpdateResult(changed), nil } func (s *ChannelStore) SetChannelDialogPinned(ctx context.Context, userID, channelID int64, pinned bool) (bool, error) { if userID == 0 || channelID == 0 { return false, nil } var changed bool if err := s.db.QueryRow(ctx, ` WITH target AS ( SELECT c.id AS channel_id, c.top_message_id, c.date AS top_message_date FROM channels c JOIN channel_members m ON m.channel_id = c.id WHERE c.id = $2 AND m.user_id = $1 AND m.status = 'active' AND NOT c.deleted ), ensured AS ( INSERT INTO channel_dialogs (user_id, channel_id, top_message_id, top_message_date) SELECT $1, channel_id, top_message_id, top_message_date FROM target ON CONFLICT (user_id, channel_id) DO NOTHING ), next_order AS ( SELECT COALESCE(MAX(pinned_order), 0)::int + 1 AS value FROM channel_dialogs WHERE user_id = $1 AND pinned ), updated AS ( UPDATE channel_dialogs d SET pinned = $3, pinned_order = CASE WHEN $3::boolean THEN CASE WHEN d.pinned_order > 0 THEN d.pinned_order ELSE next_order.value END ELSE 0 END, updated_at = now() FROM next_order WHERE d.user_id = $1 AND d.channel_id = $2 AND EXISTS (SELECT 1 FROM target) AND (d.pinned IS DISTINCT FROM $3::boolean OR ($3::boolean AND d.pinned_order = 0)) RETURNING d.user_id ) SELECT EXISTS (SELECT 1 FROM updated)::boolean`, userID, channelID, pinned).Scan(&changed); err != nil { return false, fmt.Errorf("set channel dialog pinned: %w", err) } return changed, nil } func (s *ChannelStore) ReorderChannelPinnedDialogs(ctx context.Context, userID int64, order []domain.Peer, force bool) error { if userID == 0 { return nil } peerTypes, peerIDs := peerArrays(order) if force { if _, err := s.db.Exec(ctx, ` WITH requested AS ( SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS peer_id FROM generate_subscripts($3::bigint[], 1) AS g(i) WHERE i <= cardinality($2::text[]) ) UPDATE channel_dialogs d SET pinned = false, pinned_order = 0, updated_at = now() WHERE d.user_id = $1 AND d.pinned AND NOT EXISTS ( SELECT 1 FROM requested r WHERE r.peer_type = 'channel' AND r.peer_id = d.channel_id )`, userID, peerTypes, peerIDs); err != nil { return fmt.Errorf("clear channel pinned dialogs not in order: %w", err) } } if len(peerIDs) == 0 { return nil } if _, err := s.db.Exec(ctx, ` WITH requested AS ( SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS peer_id, i::int AS pos FROM generate_subscripts($3::bigint[], 1) AS g(i) WHERE i <= cardinality($2::text[]) ), deduped AS ( SELECT DISTINCT ON (peer_id) peer_id, (cardinality($3::bigint[]) - pos + 1)::int AS ord FROM requested WHERE peer_type = 'channel' ORDER BY peer_id, pos ) UPDATE channel_dialogs d SET pinned = true, pinned_order = deduped.ord, updated_at = now() FROM deduped WHERE d.user_id = $1 AND d.channel_id = deduped.peer_id`, userID, peerTypes, peerIDs); err != nil { return fmt.Errorf("reorder channel pinned dialogs: %w", err) } return nil } func (s *ChannelStore) SetChannelDialogUnreadMark(ctx context.Context, userID, channelID int64, unread bool) (bool, error) { if userID == 0 || channelID == 0 { return false, nil } var changed bool if err := s.db.QueryRow(ctx, ` WITH target AS ( SELECT c.id AS channel_id, c.top_message_id, c.date AS top_message_date FROM channels c JOIN channel_members m ON m.channel_id = c.id WHERE c.id = $2 AND m.user_id = $1 AND m.status = 'active' AND NOT c.deleted ), ensured AS ( INSERT INTO channel_dialogs (user_id, channel_id, top_message_id, top_message_date) SELECT $1, channel_id, top_message_id, top_message_date FROM target ON CONFLICT (user_id, channel_id) DO NOTHING ), updated_dialog AS ( UPDATE channel_dialogs d SET unread_mark = $3, updated_at = now() WHERE d.user_id = $1 AND d.channel_id = $2 AND EXISTS (SELECT 1 FROM target) AND d.unread_mark IS DISTINCT FROM $3::boolean RETURNING d.user_id ), updated_member AS ( UPDATE channel_members m SET unread_mark = $3 WHERE m.user_id = $1 AND m.channel_id = $2 AND m.status = 'active' RETURNING m.user_id ) SELECT EXISTS (SELECT 1 FROM updated_dialog)::boolean`, userID, channelID, unread).Scan(&changed); err != nil { return false, fmt.Errorf("set channel dialog unread mark: %w", err) } return changed, nil } func (s *ChannelStore) SetChannelViewForumAsMessages(ctx context.Context, userID, channelID int64, enabled bool) (bool, error) { if userID == 0 || channelID == 0 { return false, nil } var changed bool if err := s.db.QueryRow(ctx, ` WITH target AS ( SELECT c.id AS channel_id, c.top_message_id, c.date AS top_message_date FROM channels c JOIN channel_members m ON m.channel_id = c.id WHERE c.id = $2 AND m.user_id = $1 AND m.status = 'active' AND NOT c.deleted ), ensured AS ( INSERT INTO channel_dialogs (user_id, channel_id, top_message_id, top_message_date) SELECT $1, channel_id, top_message_id, top_message_date FROM target ON CONFLICT (user_id, channel_id) DO NOTHING ), updated_dialog AS ( UPDATE channel_dialogs d SET view_forum_as_messages = $3, updated_at = now() WHERE d.user_id = $1 AND d.channel_id = $2 AND EXISTS (SELECT 1 FROM target) AND d.view_forum_as_messages IS DISTINCT FROM $3::boolean RETURNING d.user_id ) SELECT EXISTS (SELECT 1 FROM updated_dialog)::boolean`, userID, channelID, enabled).Scan(&changed); err != nil { return false, fmt.Errorf("set channel view forum as messages: %w", err) } return changed, nil } func (s *ChannelStore) ListChannelUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error) { if userID == 0 { return nil, nil } rows, err := s.db.Query(ctx, ` SELECT d.channel_id FROM channel_dialogs d JOIN channel_members m ON m.channel_id = d.channel_id AND m.user_id = d.user_id AND m.status = 'active' JOIN channels c ON c.id = d.channel_id AND NOT c.deleted WHERE d.user_id = $1 AND d.unread_mark ORDER BY d.top_message_date DESC, d.top_message_id DESC, d.channel_id DESC LIMIT 500`, userID) if err != nil { return nil, fmt.Errorf("list channel unread marks: %w", err) } defer rows.Close() out := make([]domain.Peer, 0) for rows.Next() { var channelID int64 if err := rows.Scan(&channelID); err != nil { return nil, err } out = append(out, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) } if err := rows.Err(); err != nil { return nil, err } return out, nil } func (s *ChannelStore) EditChannelPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error { if userID == 0 || len(peers) == 0 { return nil } peerTypes := make([]string, 0, len(peers)) peerIDs := make([]int64, 0, len(peers)) folderIDs := make([]int32, 0, len(peers)) seen := make(map[int64]struct{}, len(peers)) for _, item := range peers { if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 { continue } if item.FolderID != domain.DialogMainFolderID && item.FolderID != domain.DialogArchiveFolderID { continue } if _, ok := seen[item.Peer.ID]; ok { continue } seen[item.Peer.ID] = struct{}{} peerTypes = append(peerTypes, string(item.Peer.Type)) peerIDs = append(peerIDs, item.Peer.ID) folderIDs = append(folderIDs, int32(item.FolderID)) } if len(peerIDs) == 0 { return nil } if _, err := s.db.Exec(ctx, ` WITH requested AS ( SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS channel_id, ($4::int[])[i] AS folder_id FROM generate_subscripts($3::bigint[], 1) AS g(i) WHERE i <= cardinality($2::text[]) AND i <= cardinality($4::int[]) ), deduped AS ( SELECT DISTINCT ON (channel_id) channel_id, folder_id FROM requested WHERE peer_type = 'channel' AND folder_id IN (0, 1) ORDER BY channel_id ) UPDATE channel_dialogs d SET folder_id = deduped.folder_id, updated_at = now() FROM deduped WHERE d.user_id = $1 AND d.channel_id = deduped.channel_id AND EXISTS ( SELECT 1 FROM channel_members m WHERE m.user_id = d.user_id AND m.channel_id = d.channel_id AND m.status = 'active' )`, userID, peerTypes, peerIDs, folderIDs); err != nil { return fmt.Errorf("edit channel peer folders: %w", err) } return nil } func (s *ChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) { channel, member, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, filter.ChannelID) if err != nil { return domain.ChannelHistory{}, err } limit := filter.Limit if limit <= 0 || limit > 100 { limit = 100 } // 公共过滤条件(不含 offset 锚点的方向条件,供 add_offset 各模式复用) baseArgs := []any{filter.ChannelID} base := "channel_id = $1 AND NOT deleted" if member.AvailableMinID > 0 { baseArgs = append(baseArgs, member.AvailableMinID) base += fmt.Sprintf(" AND id > $%d", len(baseArgs)) } if filter.Query != "" { baseArgs = append(baseArgs, filter.Query) base += fmt.Sprintf(" AND body ILIKE '%%' || $%d || '%%'", len(baseArgs)) } if filter.SenderUserID != 0 { baseArgs = append(baseArgs, filter.SenderUserID) base += fmt.Sprintf(" AND sender_user_id = $%d", len(baseArgs)) } if filter.MinDate > 0 { baseArgs = append(baseArgs, filter.MinDate) base += fmt.Sprintf(" AND message_date > $%d", len(baseArgs)) } if filter.MaxDate > 0 { baseArgs = append(baseArgs, filter.MaxDate) base += fmt.Sprintf(" AND message_date < $%d", len(baseArgs)) } if filter.MaxID > 0 { baseArgs = append(baseArgs, filter.MaxID) base += fmt.Sprintf(" AND id <= $%d", len(baseArgs)) } if filter.MinID > 0 { baseArgs = append(baseArgs, filter.MinID) base += fmt.Sprintf(" AND id > $%d", len(baseArgs)) } scanList := func(sql string, queryArgs []any) ([]domain.ChannelMessage, error) { rows, err := s.db.Query(ctx, sql, queryArgs...) if err != nil { return nil, fmt.Errorf("list channel history: %w", err) } defer rows.Close() var list []domain.ChannelMessage for rows.Next() { msg, scanErr := scanChannelMessage(rows) if scanErr != nil { return nil, scanErr } list = append(list, msg) } return list, rows.Err() } // add_offset 决定加载方向(对齐私聊 ListMessagesByUser): // >= 0 backward:锚点更旧方向,先跳过 add_offset 条 // < 0 且 +limit>0 around:以锚点为中心,向更新取 -add_offset 条 + 向更旧取 limit+add_offset 条 // 否则 forward:仅锚点更新方向(拉未读消息) addOffset := filter.AddOffset out := domain.ChannelHistory{Channel: channel, Self: member} hasMoreOlder := false // 锚点条件:offset_date 优先按日期、否则按消息 id(对齐私聊/orange); // 二者皆空时向更新方向退化为空、向更旧方向退化为全部(取最新)。 forwardCond := func(args *[]any) string { if filter.OffsetDate > 0 { *args = append(*args, filter.OffsetDate) return fmt.Sprintf("message_date >= $%d", len(*args)) } if filter.OffsetID > 0 { *args = append(*args, filter.OffsetID) return fmt.Sprintf("id > $%d", len(*args)) } return "false" } aroundOlderCond := func(args *[]any) string { if filter.OffsetDate > 0 { *args = append(*args, filter.OffsetDate) return fmt.Sprintf("message_date < $%d", len(*args)) } if filter.OffsetID > 0 { *args = append(*args, filter.OffsetID) return fmt.Sprintf("id <= $%d", len(*args)) } return "true" } switch { case addOffset < 0 && addOffset+limit > 0: // around:以锚点为中心,向更新取 -add_offset 条 + 向更旧(含锚点)取 limit+add_offset 条 fwdLimit := minInt(-addOffset, limit) bwdLimit := maxInt(limit+addOffset, 0) fwdArgs := append([]any{}, baseArgs...) fwdWhere := forwardCond(&fwdArgs) fwdArgs = append(fwdArgs, fwdLimit) newer, err := scanList(fmt.Sprintf("SELECT "+channelMessageColumns+" FROM channel_messages WHERE %s AND %s ORDER BY id ASC LIMIT $%d", base, fwdWhere, len(fwdArgs)), fwdArgs) if err != nil { return domain.ChannelHistory{}, err } bwdArgs := append([]any{}, baseArgs...) bwdWhere := aroundOlderCond(&bwdArgs) bwdArgs = append(bwdArgs, bwdLimit+1) older, err := scanList(fmt.Sprintf("SELECT "+channelMessageColumns+" FROM channel_messages WHERE %s AND %s ORDER BY id DESC LIMIT $%d", base, bwdWhere, len(bwdArgs)), bwdArgs) if err != nil { return domain.ChannelHistory{}, err } if len(older) > bwdLimit { older = older[:bwdLimit] hasMoreOlder = true } for i := len(newer) - 1; i >= 0; i-- { out.Messages = append(out.Messages, newer[i]) } out.Messages = append(out.Messages, older...) case addOffset < 0: // forward:仅锚点更新方向(拉未读/更新消息) fwdArgs := append([]any{}, baseArgs...) fwdWhere := forwardCond(&fwdArgs) fwdArgs = append(fwdArgs, limit+1) newer, err := scanList(fmt.Sprintf("SELECT "+channelMessageColumns+" FROM channel_messages WHERE %s AND %s ORDER BY id ASC LIMIT $%d", base, fwdWhere, len(fwdArgs)), fwdArgs) if err != nil { return domain.ChannelHistory{}, err } if len(newer) > limit { newer = newer[:limit] } for i := len(newer) - 1; i >= 0; i-- { out.Messages = append(out.Messages, newer[i]) } default: // backward:锚点更旧方向(不含锚点),先跳过 add_offset 条 where := base args := append([]any{}, baseArgs...) if filter.OffsetDate > 0 { args = append(args, filter.OffsetDate) where += fmt.Sprintf(" AND message_date < $%d", len(args)) } else if filter.OffsetID > 0 { args = append(args, filter.OffsetID) where += fmt.Sprintf(" AND id < $%d", len(args)) } args = append(args, limit+1) limIdx := len(args) sql := "SELECT " + channelMessageColumns + " FROM channel_messages WHERE " + where + " ORDER BY id DESC" if addOffset > 0 { args = append(args, addOffset) sql += fmt.Sprintf(" OFFSET $%d", len(args)) } sql += fmt.Sprintf(" LIMIT $%d", limIdx) older, err := scanList(sql, args) if err != nil { return domain.ChannelHistory{}, err } if len(older) > limit { older = older[:limit] hasMoreOlder = true } out.Messages = older } out.Count = len(out.Messages) if hasMoreOlder { out.Count = len(out.Messages) + 1 } if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, out.Messages); err != nil { return domain.ChannelHistory{}, err } if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil { return domain.ChannelHistory{}, err } return out, nil } func (s *ChannelStore) SearchPublicPosts(ctx context.Context, viewerUserID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) { query := strings.TrimSpace(req.Query) hashtag := strings.TrimSpace(req.Hashtag) if (query == "") == (hashtag == "") { return domain.ChannelHistory{}, domain.ErrChannelInvalid } limit := req.Limit if limit <= 0 || limit > domain.MaxChannelSearchPostsLimit { limit = domain.MaxChannelSearchPostsLimit } args := []any{} where := `NOT deleted AND body <> '' AND EXISTS ( SELECT 1 FROM channels c WHERE c.id = channel_messages.channel_id AND NOT c.deleted AND COALESCE(c.username, '') <> '' )` if query != "" { args = append(args, "%"+escapeLike(query)+"%") where += fmt.Sprintf(" AND body ILIKE $%d ESCAPE '\\'", len(args)) } if hashtag != "" { args = append(args, "%#"+escapeLike(hashtag)+"%") where += fmt.Sprintf(" AND body ILIKE $%d ESCAPE '\\'", len(args)) } switch { case req.OffsetRate > 0 && req.OffsetChannelID > 0 && req.OffsetID > 0: args = append(args, req.OffsetRate, req.OffsetChannelID, req.OffsetID) n := len(args) where += fmt.Sprintf(" AND (message_date < $%d OR (message_date = $%d AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))))", n-2, n-2, n-1, n-1, n) case req.OffsetRate > 0: args = append(args, req.OffsetRate) where += fmt.Sprintf(" AND message_date < $%d", len(args)) case req.OffsetChannelID > 0 && req.OffsetID > 0: args = append(args, req.OffsetChannelID, req.OffsetID) n := len(args) where += fmt.Sprintf(" AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))", n-1, n-1, n) case req.OffsetID > 0: args = append(args, req.OffsetID) where += fmt.Sprintf(" AND id < $%d", len(args)) } queryLimit := limit + 1 args = append(args, queryLimit) rows, err := s.db.Query(ctx, ` SELECT `+channelMessageColumns+` FROM channel_messages WHERE `+where+` ORDER BY message_date DESC, channel_id DESC, id DESC LIMIT $`+fmt.Sprint(len(args)), args...) if err != nil { return domain.ChannelHistory{}, fmt.Errorf("search public channel posts: %w", err) } defer rows.Close() out := domain.ChannelHistory{} channelRefs := make(map[int64]struct{}) for rows.Next() { msg, err := scanChannelMessage(rows) if err != nil { return domain.ChannelHistory{}, err } out.Messages = append(out.Messages, msg) channelRefs[msg.ChannelID] = struct{}{} } if err := rows.Err(); err != nil { return domain.ChannelHistory{}, err } if len(out.Messages) > limit { out.Messages = out.Messages[:limit] out.Count = limit + 1 channelRefs = make(map[int64]struct{}, len(out.Messages)) for _, msg := range out.Messages { channelRefs[msg.ChannelID] = struct{}{} } } else { out.Count = len(out.Messages) } channels, err := listChannelsByIDs(ctx, s.db, mapKeysInt64(channelRefs)) if err != nil { return domain.ChannelHistory{}, err } out.Channels = channels if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil { return domain.ChannelHistory{}, err } return out, nil } func (s *ChannelStore) SearchJoinedMessages(ctx context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) { query := strings.TrimSpace(req.Query) if viewerUserID == 0 || query == "" { return domain.ChannelHistory{}, domain.ErrChannelInvalid } limit := req.Limit if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit { limit = domain.MaxChannelGlobalSearchLimit } args := []any{viewerUserID, "%" + escapeLike(query) + "%"} where := `NOT deleted AND body <> '' AND body ILIKE $2 ESCAPE '\' AND EXISTS ( SELECT 1 FROM channels c JOIN channel_members cm ON cm.channel_id = c.id AND cm.user_id = $1 AND cm.status = 'active' AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false) LEFT JOIN channel_dialogs d ON d.channel_id = c.id AND d.user_id = $1 WHERE c.id = channel_messages.channel_id AND NOT c.deleted AND (cm.available_min_id <= 0 OR channel_messages.id > cm.available_min_id)` if req.BroadcastsOnly { where += ` AND c.broadcast AND NOT c.megagroup` } if req.GroupsOnly { where += ` AND c.megagroup` } if req.HasFolderID { args = append(args, req.FolderID) where += fmt.Sprintf(` AND d.folder_id = $%d`, len(args)) } where += ` )` if req.MinDate > 0 { args = append(args, req.MinDate) where += fmt.Sprintf(" AND message_date > $%d", len(args)) } if req.MaxDate > 0 { args = append(args, req.MaxDate) where += fmt.Sprintf(" AND message_date < $%d", len(args)) } switch { case req.OffsetRate > 0 && req.OffsetChannelID > 0 && req.OffsetID > 0: args = append(args, req.OffsetRate, req.OffsetChannelID, req.OffsetID) n := len(args) where += fmt.Sprintf(" AND (message_date < $%d OR (message_date = $%d AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))))", n-2, n-2, n-1, n-1, n) case req.OffsetRate > 0: args = append(args, req.OffsetRate) where += fmt.Sprintf(" AND message_date < $%d", len(args)) case req.OffsetChannelID > 0 && req.OffsetID > 0: args = append(args, req.OffsetChannelID, req.OffsetID) n := len(args) where += fmt.Sprintf(" AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))", n-1, n-1, n) case req.OffsetID > 0: args = append(args, req.OffsetID) where += fmt.Sprintf(" AND id < $%d", len(args)) } queryLimit := limit + 1 args = append(args, queryLimit) rows, err := s.db.Query(ctx, ` SELECT `+channelMessageColumns+` FROM channel_messages WHERE `+where+` ORDER BY message_date DESC, channel_id DESC, id DESC LIMIT $`+fmt.Sprint(len(args)), args...) if err != nil { return domain.ChannelHistory{}, fmt.Errorf("search joined channel messages: %w", err) } defer rows.Close() out := domain.ChannelHistory{} channelRefs := make(map[int64]struct{}) for rows.Next() { msg, err := scanChannelMessage(rows) if err != nil { return domain.ChannelHistory{}, err } out.Messages = append(out.Messages, msg) channelRefs[msg.ChannelID] = struct{}{} } if err := rows.Err(); err != nil { return domain.ChannelHistory{}, err } if len(out.Messages) > limit { out.Messages = out.Messages[:limit] out.Count = limit + 1 channelRefs = make(map[int64]struct{}, len(out.Messages)) for _, msg := range out.Messages { channelRefs[msg.ChannelID] = struct{}{} } } else { out.Count = len(out.Messages) } channels, err := listChannelsByIDs(ctx, s.db, mapKeysInt64(channelRefs)) if err != nil { return domain.ChannelHistory{}, err } out.Channels = channels if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil { return domain.ChannelHistory{}, err } return out, nil } func (s *ChannelStore) GetChannelMessages(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelHistory, error) { channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) if err != nil { return domain.ChannelHistory{}, err } if len(ids) == 0 { return domain.ChannelHistory{Channel: channel, Self: member}, nil } if len(ids) > domain.MaxGetMessageIDs { return domain.ChannelHistory{}, domain.ErrChannelInvalid } id32, _, err := validUniqueChannelMessageIDs(ids) if err != nil { return domain.ChannelHistory{}, err } args := []any{channelID, id32} where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" if member.AvailableMinID > 0 { args = append(args, member.AvailableMinID) where += fmt.Sprintf(" AND id > $%d", len(args)) } rows, err := s.db.Query(ctx, ` SELECT `+channelMessageColumns+` FROM channel_messages WHERE `+where+` ORDER BY id DESC`, args...) if err != nil { return domain.ChannelHistory{}, fmt.Errorf("get channel messages by ids: %w", err) } defer rows.Close() out := domain.ChannelHistory{Channel: channel, Self: member} for rows.Next() { msg, err := scanChannelMessage(rows) if err != nil { return domain.ChannelHistory{}, err } out.Messages = append(out.Messages, msg) } if err := rows.Err(); err != nil { return domain.ChannelHistory{}, err } out.Count = len(out.Messages) if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, out.Messages); err != nil { return domain.ChannelHistory{}, err } if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil { return domain.ChannelHistory{}, err } return out, nil } func (s *ChannelStore) ReadChannelMessageContents(ctx context.Context, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("read channel message contents: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("begin read channel message contents: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.ReadChannelMessageContentsResult{}, err } if len(req.IDs) == 0 { if err := tx.Commit(ctx); err != nil { return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("commit read channel message contents: %w", err) } committed = true return domain.ReadChannelMessageContentsResult{Channel: channel}, nil } if len(req.IDs) > domain.MaxGetMessageIDs { return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid } id32, _, err := validUniqueChannelMessageIDs(req.IDs) if err != nil { return domain.ReadChannelMessageContentsResult{}, err } args := []any{req.ChannelID, id32} where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" if member.AvailableMinID > 0 { args = append(args, member.AvailableMinID) where += fmt.Sprintf(" AND id > $%d", len(args)) } rows, err := tx.Query(ctx, ` SELECT `+channelMessageColumns+` FROM channel_messages WHERE `+where+` ORDER BY id DESC`, args...) if err != nil { return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("read channel messages by ids: %w", err) } messages := make([]domain.ChannelMessage, 0, len(id32)) for rows.Next() { msg, err := scanChannelMessage(rows) if err != nil { rows.Close() return domain.ReadChannelMessageContentsResult{}, err } messages = append(messages, msg) } if err := rows.Err(); err != nil { rows.Close() return domain.ReadChannelMessageContentsResult{}, err } rows.Close() visibleIDs := make([]int32, 0, len(messages)) for _, msg := range messages { visibleIDs = append(visibleIDs, int32(msg.ID)) } cleared, err := clearChannelUnreadReactionsForMessageIDsTx(ctx, tx, req.UserID, req.ChannelID, visibleIDs) if err != nil { return domain.ReadChannelMessageContentsResult{}, err } if err := s.populateChannelMessageReplies(ctx, tx, req.UserID, channel, messages); err != nil { return domain.ReadChannelMessageContentsResult{}, err } if err := s.populateChannelMessagesReactions(ctx, tx, req.UserID, []domain.Channel{channel}, messages); err != nil { return domain.ReadChannelMessageContentsResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("commit read channel message contents: %w", err) } committed = true return domain.ReadChannelMessageContentsResult{ Channel: channel, Messages: messages, ClearedUnreadReactionMessageIDs: cleared, }, nil } func (s *ChannelStore) GetChannelMessageViews(ctx context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid } _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageViewsResult{}, err } if len(req.IDs) == 0 { return domain.ChannelMessageViewsResult{Views: map[int]int{}}, nil } if len(req.IDs) > domain.MaxGetMessageIDs { return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid } id32, _, err := validUniqueChannelMessageIDs(req.IDs) if err != nil { return domain.ChannelMessageViewsResult{}, err } if req.Increment { date := req.Date if date <= 0 { date = nowUnix() } rows, err := s.db.Query(ctx, ` WITH inserted AS ( INSERT INTO channel_message_viewers (channel_id, message_id, viewer_user_id, viewed_at) SELECT m.channel_id, m.id, $3, $4 FROM channel_messages m WHERE m.channel_id = $1 AND m.id = ANY($2::int[]) AND NOT m.deleted AND m.id > $5 ON CONFLICT DO NOTHING RETURNING message_id ), updated AS ( UPDATE channel_messages m SET views_count = views_count + 1, updated_at = now() FROM inserted i WHERE m.channel_id = $1 AND m.id = i.message_id RETURNING m.id ) SELECT i.message_id FROM inserted i LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, date, member.AvailableMinID) if err != nil { return domain.ChannelMessageViewsResult{}, fmt.Errorf("increment channel message views: %w", err) } for rows.Next() { var ignored int if err := rows.Scan(&ignored); err != nil { rows.Close() return domain.ChannelMessageViewsResult{}, err } } if err := rows.Err(); err != nil { rows.Close() return domain.ChannelMessageViewsResult{}, err } rows.Close() } args := []any{req.ChannelID, id32} where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" if member.AvailableMinID > 0 { args = append(args, member.AvailableMinID) where += fmt.Sprintf(" AND id > $%d", len(args)) } rows, err := s.db.Query(ctx, ` SELECT id, views_count FROM channel_messages WHERE `+where, args...) if err != nil { return domain.ChannelMessageViewsResult{}, fmt.Errorf("get channel message views: %w", err) } defer rows.Close() out := make(map[int]int, len(req.IDs)) for rows.Next() { var id int var views int if err := rows.Scan(&id, &views); err != nil { return domain.ChannelMessageViewsResult{}, err } out[id] = views } if err := rows.Err(); err != nil { return domain.ChannelMessageViewsResult{}, err } return domain.ChannelMessageViewsResult{Views: out}, nil } func (s *ChannelStore) SetChannelMessageReactions(ctx context.Context, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid } if len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid } for _, reaction := range req.Reactions { if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" || len(reaction.Emoticon) > domain.MaxChannelReactionEmoticonLength { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid } } if req.Date <= 0 { req.Date = nowUnix() } beginner, ok := s.db.(txBeginner) if !ok { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("set channel message reactions: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("begin set channel message reactions: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionsResult{}, err } msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) if err != nil { return domain.ChannelMessageReactionsResult{}, err } if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID { return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid } if _, err := tx.Exec(ctx, ` DELETE FROM channel_message_reactions WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3`, req.ChannelID, req.MessageID, req.UserID); err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("delete channel message reactions: %w", err) } for i, reaction := range req.Reactions { if _, err := tx.Exec(ctx, ` INSERT INTO channel_message_reactions ( channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, big, unread, chosen_order, reaction_date ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, req.ChannelID, req.MessageID, req.UserID, msg.SenderUserID, string(reaction.Type), reaction.Emoticon, req.Big, msg.SenderUserID != 0 && msg.SenderUserID != req.UserID, i+1, req.Date); err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("insert channel message reaction: %w", err) } if req.AddToRecent { if _, err := tx.Exec(ctx, ` INSERT INTO user_recent_reactions (user_id, reaction_type, reaction_value, reaction_date) VALUES ($1,$2,$3,$4) ON CONFLICT (user_id, reaction_type, reaction_value) DO UPDATE SET reaction_date = EXCLUDED.reaction_date, updated_at = now()`, req.UserID, string(reaction.Type), reaction.Emoticon, req.Date); err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("upsert recent message reaction: %w", err) } } if _, err := tx.Exec(ctx, ` INSERT INTO user_top_reactions (user_id, reaction_type, reaction_value, reaction_count, reaction_date) VALUES ($1,$2,$3,1,$4) ON CONFLICT (user_id, reaction_type, reaction_value) DO UPDATE SET reaction_count = user_top_reactions.reaction_count + 1, reaction_date = EXCLUDED.reaction_date, updated_at = now()`, req.UserID, string(reaction.Type), reaction.Emoticon, req.Date); err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("upsert top message reaction: %w", err) } } if err := refreshChannelUnreadReactionsCountTx(ctx, tx, msg.SenderUserID, req.ChannelID); err != nil { return domain.ChannelMessageReactionsResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("commit set channel message reactions: %w", err) } committed = true messages := []domain.ChannelMessage{msg} if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { return domain.ChannelMessageReactionsResult{}, err } msg = messages[0] reactions := emptyChannelMessageReactions(channel) if msg.Reactions != nil { reactions = *msg.Reactions } else { msg.Reactions = &reactions } recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout) if err != nil { recipients = []int64{req.UserID} } return domain.ChannelMessageReactionsResult{ Channel: channel, Message: msg, Messages: []domain.ChannelMessage{msg}, Reactions: reactions, Recipients: recipients, }, nil } func (s *ChannelStore) DeleteChannelParticipantReaction(ctx context.Context, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID || req.ParticipantUserID == 0 { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("delete channel participant reaction: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("begin delete channel participant reaction: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionsResult{}, err } if !canDeleteAnyChannelMessage(member) { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelAdminRequired } msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) if err != nil { return domain.ChannelMessageReactionsResult{}, err } if msg.Deleted || msg.ID <= member.AvailableMinID { return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid } if _, err := tx.Exec(ctx, ` DELETE FROM channel_message_reactions WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3`, req.ChannelID, req.MessageID, req.ParticipantUserID); err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("delete participant reaction: %w", err) } if err := refreshChannelUnreadReactionsCountTx(ctx, tx, msg.SenderUserID, req.ChannelID); err != nil { return domain.ChannelMessageReactionsResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("commit delete participant reaction: %w", err) } committed = true messages := []domain.ChannelMessage{msg} if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { return domain.ChannelMessageReactionsResult{}, err } msg = messages[0] reactions := emptyChannelMessageReactions(channel) if msg.Reactions != nil { reactions = *msg.Reactions } else { msg.Reactions = &reactions } recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout) if err != nil { recipients = []int64{req.UserID} } return domain.ChannelMessageReactionsResult{ Channel: channel, Message: msg, Messages: []domain.ChannelMessage{msg}, Reactions: reactions, Recipients: recipients, }, nil } func (s *ChannelStore) DeleteChannelParticipantReactions(ctx context.Context, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid } if req.Limit <= 0 || req.Limit > domain.MaxDeleteParticipantReactionsBatch { req.Limit = domain.MaxDeleteParticipantReactionsBatch } beginner, ok := s.db.(txBeginner) if !ok { return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("delete channel participant reactions: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("begin delete channel participant reactions: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.DeleteChannelParticipantReactionsResult{}, err } if !canDeleteAnyChannelMessage(member) { return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelAdminRequired } rows, err := tx.Query(ctx, ` SELECT message_id, MAX(sender_user_id) FROM channel_message_reactions WHERE channel_id = $1 AND reacted_user_id = $2 GROUP BY message_id ORDER BY MAX(reaction_date) DESC, message_id DESC LIMIT $3`, req.ChannelID, req.ParticipantUserID, req.Limit) if err != nil { return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("list participant reaction messages: %w", err) } ids := make([]int, 0, req.Limit) owners := make(map[int64]struct{}) for rows.Next() { var msgID int var senderUserID int64 if err := rows.Scan(&msgID, &senderUserID); err != nil { rows.Close() return domain.DeleteChannelParticipantReactionsResult{}, err } ids = append(ids, msgID) if senderUserID != 0 { owners[senderUserID] = struct{}{} } } if err := rows.Err(); err != nil { rows.Close() return domain.DeleteChannelParticipantReactionsResult{}, err } rows.Close() if len(ids) > 0 { if _, err := tx.Exec(ctx, ` DELETE FROM channel_message_reactions WHERE channel_id = $1 AND reacted_user_id = $2 AND message_id = ANY($3::int[])`, req.ChannelID, req.ParticipantUserID, int32s(ids)); err != nil { return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("delete participant reactions: %w", err) } for ownerID := range owners { if err := refreshChannelUnreadReactionsCountTx(ctx, tx, ownerID, req.ChannelID); err != nil { return domain.DeleteChannelParticipantReactionsResult{}, err } } } if err := tx.Commit(ctx); err != nil { return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("commit delete participant reactions: %w", err) } committed = true messages := []domain.ChannelMessage{} if len(ids) > 0 { res, err := s.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{ UserID: req.UserID, ChannelID: req.ChannelID, IDs: ids, }) if err != nil { return domain.DeleteChannelParticipantReactionsResult{}, err } messages = res.Messages } recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout) if err != nil { recipients = []int64{req.UserID} } return domain.DeleteChannelParticipantReactionsResult{ Channel: channel, Messages: messages, Recipients: recipients, Deleted: len(ids), }, nil } func (s *ChannelStore) GetChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid } if len(req.IDs) > domain.MaxGetMessageIDs { return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid } channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionsResult{}, err } if len(req.IDs) == 0 { return domain.ChannelMessageReactionsResult{Channel: channel}, nil } id32, _, err := validUniqueChannelMessageIDs(req.IDs) if err != nil { return domain.ChannelMessageReactionsResult{}, err } args := []any{req.ChannelID, id32} where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" if member.AvailableMinID > 0 { args = append(args, member.AvailableMinID) where += fmt.Sprintf(" AND id > $%d", len(args)) } rows, err := s.db.Query(ctx, ` SELECT `+channelMessageColumns+` FROM channel_messages WHERE `+where+` ORDER BY id DESC`, args...) if err != nil { return domain.ChannelMessageReactionsResult{}, fmt.Errorf("get channel message reactions messages: %w", err) } defer rows.Close() messages := make([]domain.ChannelMessage, 0, len(req.IDs)) for rows.Next() { msg, err := scanChannelMessage(rows) if err != nil { return domain.ChannelMessageReactionsResult{}, err } messages = append(messages, msg) } if err := rows.Err(); err != nil { return domain.ChannelMessageReactionsResult{}, err } if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { return domain.ChannelMessageReactionsResult{}, err } res := domain.ChannelMessageReactionsResult{Channel: channel, Messages: messages} if len(messages) == 1 { res.Message = messages[0] res.Reactions = emptyChannelMessageReactions(channel) if messages[0].Reactions != nil { res.Reactions = *messages[0].Reactions } } return res, nil } func (s *ChannelStore) ListChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error) { if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid } if req.Limit <= 0 || req.Limit > domain.MaxChannelMessageReactionListLimit { req.Limit = domain.MaxChannelMessageReactionListLimit } channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageReactionsList{}, err } if channel.Broadcast && !channel.Megagroup { return domain.ChannelMessageReactionsList{}, domain.ErrChannelRightForbidden } msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID) if err != nil { return domain.ChannelMessageReactionsList{}, err } if msg.Deleted || msg.ID <= member.AvailableMinID { return domain.ChannelMessageReactionsList{}, domain.ErrMessageIDInvalid } baseWhere := []string{"channel_id = $1", "message_id = $2"} baseArgs := []any{req.ChannelID, req.MessageID} if req.Reaction != nil { if req.Reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(req.Reaction.Emoticon) == "" { return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid } baseArgs = append(baseArgs, string(req.Reaction.Type), req.Reaction.Emoticon) baseWhere = append(baseWhere, fmt.Sprintf("reaction_type = $%d AND reaction_value = $%d", len(baseArgs)-1, len(baseArgs))) } var count int if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM channel_message_reactions WHERE `+strings.Join(baseWhere, " AND "), baseArgs...).Scan(&count); err != nil { return domain.ChannelMessageReactionsList{}, fmt.Errorf("count channel message reactions: %w", err) } where := append([]string(nil), baseWhere...) args := append([]any(nil), baseArgs...) if req.Offset != "" { cursor, ok := parseChannelReactionOffset(req.Offset) if !ok { return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid } args = append(args, cursor.date, cursor.userID, cursor.emoticon) n := len(args) where = append(where, fmt.Sprintf("(reaction_date < $%d OR (reaction_date = $%d AND (reacted_user_id < $%d OR (reacted_user_id = $%d AND reaction_value > $%d))))", n-2, n-2, n-1, n-1, n)) } args = append(args, req.Limit+1) rows, err := s.db.Query(ctx, ` SELECT channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, big, unread, chosen_order, reaction_date FROM channel_message_reactions WHERE `+strings.Join(where, " AND ")+` ORDER BY reaction_date DESC, reacted_user_id DESC, reaction_value ASC LIMIT $`+fmt.Sprint(len(args)), args...) if err != nil { return domain.ChannelMessageReactionsList{}, fmt.Errorf("list channel message reactions: %w", err) } defer rows.Close() reactions := make([]domain.ChannelMessagePeerReaction, 0, req.Limit+1) for rows.Next() { row, err := scanChannelMessagePeerReaction(rows, req.UserID) if err != nil { return domain.ChannelMessageReactionsList{}, err } reactions = append(reactions, row) } if err := rows.Err(); err != nil { return domain.ChannelMessageReactionsList{}, err } next := "" if len(reactions) > req.Limit { reactions = reactions[:req.Limit] next = channelReactionOffset(reactions[len(reactions)-1]) } return domain.ChannelMessageReactionsList{ Channel: channel, Message: msg, Count: count, Reactions: reactions, NextOffset: next, }, nil } func (s *ChannelStore) ListTopMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { if userID == 0 { return nil, domain.ErrChannelInvalid } if limit <= 0 { return []domain.MessageReaction{}, nil } if limit > domain.MaxTopMessageReactions { limit = domain.MaxTopMessageReactions } rows, err := s.db.Query(ctx, ` SELECT reaction_type, reaction_value FROM user_top_reactions WHERE user_id = $1 ORDER BY reaction_count DESC, reaction_date DESC, updated_at DESC, reaction_value ASC LIMIT $2`, userID, limit) if err != nil { return nil, fmt.Errorf("list top message reactions: %w", err) } defer rows.Close() out := make([]domain.MessageReaction, 0, limit) for rows.Next() { var reactionType, reactionValue string if err := rows.Scan(&reactionType, &reactionValue); err != nil { return nil, err } out = append(out, domain.MessageReaction{ Type: domain.MessageReactionType(reactionType), Emoticon: reactionValue, }) } if err := rows.Err(); err != nil { return nil, err } return out, nil } func (s *ChannelStore) ListRecentMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { if userID == 0 { return nil, domain.ErrChannelInvalid } if limit <= 0 { return []domain.MessageReaction{}, nil } if limit > domain.MaxRecentMessageReactions { limit = domain.MaxRecentMessageReactions } rows, err := s.db.Query(ctx, ` SELECT reaction_type, reaction_value FROM user_recent_reactions WHERE user_id = $1 ORDER BY reaction_date DESC, updated_at DESC, reaction_value ASC LIMIT $2`, userID, limit) if err != nil { return nil, fmt.Errorf("list recent message reactions: %w", err) } defer rows.Close() out := make([]domain.MessageReaction, 0, limit) for rows.Next() { var reactionType, reactionValue string if err := rows.Scan(&reactionType, &reactionValue); err != nil { return nil, err } out = append(out, domain.MessageReaction{ Type: domain.MessageReactionType(reactionType), Emoticon: reactionValue, }) } if err := rows.Err(); err != nil { return nil, err } return out, nil } func (s *ChannelStore) RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error { if userID == 0 || len(reactions) == 0 { return nil } if date <= 0 { date = nowUnix() } for _, reaction := range reactions { if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" || len(reaction.Emoticon) > domain.MaxChannelReactionEmoticonLength { continue } if addToRecent { if _, err := s.db.Exec(ctx, ` INSERT INTO user_recent_reactions (user_id, reaction_type, reaction_value, reaction_date) VALUES ($1,$2,$3,$4) ON CONFLICT (user_id, reaction_type, reaction_value) DO UPDATE SET reaction_date = EXCLUDED.reaction_date, updated_at = now()`, userID, string(reaction.Type), reaction.Emoticon, date); err != nil { return fmt.Errorf("record recent message reaction: %w", err) } } if _, err := s.db.Exec(ctx, ` INSERT INTO user_top_reactions (user_id, reaction_type, reaction_value, reaction_count, reaction_date) VALUES ($1,$2,$3,1,$4) ON CONFLICT (user_id, reaction_type, reaction_value) DO UPDATE SET reaction_count = user_top_reactions.reaction_count + 1, reaction_date = EXCLUDED.reaction_date, updated_at = now()`, userID, string(reaction.Type), reaction.Emoticon, date); err != nil { return fmt.Errorf("record top message reaction: %w", err) } } return nil } func (s *ChannelStore) ClearRecentMessageReactions(ctx context.Context, userID int64) error { if userID == 0 { return domain.ErrChannelInvalid } if _, err := s.db.Exec(ctx, `DELETE FROM user_recent_reactions WHERE user_id = $1`, userID); err != nil { return fmt.Errorf("clear recent message reactions: %w", err) } return nil } func (s *ChannelStore) ListSavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) { if userID == 0 { return nil, domain.ErrChannelInvalid } if limit <= 0 { return []domain.SavedReactionTag{}, nil } if limit > domain.MaxSavedReactionTags { limit = domain.MaxSavedReactionTags } rows, err := s.db.Query(ctx, ` SELECT reaction_type, reaction_value, title, reaction_count FROM user_saved_reaction_tags WHERE user_id = $1 ORDER BY reaction_count DESC, updated_at DESC, reaction_value ASC LIMIT $2`, userID, limit) if err != nil { return nil, fmt.Errorf("list saved reaction tags: %w", err) } defer rows.Close() out := make([]domain.SavedReactionTag, 0, limit) for rows.Next() { var reactionType, reactionValue, title string var count int if err := rows.Scan(&reactionType, &reactionValue, &title, &count); err != nil { return nil, err } out = append(out, domain.SavedReactionTag{ UserID: userID, Reaction: domain.MessageReaction{ Type: domain.MessageReactionType(reactionType), Emoticon: reactionValue, }, Title: title, Count: count, }) } if err := rows.Err(); err != nil { return nil, err } return out, nil } func (s *ChannelStore) UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error { if tag.UserID == 0 || tag.Reaction.Type != domain.MessageReactionEmoji { return domain.ErrChannelInvalid } reactionValue := strings.TrimSpace(tag.Reaction.Emoticon) if reactionValue == "" { return domain.ErrChannelInvalid } if tag.Count < 0 { tag.Count = 0 } if _, err := s.db.Exec(ctx, ` INSERT INTO user_saved_reaction_tags (user_id, reaction_type, reaction_value, title, reaction_count) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id, reaction_type, reaction_value) DO UPDATE SET title = EXCLUDED.title, reaction_count = GREATEST(user_saved_reaction_tags.reaction_count, EXCLUDED.reaction_count), updated_at = now()`, tag.UserID, string(tag.Reaction.Type), reactionValue, tag.Title, tag.Count); err != nil { return fmt.Errorf("upsert saved reaction tag: %w", err) } return nil } func (s *ChannelStore) CreateForumTopic(ctx context.Context, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.RandomID == 0 { return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid } title := strings.TrimSpace(req.Title) if title == "" && !req.TitleMissing { return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid } channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.CreateChannelForumTopicResult{}, err } if !channel.Forum || channel.Broadcast || !channel.Megagroup { return domain.CreateChannelForumTopicResult{}, domain.ErrChannelForumMissing } if !canSendChannelMessage(channel, member) { return domain.CreateChannelForumTopicResult{}, domain.ErrChannelWriteForbidden } if req.IconColor == 0 { req.IconColor = domain.DefaultForumTopicIconColor } res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: req.UserID, ChannelID: req.ChannelID, RandomID: req.RandomID, SendAs: req.SendAs, Action: &domain.ChannelMessageAction{ Type: domain.ChannelActionTopicCreate, Title: title, IconColor: req.IconColor, IconEmojiID: req.IconEmojiID, TitleMissing: req.TitleMissing, }, Date: req.Date, }) if err != nil { return domain.CreateChannelForumTopicResult{}, err } if res.Message.Action == nil || res.Message.Action.Type != domain.ChannelActionTopicCreate { return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid } if _, err := s.db.Exec(ctx, ` INSERT INTO channel_forum_topics ( channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id, title_missing, date, top_message_id, read_inbox_max_id, read_outbox_max_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $2, $2, $2) ON CONFLICT (channel_id, topic_id) DO NOTHING`, req.ChannelID, res.Message.ID, req.UserID, title, req.IconColor, req.IconEmojiID, req.TitleMissing, res.Message.Date); err != nil { return domain.CreateChannelForumTopicResult{}, fmt.Errorf("insert forum topic: %w", err) } topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, res.Message.ID) if err != nil { return domain.CreateChannelForumTopicResult{}, err } return domain.CreateChannelForumTopicResult{ Channel: res.Channel, Topic: topic, Message: res.Message, Event: res.Event, Recipients: res.Recipients, Duplicate: res.Duplicate, }, nil } func (s *ChannelStore) EditForumTopic(ctx context.Context, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid } channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.EditChannelForumTopicResult{}, err } if !channel.Forum { return domain.EditChannelForumTopicResult{}, domain.ErrChannelForumMissing } topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) if err != nil { return domain.EditChannelForumTopicResult{}, err } if !canManageForumTopic(channel, member, topic, req.UserID) { return domain.EditChannelForumTopicResult{}, domain.ErrChannelAdminRequired } next := topic action := domain.ChannelMessageAction{Type: domain.ChannelActionTopicEdit} changed := false if req.Title != nil { title := strings.TrimSpace(*req.Title) if title == "" { return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid } if next.Title != title { next.Title = title action.Title = title changed = true } } if req.IconEmojiID != nil && next.IconEmojiID != *req.IconEmojiID { next.IconEmojiID = *req.IconEmojiID action.IconEmojiID = *req.IconEmojiID action.IconEmojiIDSet = true changed = true } if req.Closed != nil && next.Closed != *req.Closed { next.Closed = *req.Closed action.Closed = boolPtr(*req.Closed) changed = true } if req.Hidden != nil && next.Hidden != *req.Hidden { next.Hidden = *req.Hidden action.Hidden = boolPtr(*req.Hidden) changed = true } if !changed { return domain.EditChannelForumTopicResult{}, domain.ErrChannelNotModified } res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ UserID: req.UserID, ChannelID: req.ChannelID, ReplyTo: &domain.MessageReply{ Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, MessageID: req.TopicID, TopMessageID: req.TopicID, }, Action: &action, Date: req.Date, }) if err != nil { return domain.EditChannelForumTopicResult{}, err } if _, err := s.db.Exec(ctx, ` UPDATE channel_forum_topics SET title = $3, icon_emoji_id = $4, closed = $5, hidden = $6, top_message_id = GREATEST(top_message_id, $7), updated_at = now() WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, req.ChannelID, req.TopicID, next.Title, next.IconEmojiID, next.Closed, next.Hidden, res.Message.ID); err != nil { return domain.EditChannelForumTopicResult{}, fmt.Errorf("update forum topic: %w", err) } topic, err = s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) if err != nil { return domain.EditChannelForumTopicResult{}, err } return domain.EditChannelForumTopicResult{ Channel: res.Channel, Topic: topic, Message: res.Message, Event: res.Event, Recipients: res.Recipients, }, nil } func (s *ChannelStore) UpdatePinnedForumTopic(ctx context.Context, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelInvalid } channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.UpdateChannelForumTopicPinnedResult{}, err } if !channel.Forum { return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelForumMissing } topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) if err != nil { return domain.UpdateChannelForumTopicPinnedResult{}, err } if !canPinChannelMessages(channel, member) { return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelAdminRequired } if topic.Pinned == req.Pinned { return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelNotModified } pinnedOrder := 0 if req.Pinned { pinnedOrder = topic.PinnedOrder if pinnedOrder == 0 { pinnedOrder, err = s.nextForumTopicPinnedOrder(ctx, req.ChannelID) if err != nil { return domain.UpdateChannelForumTopicPinnedResult{}, err } } } if _, err := s.db.Exec(ctx, ` UPDATE channel_forum_topics SET pinned = $3, pinned_order = $4, updated_at = now() WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, req.ChannelID, req.TopicID, req.Pinned, pinnedOrder); err != nil { return domain.UpdateChannelForumTopicPinnedResult{}, fmt.Errorf("update pinned forum topic: %w", err) } topic, err = s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) if err != nil { return domain.UpdateChannelForumTopicPinnedResult{}, err } recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) return domain.UpdateChannelForumTopicPinnedResult{Channel: channel, Topic: topic, Recipients: recipients}, nil } func (s *ChannelStore) ReorderPinnedForumTopics(ctx context.Context, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error) { if req.UserID == 0 || req.ChannelID == 0 || len(req.Order) > domain.MaxChannelForumTopicIDs { return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelInvalid } channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ReorderChannelPinnedForumTopicsResult{}, err } if !channel.Forum { return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelForumMissing } if !canPinChannelMessages(channel, member) { return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelAdminRequired } seen := make(map[int]struct{}, len(req.Order)) order := make([]int, 0, len(req.Order)) for _, id := range req.Order { if id <= 0 || id > domain.MaxMessageBoxID { return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid } if _, ok := seen[id]; ok { continue } topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, id) if err != nil || !topic.Pinned { if req.Force { continue } return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid } seen[id] = struct{}{} order = append(order, id) } for i, id := range order { if _, err := s.db.Exec(ctx, ` UPDATE channel_forum_topics SET pinned_order = $3, updated_at = now() WHERE channel_id = $1 AND topic_id = $2 AND pinned AND NOT deleted`, req.ChannelID, id, len(order)-i); err != nil { return domain.ReorderChannelPinnedForumTopicsResult{}, fmt.Errorf("reorder pinned forum topics: %w", err) } } recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) return domain.ReorderChannelPinnedForumTopicsResult{Channel: channel, Order: order, Recipients: recipients}, nil } func (s *ChannelStore) DeleteForumTopicHistory(ctx context.Context, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error) { if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid } if req.Date == 0 { req.Date = nowUnix() } beginner, ok := s.db.(txBeginner) if !ok { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("delete forum topic history: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("begin delete forum topic history: %w", err) } committed := false var reserved []reservedChannelPts defer func() { if !committed { _ = tx.Rollback(ctx) s.recordChannelPtsGaps(ctx, reserved, req.Date) } }() channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.DeleteChannelHistoryResult{}, err } if !channel.Forum { return domain.DeleteChannelHistoryResult{}, domain.ErrChannelForumMissing } topic, err := s.getForumTopic(ctx, tx, req.ChannelID, req.TopicID) if err != nil { return domain.DeleteChannelHistoryResult{}, err } if !canManageForumTopic(channel, member, topic, req.UserID) && !canDeleteAnyChannelMessage(member) { return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired } rows, err := tx.Query(ctx, ` SELECT id FROM channel_messages WHERE channel_id = $1 AND NOT deleted AND (id = $2 OR reply_to_top_id = $2) ORDER BY id DESC LIMIT $3`, req.ChannelID, req.TopicID, domain.MaxDeleteHistoryBatch) if err != nil { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("list forum topic delete ids: %w", err) } ids := make([]int, 0, domain.MaxDeleteHistoryBatch) for rows.Next() { var id int if err := rows.Scan(&id); err != nil { rows.Close() return domain.DeleteChannelHistoryResult{}, err } ids = append(ids, id) } if err := rows.Err(); err != nil { rows.Close() return domain.DeleteChannelHistoryResult{}, err } rows.Close() deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, ids, req.UserID, req.Date, &reserved) if err != nil { return domain.DeleteChannelHistoryResult{}, err } remaining := 0 if err := tx.QueryRow(ctx, ` SELECT COUNT(*)::int FROM channel_messages WHERE channel_id = $1 AND NOT deleted AND (id = $2 OR reply_to_top_id = $2)`, req.ChannelID, req.TopicID).Scan(&remaining); err != nil { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("count remaining forum topic messages: %w", err) } offset := 0 if remaining > 0 { offset = 1 } else if _, err := tx.Exec(ctx, ` UPDATE channel_forum_topics SET deleted = true, updated_at = now() WHERE channel_id = $1 AND topic_id = $2`, req.ChannelID, req.TopicID); err != nil { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("mark forum topic deleted: %w", err) } if err := tx.Commit(ctx); err != nil { return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit delete forum topic history: %w", err) } committed = true recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) return domain.DeleteChannelHistoryResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, Offset: offset}, nil } func (s *ChannelStore) ListForumTopics(ctx context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) { channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) if err != nil { return domain.ChannelForumTopicList{}, err } if !channel.Forum { return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing } limit := filter.Limit if limit <= 0 || limit > domain.MaxChannelForumTopicsLimit { limit = domain.MaxChannelForumTopicsLimit } query := strings.TrimSpace(strings.ToLower(filter.Query)) countArgs := []any{filter.ChannelID, member.AvailableMinID, query} countSQL := ` SELECT COUNT(*)::int FROM channel_forum_topics WHERE channel_id = $1 AND NOT deleted AND topic_id > $2 AND ($3 = '' OR POSITION($3 IN LOWER(title)) > 0)` var total int if err := s.db.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil { return domain.ChannelForumTopicList{}, fmt.Errorf("count forum topics: %w", err) } args := []any{filter.ChannelID, member.AvailableMinID, query} where := `channel_id = $1 AND NOT deleted AND topic_id > $2 AND ($3 = '' OR POSITION($3 IN LOWER(title)) > 0)` offsetID := filter.OffsetTopic if offsetID == 0 { offsetID = filter.OffsetID } if filter.OffsetDate != 0 { args = append(args, filter.OffsetDate, offsetID) where += fmt.Sprintf(" AND (date, topic_id) < ($%d, $%d)", len(args)-1, len(args)) } else if offsetID != 0 { args = append(args, offsetID) where += fmt.Sprintf(" AND topic_id < $%d", len(args)) } args = append(args, limit) rows, err := s.db.Query(ctx, ` SELECT `+channelForumTopicColumns+` FROM channel_forum_topics WHERE `+where+` ORDER BY pinned DESC, pinned_order DESC, date DESC, topic_id DESC LIMIT $`+fmt.Sprint(len(args)), args...) if err != nil { return domain.ChannelForumTopicList{}, fmt.Errorf("list forum topics: %w", err) } defer rows.Close() topics := make([]domain.ChannelForumTopic, 0, limit) for rows.Next() { topic, err := scanChannelForumTopic(rows) if err != nil { return domain.ChannelForumTopicList{}, err } topics = append(topics, s.topicWithViewerCounters(ctx, viewerUserID, filter.ChannelID, topic, member.ReadInboxMaxID, member.AvailableMinID)) } if err := rows.Err(); err != nil { return domain.ChannelForumTopicList{}, err } messages, err := s.forumTopicRootMessages(ctx, filter.ChannelID, topics, member.AvailableMinID) if err != nil { return domain.ChannelForumTopicList{}, err } dialog, _ := s.getChannelDialog(ctx, s.db, viewerUserID, channel) return domain.ChannelForumTopicList{Channel: channel, Dialog: dialog, Topics: topics, Messages: messages, Count: total}, nil } func (s *ChannelStore) GetForumTopicsByID(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) { channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) if err != nil { return domain.ChannelForumTopicList{}, err } if !channel.Forum { return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing } if len(ids) == 0 { dialog, _ := s.getChannelDialog(ctx, s.db, viewerUserID, channel) return domain.ChannelForumTopicList{Channel: channel, Dialog: dialog}, nil } id32, _, err := validUniqueChannelMessageIDs(ids) if err != nil { return domain.ChannelForumTopicList{}, err } rows, err := s.db.Query(ctx, ` SELECT `+channelForumTopicColumns+` FROM channel_forum_topics WHERE channel_id = $1 AND NOT deleted AND topic_id > $2 AND topic_id = ANY($3::int[]) ORDER BY pinned DESC, pinned_order DESC, date DESC, topic_id DESC`, channelID, member.AvailableMinID, id32) if err != nil { return domain.ChannelForumTopicList{}, fmt.Errorf("get forum topics by id: %w", err) } defer rows.Close() topics := make([]domain.ChannelForumTopic, 0, len(id32)) for rows.Next() { topic, err := scanChannelForumTopic(rows) if err != nil { return domain.ChannelForumTopicList{}, err } topics = append(topics, s.topicWithViewerCounters(ctx, viewerUserID, channelID, topic, member.ReadInboxMaxID, member.AvailableMinID)) } if err := rows.Err(); err != nil { return domain.ChannelForumTopicList{}, err } messages, err := s.forumTopicRootMessages(ctx, channelID, topics, member.AvailableMinID) if err != nil { return domain.ChannelForumTopicList{}, err } dialog, _ := s.getChannelDialog(ctx, s.db, viewerUserID, channel) return domain.ChannelForumTopicList{Channel: channel, Dialog: dialog, Topics: topics, Messages: messages, Count: len(topics)}, nil } func (s *ChannelStore) ListChannelReplies(ctx context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) { source, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) if err != nil { return domain.ChannelHistory{}, err } root, err := s.getChannelMessage(ctx, s.db, filter.ChannelID, filter.RootMessageID) if err != nil || root.Deleted || root.ID <= member.AvailableMinID { return domain.ChannelHistory{}, domain.ErrMessageIDInvalid } target := source availableMinID := member.AvailableMinID extraChannels := []domain.Channel(nil) rootID := root.ID if source.Broadcast { if root.Discussion == nil || root.Discussion.ChannelID == 0 || root.Discussion.MessageID == 0 { return domain.ChannelHistory{Channel: source}, nil } linked, err := getChannelByID(ctx, s.db, root.Discussion.ChannelID) if err != nil { return domain.ChannelHistory{Channel: source}, nil } target = linked rootID = root.Discussion.MessageID availableMinID = 0 if linkedMember, err := s.getChannelMember(ctx, s.db, linked.ID, viewerUserID); err == nil && validateChannelMemberVisible(linkedMember) == nil { availableMinID = linkedMember.AvailableMinID } extraChannels = append(extraChannels, source) } targetRoot, err := s.getChannelMessage(ctx, s.db, target.ID, rootID) if err != nil || targetRoot.Deleted || targetRoot.ID <= availableMinID { return domain.ChannelHistory{Channel: target, Channels: extraChannels}, nil } limit := filter.Limit if limit <= 0 || limit > domain.MaxChannelRepliesLimit { limit = domain.MaxChannelRepliesLimit } filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) count, err := s.countChannelReplies(ctx, target.ID, rootID, availableMinID, filter) if err != nil { return domain.ChannelHistory{}, err } messages, err := s.queryChannelRepliesPage(ctx, target.ID, rootID, availableMinID, filter, limit) if err != nil { return domain.ChannelHistory{}, err } if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, target, messages); err != nil { return domain.ChannelHistory{}, err } if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{target}, messages); err != nil { return domain.ChannelHistory{}, err } topics := []domain.ChannelForumTopic(nil) if target.Forum { if topic, err := s.getForumTopic(ctx, s.db, target.ID, rootID); err == nil && !topic.Hidden { topic = s.topicWithViewerCounters(ctx, viewerUserID, target.ID, topic, availableMinID, availableMinID) topics = append(topics, topic) } else if err != nil && !errors.Is(err, domain.ErrMessageIDInvalid) { return domain.ChannelHistory{}, err } } return domain.ChannelHistory{Channel: target, Channels: extraChannels, Topics: topics, Messages: messages, Count: count}, nil } func (s *ChannelStore) ListChannelUnreadMentions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) { channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) if err != nil { return domain.ChannelHistory{}, err } limit := filter.Limit if limit <= 0 || limit > domain.MaxChannelUnreadMentionsLimit { limit = domain.MaxChannelUnreadMentionsLimit } filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) count, err := s.countChannelUnreadMentions(ctx, viewerUserID, filter, member.AvailableMinID) if err != nil { return domain.ChannelHistory{}, err } messages, err := s.queryChannelUnreadMentionsPage(ctx, viewerUserID, filter, member.AvailableMinID, limit) if err != nil { return domain.ChannelHistory{}, err } if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, messages); err != nil { return domain.ChannelHistory{}, err } if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, messages); err != nil { return domain.ChannelHistory{}, err } return domain.ChannelHistory{Channel: channel, Messages: messages, Count: count}, nil } func (s *ChannelStore) ReadChannelMentions(ctx context.Context, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.ReadChannelMentionsResult{}, fmt.Errorf("read channel mentions: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.ReadChannelMentionsResult{}, fmt.Errorf("begin read channel mentions: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, _, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.ReadChannelMentionsResult{}, err } limit := req.Limit if limit <= 0 || limit > domain.MaxChannelReadMentionsBatch { limit = domain.MaxChannelReadMentionsBatch } cleared, remaining, err := readChannelMentionsTx(ctx, tx, req.UserID, req.ChannelID, req.TopMsgID, limit) if err != nil { return domain.ReadChannelMentionsResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.ReadChannelMentionsResult{}, fmt.Errorf("commit read channel mentions: %w", err) } committed = true offset := 0 if remaining > 0 { offset = 1 } return domain.ReadChannelMentionsResult{ Channel: channel, Cleared: cleared, Remaining: remaining, Offset: offset, ChannelPts: channel.Pts, }, nil } func (s *ChannelStore) ListChannelUnreadReactions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) { channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) if err != nil { return domain.ChannelHistory{}, err } limit := filter.Limit if limit <= 0 || limit > domain.MaxChannelUnreadReactionsLimit { limit = domain.MaxChannelUnreadReactionsLimit } filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) count, err := s.countChannelUnreadReactions(ctx, viewerUserID, filter, member.AvailableMinID) if err != nil { return domain.ChannelHistory{}, err } messages, err := s.queryChannelUnreadReactionsPage(ctx, viewerUserID, filter, member.AvailableMinID, limit) if err != nil { return domain.ChannelHistory{}, err } if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, messages); err != nil { return domain.ChannelHistory{}, err } if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, messages); err != nil { return domain.ChannelHistory{}, err } return domain.ChannelHistory{Channel: channel, Messages: messages, Count: count}, nil } func (s *ChannelStore) ReadChannelReactions(ctx context.Context, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error) { if req.UserID == 0 || req.ChannelID == 0 { return domain.ReadChannelReactionsResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) if !ok { return domain.ReadChannelReactionsResult{}, fmt.Errorf("read channel reactions: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.ReadChannelReactionsResult{}, fmt.Errorf("begin read channel reactions: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() channel, _, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) if err != nil { return domain.ReadChannelReactionsResult{}, err } limit := req.Limit if limit <= 0 || limit > domain.MaxChannelReadReactionsBatch { limit = domain.MaxChannelReadReactionsBatch } cleared, remaining, err := readChannelReactionsTx(ctx, tx, req.UserID, req.ChannelID, req.TopMsgID, limit) if err != nil { return domain.ReadChannelReactionsResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.ReadChannelReactionsResult{}, fmt.Errorf("commit read channel reactions: %w", err) } committed = true offset := 0 if remaining > 0 { offset = 1 } return domain.ReadChannelReactionsResult{ Channel: channel, Cleared: cleared, Remaining: remaining, Offset: offset, ChannelPts: channel.Pts, }, nil } func (s *ChannelStore) GetDiscussionMessage(ctx context.Context, viewerUserID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) { source, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) if err != nil { return domain.ChannelDiscussionMessage{}, err } msg, err := s.getChannelMessage(ctx, s.db, channelID, msgID) if err != nil || msg.Deleted || msg.ID <= member.AvailableMinID { return domain.ChannelDiscussionMessage{}, domain.ErrMessageIDInvalid } result := domain.ChannelDiscussionMessage{PostChannel: source, DiscussionChannel: source, Channels: []domain.Channel{source}} target := source targetMsg := msg if source.Broadcast { if msg.Discussion == nil || msg.Discussion.ChannelID == 0 || msg.Discussion.MessageID == 0 { return result, nil } linked, err := getChannelByID(ctx, s.db, msg.Discussion.ChannelID) if err != nil { return result, nil } linkedMsg, err := s.getChannelMessage(ctx, s.db, linked.ID, msg.Discussion.MessageID) if err != nil || linkedMsg.Deleted { return result, nil } target = linked targetMsg = linkedMsg result.DiscussionChannel = linked result.Channels = []domain.Channel{source, linked} } messages := []domain.ChannelMessage{targetMsg} if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, target, messages); err != nil { return domain.ChannelDiscussionMessage{}, err } if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{target}, messages); err != nil { return domain.ChannelDiscussionMessage{}, err } readInbox, readOutbox := s.channelReadWatermarks(ctx, target.ID, viewerUserID) result.Messages = messages result.ReadInboxMaxID = readInbox result.ReadOutboxMaxID = readOutbox if messages[0].Replies != nil { result.MaxID = messages[0].Replies.MaxID } result.UnreadCount = s.channelThreadUnreadCount(ctx, target.ID, targetMsg.ID, viewerUserID, readInbox) return result, nil } func (s *ChannelStore) ReadChannelHistory(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) { var lastErr error for attempt := 0; attempt < retryableChannelTxAttempts; attempt++ { res, err := s.readChannelHistoryOnce(ctx, req) if err == nil || !isRetryablePostgresTxError(err) || ctx.Err() != nil { return res, err } lastErr = err } return domain.ReadChannelHistoryResult{}, lastErr } func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) { channel, _, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ReadChannelHistoryResult{}, err } maxID := req.MaxID if maxID <= 0 || maxID > channel.TopMessageID { maxID = channel.TopMessageID } beginner, ok := s.db.(txBeginner) if !ok { return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel history: db does not support transactions") } tx, err := beginner.Begin(ctx) if err != nil { return domain.ReadChannelHistoryResult{}, fmt.Errorf("begin read channel history: %w", err) } committed := false defer func() { if !committed { _ = tx.Rollback(ctx) } }() var previous int if err := tx.QueryRow(ctx, `SELECT read_inbox_max_id FROM channel_members WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID).Scan(&previous); err != nil { return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel member state: %w", err) } changed := maxID > previous var outboxUpdates []domain.ChannelReadOutboxUpdate if _, err := tx.Exec(ctx, ` UPDATE channel_members SET read_inbox_date = CASE WHEN read_inbox_max_id < $3 THEN $4 ELSE read_inbox_date END, read_inbox_max_id = GREATEST(read_inbox_max_id, $3), unread_mark = false, updated_at = now() WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, maxID, req.Date); err != nil { return domain.ReadChannelHistoryResult{}, fmt.Errorf("update channel member read: %w", err) } msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID) if changed { outboxUpdates, err = advanceChannelReadOutboxTx(ctx, tx, channel, msg, req.UserID, previous, maxID) if err != nil { return domain.ReadChannelHistoryResult{}, err } } if err := upsertChannelDialogTx(ctx, tx, req.UserID, channel, msg, maxID, 0); err != nil { return domain.ReadChannelHistoryResult{}, err } if err := tx.Commit(ctx); err != nil { return domain.ReadChannelHistoryResult{}, fmt.Errorf("commit read channel history: %w", err) } committed = true dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) if err != nil { return domain.ReadChannelHistoryResult{}, err } return domain.ReadChannelHistoryResult{ ChannelID: req.ChannelID, MaxID: maxID, StillUnreadCount: dialog.UnreadCount, Changed: changed, Pts: channel.Pts, Dialog: dialog, OutboxUpdates: outboxUpdates, }, nil } func advanceChannelReadOutboxTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, top domain.ChannelMessage, readerUserID int64, previous, maxID int) ([]domain.ChannelReadOutboxUpdate, error) { if maxID <= previous { return nil, nil } lowerID := previous if maxID-lowerID > domain.MaxChannelReadOutboxScanMessages { lowerID = maxID - domain.MaxChannelReadOutboxScanMessages } rows, err := tx.Query(ctx, ` WITH latest_sender_messages AS ( SELECT sender_user_id, MAX(id) AS max_id FROM channel_messages WHERE channel_id = $1 AND id > $2 AND id <= $3 AND NOT deleted AND sender_user_id <> $4 GROUP BY sender_user_id ORDER BY max_id DESC LIMIT $5 ) SELECT sender_user_id, max_id FROM latest_sender_messages ORDER BY sender_user_id ASC`, channel.ID, lowerID, maxID, readerUserID, domain.MaxChannelReadOutboxFanout) if err != nil { return nil, fmt.Errorf("list channel read outbox senders: %w", err) } defer rows.Close() type candidate struct { userID int64 maxID int } candidates := make([]candidate, 0, domain.MaxChannelReadOutboxFanout) for rows.Next() { var item candidate if err := rows.Scan(&item.userID, &item.maxID); err != nil { return nil, err } candidates = append(candidates, item) } if err := rows.Err(); err != nil { return nil, err } out := make([]domain.ChannelReadOutboxUpdate, 0, len(candidates)) for _, item := range candidates { var readOutboxMaxID, readInboxMaxID int err := tx.QueryRow(ctx, ` UPDATE channel_members SET read_outbox_max_id = GREATEST(read_outbox_max_id, $3), updated_at = now() WHERE channel_id = $1 AND user_id = $2 AND status = 'active' AND read_outbox_max_id < $3 RETURNING read_outbox_max_id, read_inbox_max_id`, channel.ID, item.userID, item.maxID).Scan(&readOutboxMaxID, &readInboxMaxID) if errors.Is(err, pgx.ErrNoRows) { continue } if err != nil { return nil, fmt.Errorf("update channel sender read outbox: %w", err) } if err := upsertChannelDialogTx(ctx, tx, item.userID, channel, top, readInboxMaxID, readOutboxMaxID); err != nil { return nil, err } out = append(out, domain.ChannelReadOutboxUpdate{UserID: item.userID, MaxID: readOutboxMaxID}) } return out, nil } func (s *ChannelStore) ListMessageReadParticipants(ctx context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) { channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ChannelReadParticipantsResult{}, err } if req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { return domain.ChannelReadParticipantsResult{}, domain.ErrMessageIDInvalid } msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID) if err != nil { return domain.ChannelReadParticipantsResult{}, err } if msg.Deleted || msg.ID <= member.AvailableMinID { return domain.ChannelReadParticipantsResult{}, domain.ErrMessageIDInvalid } result := domain.ChannelReadParticipantsResult{Channel: channel, Message: msg} if !channel.Megagroup || channel.ParticipantsHidden || channel.ParticipantsCount > domain.MaxChannelReadParticipants { return result, nil } if req.Date > 0 && msg.Date+domain.ChannelReadMarkExpirePeriod <= req.Date { return result, nil } limit := req.Limit if limit <= 0 || limit > domain.MaxChannelReadParticipants { limit = domain.MaxChannelReadParticipants } rows, err := s.db.Query(ctx, ` SELECT user_id, read_inbox_date FROM channel_members WHERE channel_id = $1 AND status = 'active' AND user_id <> $2 AND available_min_id < $3 AND read_inbox_max_id >= $3 AND read_inbox_date > 0 AND NOT COALESCE((banned_rights->>'ViewMessages')::boolean, false) ORDER BY read_inbox_date ASC, user_id ASC LIMIT $4`, req.ChannelID, req.UserID, req.MessageID, limit) if err != nil { return domain.ChannelReadParticipantsResult{}, fmt.Errorf("list channel read participants: %w", err) } defer rows.Close() for rows.Next() { var item domain.ChannelReadParticipant if err := rows.Scan(&item.UserID, &item.Date); err != nil { return domain.ChannelReadParticipantsResult{}, err } result.Participants = append(result.Participants, item) } if err := rows.Err(); err != nil { return domain.ChannelReadParticipantsResult{}, err } return result, nil } func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) { channel, member, preview, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ChannelDifference{}, err } if req.Pts < 0 || req.Pts > channel.Pts { return domain.ChannelDifference{}, domain.ErrPersistentTimestamp } if !preview && member.AvailableMinPts > req.Pts { req.Pts = minInt(member.AvailableMinPts, channel.Pts) } limit := req.Limit if limit <= 0 || limit > domain.MaxChannelDifferenceLimit { limit = domain.MaxChannelDifferenceLimit } if channel.Pts-req.Pts > limit { args := []any{req.ChannelID} where := "channel_id = $1 AND NOT deleted" if member.AvailableMinID > 0 { args = append(args, member.AvailableMinID) where += fmt.Sprintf(" AND id > $%d", len(args)) } args = append(args, domain.MaxChannelDifferenceTooLongMessages) rows, err := s.db.Query(ctx, ` SELECT `+channelMessageColumns+` FROM channel_messages WHERE `+where+` ORDER BY id DESC LIMIT $`+fmt.Sprint(len(args)), args...) if err != nil { return domain.ChannelDifference{}, fmt.Errorf("list channel too long messages: %w", err) } defer rows.Close() diff := domain.ChannelDifference{ Channel: channel, Self: member, Pts: channel.Pts, Final: true, TooLong: true, Timeout: 30, } for rows.Next() { msg, err := scanChannelMessage(rows) if err != nil { return domain.ChannelDifference{}, err } diff.NewMessages = append(diff.NewMessages, msg) } if err := rows.Err(); err != nil { return domain.ChannelDifference{}, err } if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages); err != nil { return domain.ChannelDifference{}, err } if preview { diff.Dialog = previewChannelDialog(req.UserID, channel, member) } else { dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) if err != nil { return domain.ChannelDifference{}, err } diff.Dialog = dialog } return diff, nil } rows, err := s.db.Query(ctx, ` SELECT channel_id, pts, pts_count, date, event_type, message_id, message_ids::text, sender_user_id, user_ids::text, payload::text FROM channel_update_events WHERE channel_id = $1 AND pts > $2 ORDER BY pts ASC LIMIT $3`, req.ChannelID, req.Pts, limit) if err != nil { return domain.ChannelDifference{}, fmt.Errorf("list channel difference: %w", err) } defer rows.Close() diff := domain.ChannelDifference{Channel: channel, Self: member, Pts: channel.Pts, Final: true, Timeout: 30} userRefs := make(map[int64]struct{}) channelRefs := make(map[int64]struct{}) lastPts := req.Pts for rows.Next() { event, messageID, err := scanChannelEvent(rows) if err != nil { return domain.ChannelDifference{}, err } lastPts = event.Pts if messageID != 0 && event.Message.ID == 0 { msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, messageID) if err != nil { return domain.ChannelDifference{}, err } event.Message = msg } visibleEvent, ok := domain.FilterChannelUpdateEventForAvailableMinID(event, member.AvailableMinID) if !ok { continue } event = visibleEvent if preview && event.Type == domain.ChannelUpdateParticipant { continue } collectChannelEventRefs(event, req.ChannelID, userRefs, channelRefs) diff.Events = append(diff.Events, event) diff.Pts = event.Pts switch event.Type { case domain.ChannelUpdateNewMessage: diff.NewMessages = append(diff.NewMessages, event.Message) default: diff.OtherUpdates = append(diff.OtherUpdates, event) } } if err := rows.Err(); err != nil { return domain.ChannelDifference{}, err } if len(diff.Events) == 0 { diff.Pts = lastPts } else if lastPts > diff.Pts { diff.Pts = lastPts } if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages); err != nil { return domain.ChannelDifference{}, err } for i := range diff.OtherUpdates { if diff.OtherUpdates[i].Message.ID == 0 { continue } messages := []domain.ChannelMessage{diff.OtherUpdates[i].Message} if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, messages); err != nil { return domain.ChannelDifference{}, err } diff.OtherUpdates[i].Message = messages[0] } users, err := listUsersByIDs(ctx, s.db, mapKeysInt64(userRefs)) if err != nil { return domain.ChannelDifference{}, err } channels, err := listChannelsByIDs(ctx, s.db, mapKeysInt64(channelRefs)) if err != nil { return domain.ChannelDifference{}, err } diff.Users = users diff.Channels = channels if preview { diff.Dialog = previewChannelDialog(req.UserID, channel, member) } else { dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) if err != nil { return domain.ChannelDifference{}, err } diff.Dialog = dialog } diff.Final = lastPts >= channel.Pts return diff, nil } func (s *ChannelStore) ListActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) { if userID == 0 || afterChannelID < 0 { return nil, domain.ErrChannelInvalid } if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { limit = domain.MaxSynchronousChannelDialogFanout } rows, err := s.db.Query(ctx, ` SELECT channel_id FROM channel_members WHERE user_id = $1 AND status = 'active' AND channel_id > $2 ORDER BY channel_id LIMIT $3`, userID, afterChannelID, limit) if err != nil { return nil, fmt.Errorf("list active channel ids for user: %w", err) } defer rows.Close() out := make([]int64, 0, limit) for rows.Next() { var channelID int64 if err := rows.Scan(&channelID); err != nil { return nil, err } out = append(out, channelID) } return out, rows.Err() } func (s *ChannelStore) ListDirtyActiveChannelsForUser(ctx context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error) { if userID == 0 || sinceDate <= 0 || afterChannelID < 0 { return nil, domain.ErrChannelInvalid } if limit <= 0 || limit > domain.MaxChannelDifferenceLimit { limit = domain.MaxChannelDifferenceLimit } rows, err := s.db.Query(ctx, ` SELECT i.channel_id, c.pts FROM user_channel_member_index i JOIN channels c ON c.id = i.channel_id AND NOT c.deleted WHERE i.user_id = $1 AND i.status = 'active' AND NOT i.deleted AND i.channel_id > $3 AND EXISTS ( SELECT 1 FROM channel_update_events e WHERE e.channel_id = i.channel_id AND e.date > $2 LIMIT 1 ) ORDER BY i.channel_id ASC LIMIT $4`, userID, sinceDate, afterChannelID, limit) if err != nil { return nil, fmt.Errorf("list dirty active channels for user: %w", err) } defer rows.Close() out := make([]domain.DirtyChannel, 0, limit) for rows.Next() { var item domain.DirtyChannel if err := rows.Scan(&item.ChannelID, &item.Pts); err != nil { return nil, err } out = append(out, item) } return out, rows.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) ListChannelInviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error) { if channelID == 0 { return nil, domain.ErrChannelInvalid } 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' AND ( role = 'creator' OR (role = 'admin' AND ( (admin_rights->>'InviteUsers')::boolean IS TRUE OR (admin_rights->>'ChangeInfo')::boolean IS TRUE )) ) ORDER BY user_id LIMIT $2`, channelID, limit) if err != nil { return nil, fmt.Errorf("list channel invite admin 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) 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) MaxChannelPts(ctx context.Context, channelID int64) (int, error) { var pts int err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(pts), 0) FROM channel_update_events WHERE channel_id = $1`, channelID).Scan(&pts) return pts, err } func (s *ChannelStore) MaxChannelMessageID(ctx context.Context, channelID int64) (int, error) { var id int err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1`, channelID).Scan(&id) return id, err } const channelColumns = `c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(c.username, ''), c.broadcast, c.megagroup, c.forum, c.forum_tabs, c.autotranslation, c.restricted_sponsored, c.broadcast_messages_allowed, c.send_paid_messages_stars, c.noforwards, c.join_to_send, c.join_request, c.signatures, c.pre_history_hidden, c.participants_hidden, c.antispam, c.linked_chat_id, c.slowmode_seconds, c.default_banned_rights::text, c.available_reactions::text, c.color_set, c.color, c.color_background_emoji_id, c.profile_color_set, c.profile_color, c.profile_color_background_emoji_id, c.emoji_status_document_id, c.emoji_status_until, c.participants_count, c.admins_count, c.kicked_count, c.banned_count, c.top_message_id, c.pinned_message_id, c.pts, c.ttl_period, c.date, c.deleted, c.photo_id, c.photo_dc_id, c.photo_stripped` const channelMessageColumns = `channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id, send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body, entities::text, reply_to::text, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, fwd_from::text, discussion_channel_id, discussion_message_id, action::text, pts, deleted, media::text` const channelForumTopicColumns = `channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id, title_missing, closed, hidden, pinned, pinned_order, date, top_message_id, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mentions_count, unread_reactions_count, unread_poll_votes_count` type rowScanner interface { Scan(dest ...any) error } func (s *ChannelStore) getChannelForMember(ctx context.Context, db sqlcgen.DBTX, viewerUserID, channelID int64) (domain.Channel, domain.ChannelMember, error) { row := db.QueryRow(ctx, ` SELECT `+channelColumns+`, 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.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date FROM channels c JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1 WHERE c.id = $2 AND NOT c.deleted`, viewerUserID, channelID) ch, member, err := scanChannelWithMember(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelPrivate } return domain.Channel{}, domain.ChannelMember{}, err } if err := validateChannelMemberVisible(member); err != nil { return domain.Channel{}, domain.ChannelMember{}, err } return ch, member, nil } func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX, viewerUserID, channelID int64) (domain.Channel, domain.ChannelMember, bool, error) { ch, member, err := s.getChannelForMember(ctx, db, viewerUserID, channelID) if err == nil { return ch, member, false, nil } if !errors.Is(err, domain.ErrChannelPrivate) { return domain.Channel{}, domain.ChannelMember{}, false, err } ch, err = getChannelByID(ctx, db, channelID) if err != nil { return domain.Channel{}, domain.ChannelMember{}, false, err } if !publicPreviewableChannel(ch) { return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate } member, err = getPublicPreviewMember(ctx, db, viewerUserID, ch) if err != nil { return domain.Channel{}, domain.ChannelMember{}, false, err } return ch, member, true, nil } func getPublicPreviewMember(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, ch domain.Channel) (domain.ChannelMember, error) { member, err := scanChannelMember(db.QueryRow(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, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date FROM channel_members WHERE channel_id = $1 AND user_id = $2`, ch.ID, viewerUserID)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return publicPreviewMember(ch, viewerUserID, domain.ChannelMember{}, false), nil } return domain.ChannelMember{}, err } if member.Status == domain.ChannelMemberBanned || member.Status == domain.ChannelMemberKicked || member.BannedRights.ViewMessages { return domain.ChannelMember{}, domain.ErrChannelUserBanned } return publicPreviewMember(ch, viewerUserID, member, true), nil } func getChannelByID(ctx context.Context, db sqlcgen.DBTX, channelID int64) (domain.Channel, error) { ch, err := scanChannel(db.QueryRow(ctx, `SELECT `+channelColumns+` FROM channels c WHERE c.id = $1 AND NOT c.deleted`, channelID)) if errors.Is(err, pgx.ErrNoRows) { return domain.Channel{}, domain.ErrChannelInvalid } return ch, err } func listChannelsByIDs(ctx context.Context, db sqlcgen.DBTX, ids []int64) ([]domain.Channel, error) { if len(ids) == 0 { return nil, nil } rows, err := db.Query(ctx, `SELECT `+channelColumns+` FROM channels c WHERE c.id = ANY($1::bigint[]) AND NOT c.deleted ORDER BY c.id ASC`, ids) if err != nil { return nil, fmt.Errorf("list channels by ids: %w", err) } defer rows.Close() out := make([]domain.Channel, 0, len(ids)) for rows.Next() { ch, err := scanChannel(rows) if err != nil { return nil, err } out = append(out, ch) } if err := rows.Err(); err != nil { return nil, err } return out, nil } func listUsersByIDs(ctx context.Context, db sqlcgen.DBTX, ids []int64) ([]domain.User, error) { if len(ids) == 0 { return nil, nil } rows, err := db.Query(ctx, ` SELECT id, access_hash, phone, first_name, last_name, username, country_code, verified, support FROM users WHERE id = ANY($1::bigint[]) ORDER BY id ASC`, ids) if err != nil { return nil, fmt.Errorf("list users by ids: %w", err) } defer rows.Close() out := make([]domain.User, 0, len(ids)) for rows.Next() { var u domain.User if err := rows.Scan(&u.ID, &u.AccessHash, &u.Phone, &u.FirstName, &u.LastName, &u.Username, &u.CountryCode, &u.Verified, &u.Support); err != nil { return nil, err } out = append(out, u) } if err := rows.Err(); err != nil { return nil, err } return out, nil } func (s *ChannelStore) getChannelMember(ctx context.Context, db sqlcgen.DBTX, channelID, userID int64) (domain.ChannelMember, error) { row := db.QueryRow(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, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date FROM channel_members WHERE channel_id = $1 AND user_id = $2`, channelID, userID) member, err := scanChannelMember(row) if errors.Is(err, pgx.ErrNoRows) { return domain.ChannelMember{}, domain.ErrChannelPrivate } return member, err } func (s *ChannelStore) getChannelDialog(ctx context.Context, db sqlcgen.DBTX, userID int64, channel domain.Channel) (domain.ChannelDialog, error) { dialog := domain.ChannelDialog{UserID: userID, ChannelID: channel.ID, TopMessageID: channel.TopMessageID} var defaultSendAsType sql.NullString var defaultSendAsID sql.NullInt64 visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" visibleReadInbox := "COALESCE(d.read_inbox_max_id, m.read_inbox_max_id)" visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) err := db.QueryRow(ctx, ` SELECT `+visibleTopID+`, COALESCE(d.top_message_date, c.date), COALESCE(d.folder_id, 0), `+visibleReadInbox+`, COALESCE(d.read_outbox_max_id, m.read_outbox_max_id), `+visibleUnreadCount+`, COALESCE(d.pinned, false), COALESCE(d.pinned_order, 0), COALESCE(d.unread_mark, m.unread_mark), COALESCE(d.unread_mentions_count, 0), COALESCE(d.unread_reactions_count, 0), COALESCE(d.view_forum_as_messages, false), d.default_send_as_peer_type, d.default_send_as_peer_id FROM channels c JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1 LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id WHERE c.id = $2`, userID, channel.ID).Scan( &dialog.TopMessageID, &dialog.TopMessageDate, &dialog.FolderID, &dialog.ReadInboxMaxID, &dialog.ReadOutboxMaxID, &dialog.UnreadCount, &dialog.Pinned, &dialog.PinnedOrder, &dialog.UnreadMark, &dialog.UnreadMentions, &dialog.UnreadReactions, &dialog.ViewForumAsMessages, &defaultSendAsType, &defaultSendAsID, ) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return domain.ChannelDialog{}, fmt.Errorf("get channel dialog: %w", err) } if defaultSendAsType.Valid && defaultSendAsID.Valid && defaultSendAsID.Int64 != 0 { dialog.DefaultSendAs = &domain.Peer{Type: domain.PeerType(defaultSendAsType.String), ID: defaultSendAsID.Int64} } if dialog.TopMessageID != 0 { if msg, err := s.getChannelMessage(ctx, db, channel.ID, dialog.TopMessageID); err == nil { dialog.TopMessageDate = msg.Date } } return dialog, nil } func (s *ChannelStore) getChannelMessage(ctx context.Context, db sqlcgen.DBTX, channelID int64, id int) (domain.ChannelMessage, error) { if channelID == 0 || id == 0 { return domain.ChannelMessage{}, pgx.ErrNoRows } msg, err := scanChannelMessage(db.QueryRow(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages WHERE channel_id = $1 AND id = $2`, channelID, id)) if errors.Is(err, pgx.ErrNoRows) { return domain.ChannelMessage{}, domain.ErrMessageIDInvalid } return msg, err } func (s *ChannelStore) getForumTopic(ctx context.Context, db sqlcgen.DBTX, channelID int64, topicID int) (domain.ChannelForumTopic, error) { if channelID == 0 || topicID == 0 { return domain.ChannelForumTopic{}, domain.ErrMessageIDInvalid } topic, err := scanChannelForumTopic(db.QueryRow(ctx, ` SELECT `+channelForumTopicColumns+` FROM channel_forum_topics WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, channelID, topicID)) if errors.Is(err, pgx.ErrNoRows) { return domain.ChannelForumTopic{}, domain.ErrMessageIDInvalid } return topic, err } func (s *ChannelStore) forumTopicRootMessages(ctx context.Context, channelID int64, topics []domain.ChannelForumTopic, availableMinID int) ([]domain.ChannelMessage, error) { if len(topics) == 0 { return nil, nil } ids := make([]int, 0, len(topics)) seen := make(map[int]struct{}, len(topics)) for _, topic := range topics { if topic.TopMessageID <= 0 { continue } if _, ok := seen[topic.TopMessageID]; ok { continue } seen[topic.TopMessageID] = struct{}{} ids = append(ids, topic.TopMessageID) } id32, _, err := validUniqueChannelMessageIDs(ids) if err != nil { return nil, err } if len(id32) == 0 { return nil, nil } rows, err := s.db.Query(ctx, ` SELECT `+channelMessageColumns+` FROM channel_messages WHERE channel_id = $1 AND id = ANY($2::int[]) AND id > $3 AND NOT deleted ORDER BY id DESC`, channelID, id32, availableMinID) if err != nil { return nil, fmt.Errorf("list forum topic root messages: %w", err) } defer rows.Close() messages := make([]domain.ChannelMessage, 0, len(id32)) for rows.Next() { msg, err := scanChannelMessage(rows) if err != nil { return nil, err } messages = append(messages, msg) } if err := rows.Err(); err != nil { return nil, err } return messages, nil } func (s *ChannelStore) nextForumTopicPinnedOrder(ctx context.Context, channelID int64) (int, error) { var maxOrder int if err := s.db.QueryRow(ctx, ` SELECT COALESCE(MAX(pinned_order), 0)::int FROM channel_forum_topics WHERE channel_id = $1 AND pinned AND NOT deleted`, channelID).Scan(&maxOrder); err != nil { return 0, fmt.Errorf("next forum topic pinned order: %w", err) } return maxOrder + 1, nil } type channelReplyStatKey struct { channelID int64 rootID int } type channelReactionMessageKey struct { channelID int64 messageID int } type channelReactionCursor struct { date int userID int64 emoticon string } func emptyChannelMessageReactions(channel domain.Channel) domain.ChannelMessageReactions { return domain.ChannelMessageReactions{ CanSeeList: !channel.Broadcast || channel.Megagroup, Results: []domain.ChannelMessageReactionCount{}, Recent: []domain.ChannelMessagePeerReaction{}, } } func (s *ChannelStore) populateChannelMessagesReactions(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, channels []domain.Channel, messages []domain.ChannelMessage) error { if len(messages) == 0 { return nil } if err := populateChannelMessageUnreadFlags(ctx, db, viewerUserID, messages); err != nil { return err } channelsByID := make(map[int64]domain.Channel, len(channels)) for _, ch := range channels { if ch.ID != 0 { channelsByID[ch.ID] = ch } } indexes := make(map[channelReactionMessageKey][]int) idsByChannel := make(map[int64][]int32) for i := range messages { if messages[i].ChannelID == 0 || messages[i].ID <= 0 { continue } key := channelReactionMessageKey{channelID: messages[i].ChannelID, messageID: messages[i].ID} if _, ok := indexes[key]; !ok { idsByChannel[messages[i].ChannelID] = append(idsByChannel[messages[i].ChannelID], int32(messages[i].ID)) } indexes[key] = append(indexes[key], i) } for channelID, ids := range idsByChannel { ch := channelsByID[channelID] if ch.ID == 0 { var err error ch, err = getChannelByID(ctx, db, channelID) if err != nil { return err } channelsByID[channelID] = ch } rows, err := db.Query(ctx, ` SELECT message_id, reaction_type, reaction_value, COUNT(*)::int, COALESCE(MAX(CASE WHEN reacted_user_id = $3 THEN chosen_order ELSE 0 END), 0)::int, COALESCE(MAX(reaction_date), 0)::int FROM channel_message_reactions WHERE channel_id = $1 AND message_id = ANY($2::int[]) GROUP BY message_id, reaction_type, reaction_value ORDER BY message_id ASC, COUNT(*) DESC, COALESCE(MAX(reaction_date), 0) DESC, reaction_value ASC`, channelID, ids, viewerUserID) if err != nil { return fmt.Errorf("load channel message reaction counts: %w", err) } for rows.Next() { var msgID int var reactionType, reactionValue string var count, chosenOrder, latestDate int if err := rows.Scan(&msgID, &reactionType, &reactionValue, &count, &chosenOrder, &latestDate); err != nil { rows.Close() return err } _ = latestDate key := channelReactionMessageKey{channelID: channelID, messageID: msgID} for _, idx := range indexes[key] { if messages[idx].Reactions == nil { reactions := emptyChannelMessageReactions(ch) messages[idx].Reactions = &reactions } messages[idx].Reactions.Results = append(messages[idx].Reactions.Results, domain.ChannelMessageReactionCount{ Reaction: domain.MessageReaction{ Type: domain.MessageReactionType(reactionType), Emoticon: reactionValue, }, Count: count, ChosenOrder: chosenOrder, }) } } if err := rows.Err(); err != nil { rows.Close() return err } rows.Close() rows, err = db.Query(ctx, ` SELECT channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, big, unread, chosen_order, reaction_date FROM ( SELECT channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, big, unread, chosen_order, reaction_date, row_number() OVER ( PARTITION BY message_id ORDER BY reaction_date DESC, reacted_user_id DESC, reaction_value ASC ) AS rn FROM channel_message_reactions WHERE channel_id = $1 AND message_id = ANY($2::int[]) ) ranked WHERE rn <= $3 ORDER BY message_id ASC, reaction_date DESC, reacted_user_id DESC, reaction_value ASC`, channelID, ids, domain.MaxChannelMessageReactionRecent) if err != nil { return fmt.Errorf("load channel message recent reactions: %w", err) } for rows.Next() { row, err := scanChannelMessagePeerReaction(rows, viewerUserID) if err != nil { rows.Close() return err } key := channelReactionMessageKey{channelID: row.ChannelID, messageID: row.MessageID} for _, idx := range indexes[key] { if messages[idx].Reactions == nil { reactions := emptyChannelMessageReactions(ch) messages[idx].Reactions = &reactions } messages[idx].Reactions.Recent = append(messages[idx].Reactions.Recent, row) } } if err := rows.Err(); err != nil { rows.Close() return err } rows.Close() } return nil } func populateChannelMessageUnreadFlags(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, messages []domain.ChannelMessage) error { if viewerUserID == 0 || len(messages) == 0 { return nil } indexes := make(map[channelReactionMessageKey][]int) idsByChannel := make(map[int64][]int32) for i := range messages { if messages[i].ChannelID == 0 || messages[i].ID <= 0 { continue } key := channelReactionMessageKey{channelID: messages[i].ChannelID, messageID: messages[i].ID} if _, ok := indexes[key]; !ok { idsByChannel[messages[i].ChannelID] = append(idsByChannel[messages[i].ChannelID], int32(messages[i].ID)) } indexes[key] = append(indexes[key], i) } for channelID, ids := range idsByChannel { rows, err := db.Query(ctx, ` SELECT message_id, COALESCE(media_unread, false) FROM channel_unread_mentions WHERE user_id = $1 AND channel_id = $2 AND message_id = ANY($3::int[])`, viewerUserID, channelID, ids) if err != nil { return fmt.Errorf("load channel message unread flags: %w", err) } for rows.Next() { var messageID int var mediaUnread bool if err := rows.Scan(&messageID, &mediaUnread); err != nil { rows.Close() return err } key := channelReactionMessageKey{channelID: channelID, messageID: messageID} for _, idx := range indexes[key] { messages[idx].Mentioned = true messages[idx].MediaUnread = mediaUnread } } if err := rows.Err(); err != nil { rows.Close() return err } rows.Close() } return nil } func channelReactionOffset(row domain.ChannelMessagePeerReaction) string { return strconv.Itoa(row.Date) + ":" + strconv.FormatInt(row.UserID, 10) + ":" + row.Reaction.Emoticon } func parseChannelReactionOffset(offset string) (channelReactionCursor, bool) { parts := strings.SplitN(offset, ":", 3) if len(parts) != 3 { return channelReactionCursor{}, false } date, err := strconv.Atoi(parts[0]) if err != nil || date < 0 { return channelReactionCursor{}, false } userID, err := strconv.ParseInt(parts[1], 10, 64) if err != nil || userID < 0 { return channelReactionCursor{}, false } return channelReactionCursor{date: date, userID: userID, emoticon: parts[2]}, true } func (s *ChannelStore) populateChannelMessageReplies(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, channel domain.Channel, messages []domain.ChannelMessage) error { if len(messages) == 0 || channel.ID == 0 { return nil } indexes := make(map[channelReplyStatKey][]int) rootsByChannel := make(map[int64][]int32) readMaxByChannel := make(map[int64]int) for i := range messages { targetChannelID := channel.ID rootID := messages[i].ID replies := &domain.ChannelMessageReplies{} if messages[i].Discussion != nil && messages[i].Discussion.ChannelID != 0 && messages[i].Discussion.MessageID != 0 { targetChannelID = messages[i].Discussion.ChannelID rootID = messages[i].Discussion.MessageID replies.Comments = true replies.ChannelID = messages[i].Discussion.ChannelID } else if channel.Broadcast && channel.LinkedChatID != 0 && messages[i].Post { replies.Comments = true replies.ChannelID = channel.LinkedChatID } if _, ok := readMaxByChannel[targetChannelID]; !ok { readInbox, _ := s.channelReadWatermarks(ctx, targetChannelID, viewerUserID) readMaxByChannel[targetChannelID] = readInbox } replies.ReadMaxID = readMaxByChannel[targetChannelID] key := channelReplyStatKey{channelID: targetChannelID, rootID: rootID} if _, ok := indexes[key]; !ok { rootsByChannel[targetChannelID] = append(rootsByChannel[targetChannelID], int32(rootID)) } indexes[key] = append(indexes[key], i) if replies.Comments { messages[i].Replies = replies } } for channelID, roots := range rootsByChannel { rows, err := db.Query(ctx, ` SELECT reply_to_top_id, COUNT(*)::int, COALESCE(MAX(id), 0)::int, COALESCE((array_agg(pts ORDER BY id DESC))[1], 0)::int FROM channel_messages WHERE channel_id = $1 AND reply_to_top_id = ANY($2::int[]) AND NOT deleted GROUP BY reply_to_top_id`, channelID, roots) if err != nil { return fmt.Errorf("load channel reply stats: %w", err) } for rows.Next() { var rootID, count, maxID, repliesPts int if err := rows.Scan(&rootID, &count, &maxID, &repliesPts); err != nil { rows.Close() return err } for _, idx := range indexes[channelReplyStatKey{channelID: channelID, rootID: rootID}] { replies := messages[idx].Replies if replies == nil { replies = &domain.ChannelMessageReplies{ReadMaxID: readMaxByChannel[channelID]} } replies.Replies = count replies.MaxID = maxID replies.RepliesPts = repliesPts messages[idx].Replies = replies } } if err := rows.Err(); err != nil { rows.Close() return err } rows.Close() } return nil } func (s *ChannelStore) channelReadWatermarks(ctx context.Context, channelID, userID int64) (int, int) { var inbox, outbox int _ = s.db.QueryRow(ctx, `SELECT read_inbox_max_id, read_outbox_max_id FROM channel_members WHERE channel_id = $1 AND user_id = $2`, channelID, userID).Scan(&inbox, &outbox) return inbox, outbox } func (s *ChannelStore) channelThreadUnreadCount(ctx context.Context, channelID int64, rootID int, viewerUserID int64, readMaxID int) int { var count int _ = s.db.QueryRow(ctx, ` SELECT COUNT(*)::int FROM channel_messages WHERE channel_id = $1 AND reply_to_top_id = $2 AND id > $3 AND sender_user_id <> $4 AND NOT deleted`, channelID, rootID, readMaxID, viewerUserID).Scan(&count) return count } func countChannelUnreadMessages(ctx context.Context, db sqlcgen.DBTX, userID, channelID int64, readMaxID, topID int) (int, error) { if userID == 0 || channelID == 0 || topID <= readMaxID { return 0, nil } var count int if err := db.QueryRow(ctx, ` SELECT COUNT(*)::int FROM channel_messages WHERE channel_id = $1 AND id > $2 AND id <= $3 AND sender_user_id <> $4 AND NOT deleted`, channelID, readMaxID, topID, userID).Scan(&count); err != nil { return 0, fmt.Errorf("count channel unread messages: %w", err) } return count, nil } func (s *ChannelStore) topicWithViewerCounters(ctx context.Context, viewerUserID, channelID int64, topic domain.ChannelForumTopic, readMaxID, availableMinID int) domain.ChannelForumTopic { topic.UnreadCount = s.channelThreadUnreadCount(ctx, channelID, topic.TopicID, viewerUserID, readMaxID) topic.UnreadMentionsCount = s.countChannelUnreadMentionsForTop(ctx, viewerUserID, channelID, topic.TopicID) topic.UnreadReactionsCount = s.countChannelUnreadReactionsForTop(ctx, viewerUserID, channelID, topic.TopicID, availableMinID) return topic } func (s *ChannelStore) countChannelUnreadMentionsForTop(ctx context.Context, userID, channelID int64, topMsgID int) int { var count int _ = s.db.QueryRow(ctx, ` SELECT COUNT(*)::int FROM channel_unread_mentions WHERE user_id = $1 AND channel_id = $2 AND top_message_id = $3`, userID, channelID, topMsgID).Scan(&count) return count } func (s *ChannelStore) countChannelUnreadReactionsForTop(ctx context.Context, userID, channelID int64, topMsgID, availableMinID int) int { var count int _ = s.db.QueryRow(ctx, ` SELECT COUNT(DISTINCT r.message_id)::int FROM channel_message_reactions r JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id WHERE r.sender_user_id = $1 AND r.channel_id = $2 AND r.unread AND r.reacted_user_id <> $1 AND cm.id > $4 AND NOT cm.deleted AND (cm.id = $3 OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $3)`, userID, channelID, topMsgID, availableMinID).Scan(&count) return count } func (s *ChannelStore) countChannelReplies(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter) (int, error) { where, args := channelRepliesBaseWhere(channelID, rootID, availableMinID, filter) var count int if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM channel_messages WHERE `+where, args...).Scan(&count); err != nil { return 0, fmt.Errorf("count channel replies: %w", err) } return count, nil } func (s *ChannelStore) countChannelUnreadMentions(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID int) (int, error) { where, args := channelUnreadMentionBaseWhere(userID, filter, availableMinID) var count int if err := s.db.QueryRow(ctx, ` SELECT COUNT(*)::int FROM channel_unread_mentions um JOIN channel_messages cm ON cm.channel_id = um.channel_id AND cm.id = um.message_id WHERE `+where, args...).Scan(&count); err != nil { return 0, fmt.Errorf("count channel unread mentions: %w", err) } return count, nil } func (s *ChannelStore) queryChannelUnreadMentionsPage(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { if limit <= 0 { return nil, nil } switch messageHistoryLoadType(filter.AddOffset, limit) { case messageHistoryLoadForward: return s.queryChannelUnreadMentionsForward(ctx, userID, filter, availableMinID, limit) case messageHistoryLoadAround: forwardLimit := -filter.AddOffset if forwardLimit > limit { forwardLimit = limit } backwardLimit := limit + filter.AddOffset if backwardLimit < 0 { backwardLimit = 0 } forward, err := s.queryChannelUnreadMentionsForward(ctx, userID, filter, availableMinID, forwardLimit) if err != nil { return nil, err } backward, err := s.queryChannelUnreadMentionsBackward(ctx, userID, filter, availableMinID, backwardLimit, true) if err != nil { return nil, err } out := append(forward, backward...) sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) return out, nil default: start := filter.AddOffset if start < 0 { start = 0 } items, err := s.queryChannelUnreadMentionsBackward(ctx, userID, filter, availableMinID, limit+start, false) if err != nil || start >= len(items) { return nil, err } return items[start:], nil } } func (s *ChannelStore) queryChannelUnreadMentionsBackward(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID, limit int, includeOffset bool) ([]domain.ChannelMessage, error) { if limit <= 0 { return nil, nil } where, args := channelUnreadMentionBaseWhere(userID, filter, availableMinID) where, args = appendChannelUnreadMentionBackwardOffset(where, args, filter, includeOffset) args = append(args, limit) return s.queryChannelUnreadMentions(ctx, filter.ChannelID, where, args, "DESC") } func (s *ChannelStore) queryChannelUnreadMentionsForward(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { if limit <= 0 { return nil, nil } where, args := channelUnreadMentionBaseWhere(userID, filter, availableMinID) where, args = appendChannelUnreadMentionForwardOffset(where, args, filter) args = append(args, limit) out, err := s.queryChannelUnreadMentions(ctx, filter.ChannelID, where, args, "ASC") if err != nil { return nil, err } sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) return out, nil } func (s *ChannelStore) queryChannelUnreadMentions(ctx context.Context, channelID int64, where string, args []any, order string) ([]domain.ChannelMessage, error) { rows, err := s.db.Query(ctx, ` SELECT `+channelMessageColumns+` FROM channel_messages WHERE channel_id = $2 AND id = ANY(ARRAY( SELECT cm.id FROM channel_unread_mentions um JOIN channel_messages cm ON cm.channel_id = um.channel_id AND cm.id = um.message_id WHERE `+where+` ORDER BY cm.id `+order+` LIMIT $`+fmt.Sprint(len(args))+` )::int[]) ORDER BY id `+order, args...) _ = channelID if err != nil { return nil, fmt.Errorf("list channel unread mentions: %w", err) } defer rows.Close() out := make([]domain.ChannelMessage, 0) for rows.Next() { msg, err := scanChannelMessage(rows) if err != nil { return nil, err } out = append(out, msg) } if err := rows.Err(); err != nil { return nil, err } return out, nil } func channelUnreadMentionBaseWhere(userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID int) (string, []any) { args := []any{userID, filter.ChannelID} where := "um.user_id = $1 AND um.channel_id = $2 AND NOT cm.deleted" if availableMinID > 0 { args = append(args, availableMinID) where += fmt.Sprintf(" AND cm.id > $%d", len(args)) } if filter.TopMsgID > 0 { args = append(args, filter.TopMsgID) where += fmt.Sprintf(" AND um.top_message_id = $%d", len(args)) } if filter.MaxID > 0 { args = append(args, filter.MaxID) where += fmt.Sprintf(" AND cm.id < $%d", len(args)) } if filter.MinID > 0 { args = append(args, filter.MinID) where += fmt.Sprintf(" AND cm.id > $%d", len(args)) } return where, args } func appendChannelUnreadMentionBackwardOffset(where string, args []any, filter domain.ChannelUnreadMentionsFilter, include bool) (string, []any) { if filter.OffsetDate > 0 { args = append(args, filter.OffsetDate) if include { return where + fmt.Sprintf(" AND cm.message_date <= $%d", len(args)), args } return where + fmt.Sprintf(" AND cm.message_date < $%d", len(args)), args } if filter.OffsetID > 0 { args = append(args, filter.OffsetID) if include { return where + fmt.Sprintf(" AND cm.id <= $%d", len(args)), args } return where + fmt.Sprintf(" AND cm.id < $%d", len(args)), args } return where, args } func appendChannelUnreadMentionForwardOffset(where string, args []any, filter domain.ChannelUnreadMentionsFilter) (string, []any) { if filter.OffsetDate > 0 { args = append(args, filter.OffsetDate) return where + fmt.Sprintf(" AND cm.message_date >= $%d", len(args)), args } if filter.OffsetID > 0 { args = append(args, filter.OffsetID) return where + fmt.Sprintf(" AND cm.id > $%d", len(args)), args } return where, args } func (s *ChannelStore) countChannelUnreadReactions(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID int) (int, error) { where, args := channelUnreadReactionBaseWhere(userID, filter, availableMinID) var count int if err := s.db.QueryRow(ctx, ` SELECT COUNT(DISTINCT cm.id)::int FROM channel_message_reactions r JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id WHERE `+where, args...).Scan(&count); err != nil { return 0, fmt.Errorf("count channel unread reactions: %w", err) } return count, nil } func (s *ChannelStore) queryChannelUnreadReactionsPage(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { if limit <= 0 { return nil, nil } switch messageHistoryLoadType(filter.AddOffset, limit) { case messageHistoryLoadForward: return s.queryChannelUnreadReactionsForward(ctx, userID, filter, availableMinID, limit) case messageHistoryLoadAround: forwardLimit := -filter.AddOffset if forwardLimit > limit { forwardLimit = limit } backwardLimit := limit + filter.AddOffset if backwardLimit < 0 { backwardLimit = 0 } forward, err := s.queryChannelUnreadReactionsForward(ctx, userID, filter, availableMinID, forwardLimit) if err != nil { return nil, err } backward, err := s.queryChannelUnreadReactionsBackward(ctx, userID, filter, availableMinID, backwardLimit, true) if err != nil { return nil, err } out := append(forward, backward...) sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) return out, nil default: start := filter.AddOffset if start < 0 { start = 0 } items, err := s.queryChannelUnreadReactionsBackward(ctx, userID, filter, availableMinID, limit+start, false) if err != nil || start >= len(items) { return nil, err } return items[start:], nil } } func (s *ChannelStore) queryChannelUnreadReactionsBackward(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID, limit int, includeOffset bool) ([]domain.ChannelMessage, error) { if limit <= 0 { return nil, nil } where, args := channelUnreadReactionBaseWhere(userID, filter, availableMinID) where, args = appendChannelUnreadReactionBackwardOffset(where, args, filter, includeOffset) args = append(args, limit) return s.queryChannelUnreadReactions(ctx, filter.ChannelID, where, args, "DESC") } func (s *ChannelStore) queryChannelUnreadReactionsForward(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { if limit <= 0 { return nil, nil } where, args := channelUnreadReactionBaseWhere(userID, filter, availableMinID) where, args = appendChannelUnreadReactionForwardOffset(where, args, filter) args = append(args, limit) out, err := s.queryChannelUnreadReactions(ctx, filter.ChannelID, where, args, "ASC") if err != nil { return nil, err } sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) return out, nil } func (s *ChannelStore) queryChannelUnreadReactions(ctx context.Context, channelID int64, where string, args []any, order string) ([]domain.ChannelMessage, error) { rows, err := s.db.Query(ctx, ` SELECT `+channelMessageColumns+` FROM channel_messages WHERE channel_id = $2 AND id = ANY(ARRAY( SELECT DISTINCT cm.id FROM channel_message_reactions r JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id WHERE `+where+` ORDER BY cm.id `+order+` LIMIT $`+fmt.Sprint(len(args))+` )::int[]) ORDER BY id `+order, args...) _ = channelID if err != nil { return nil, fmt.Errorf("list channel unread reactions: %w", err) } defer rows.Close() out := make([]domain.ChannelMessage, 0) for rows.Next() { msg, err := scanChannelMessage(rows) if err != nil { return nil, err } out = append(out, msg) } if err := rows.Err(); err != nil { return nil, err } return out, nil } func channelUnreadReactionBaseWhere(userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID int) (string, []any) { args := []any{userID, filter.ChannelID} where := "r.sender_user_id = $1 AND r.channel_id = $2 AND r.unread AND r.reacted_user_id <> $1 AND NOT cm.deleted" if availableMinID > 0 { args = append(args, availableMinID) where += fmt.Sprintf(" AND cm.id > $%d", len(args)) } if filter.TopMsgID > 0 { args = append(args, filter.TopMsgID) where += fmt.Sprintf(" AND (cm.id = $%d OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $%d)", len(args), len(args)) } if filter.MaxID > 0 { args = append(args, filter.MaxID) where += fmt.Sprintf(" AND cm.id < $%d", len(args)) } if filter.MinID > 0 { args = append(args, filter.MinID) where += fmt.Sprintf(" AND cm.id > $%d", len(args)) } return where, args } func appendChannelUnreadReactionBackwardOffset(where string, args []any, filter domain.ChannelUnreadReactionsFilter, include bool) (string, []any) { if filter.OffsetID > 0 { args = append(args, filter.OffsetID) if include { return where + fmt.Sprintf(" AND cm.id <= $%d", len(args)), args } return where + fmt.Sprintf(" AND cm.id < $%d", len(args)), args } return where, args } func appendChannelUnreadReactionForwardOffset(where string, args []any, filter domain.ChannelUnreadReactionsFilter) (string, []any) { if filter.OffsetID > 0 { args = append(args, filter.OffsetID) return where + fmt.Sprintf(" AND cm.id > $%d", len(args)), args } return where, args } func (s *ChannelStore) queryChannelRepliesPage(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter, limit int) ([]domain.ChannelMessage, error) { if limit <= 0 { return nil, nil } switch messageHistoryLoadType(filter.AddOffset, limit) { case messageHistoryLoadForward: return s.queryChannelRepliesForward(ctx, channelID, rootID, availableMinID, filter, limit) case messageHistoryLoadAround: forwardLimit := -filter.AddOffset if forwardLimit > limit { forwardLimit = limit } backwardLimit := limit + filter.AddOffset if backwardLimit < 0 { backwardLimit = 0 } forward, err := s.queryChannelRepliesForward(ctx, channelID, rootID, availableMinID, filter, forwardLimit) if err != nil { return nil, err } backward, err := s.queryChannelRepliesBackward(ctx, channelID, rootID, availableMinID, filter, backwardLimit, true) if err != nil { return nil, err } out := append(forward, backward...) sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) return out, nil default: start := filter.AddOffset if start < 0 { start = 0 } items, err := s.queryChannelRepliesBackward(ctx, channelID, rootID, availableMinID, filter, limit+start, false) if err != nil || start >= len(items) { return nil, err } return items[start:], nil } } func (s *ChannelStore) queryChannelRepliesBackward(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter, limit int, includeOffset bool) ([]domain.ChannelMessage, error) { if limit <= 0 { return nil, nil } where, args := channelRepliesBaseWhere(channelID, rootID, availableMinID, filter) where, args = appendChannelRepliesBackwardOffset(where, args, filter, includeOffset) args = append(args, limit) return s.queryChannelReplies(ctx, where, args, "DESC") } func (s *ChannelStore) queryChannelRepliesForward(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter, limit int) ([]domain.ChannelMessage, error) { if limit <= 0 { return nil, nil } where, args := channelRepliesBaseWhere(channelID, rootID, availableMinID, filter) where, args = appendChannelRepliesForwardOffset(where, args, filter) args = append(args, limit) out, err := s.queryChannelReplies(ctx, where, args, "ASC") if err != nil { return nil, err } sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) return out, nil } func (s *ChannelStore) queryChannelReplies(ctx context.Context, where string, args []any, order string) ([]domain.ChannelMessage, error) { rows, err := s.db.Query(ctx, ` SELECT `+channelMessageColumns+` FROM channel_messages WHERE `+where+` ORDER BY id `+order+` LIMIT $`+fmt.Sprint(len(args)), args...) if err != nil { return nil, fmt.Errorf("list channel replies: %w", err) } defer rows.Close() out := make([]domain.ChannelMessage, 0) for rows.Next() { msg, err := scanChannelMessage(rows) if err != nil { return nil, err } out = append(out, msg) } if err := rows.Err(); err != nil { return nil, err } return out, nil } func channelRepliesBaseWhere(channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter) (string, []any) { args := []any{channelID, rootID} where := "channel_id = $1 AND reply_to_top_id = $2 AND NOT deleted" if availableMinID > 0 { args = append(args, availableMinID) where += fmt.Sprintf(" AND id > $%d", len(args)) } if filter.MaxID > 0 { args = append(args, filter.MaxID) where += fmt.Sprintf(" AND id < $%d", len(args)) } if filter.MinID > 0 { args = append(args, filter.MinID) where += fmt.Sprintf(" AND id > $%d", len(args)) } return where, args } func appendChannelRepliesBackwardOffset(where string, args []any, filter domain.ChannelRepliesFilter, include bool) (string, []any) { if filter.OffsetDate > 0 { args = append(args, filter.OffsetDate) if include { return where + fmt.Sprintf(" AND message_date <= $%d", len(args)), args } return where + fmt.Sprintf(" AND message_date < $%d", len(args)), args } if filter.OffsetID > 0 { args = append(args, filter.OffsetID) if include { return where + fmt.Sprintf(" AND id <= $%d", len(args)), args } return where + fmt.Sprintf(" AND id < $%d", len(args)), args } return where, args } func appendChannelRepliesForwardOffset(where string, args []any, filter domain.ChannelRepliesFilter) (string, []any) { if filter.OffsetDate > 0 { args = append(args, filter.OffsetDate) return where + fmt.Sprintf(" AND message_date >= $%d", len(args)), args } if filter.OffsetID > 0 { args = append(args, filter.OffsetID) return where + fmt.Sprintf(" AND id > $%d", len(args)), args } return where + " AND false", args } type messageHistoryLoad int const ( messageHistoryLoadBackward messageHistoryLoad = iota messageHistoryLoadForward messageHistoryLoadAround ) func messageHistoryLoadType(addOffset, limit int) messageHistoryLoad { if addOffset >= 0 { return messageHistoryLoadBackward } if addOffset+limit > 0 { return messageHistoryLoadAround } return messageHistoryLoadForward } func channelMessageLess(a, b domain.ChannelMessage) bool { if a.Date != b.Date { return a.Date > b.Date } return a.ID > b.ID } func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX, req domain.SendChannelMessageRequest, member domain.ChannelMember, channel domain.Channel) (*domain.MessageReply, error) { if req.ReplyTo == nil { return nil, nil } if err := domain.ValidateMessageReplyBounds(req.ReplyTo); err != nil { return nil, err } peer := req.ReplyTo.Peer channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID} if peer.ID == 0 { peer = channelPeer } if peer != channelPeer { return nil, domain.ErrReplyMessageIDInvalid } if req.ReplyTo.MessageID == 0 { if req.ReplyTo.TopMessageID <= 0 || !channel.Forum { return nil, domain.ErrReplyMessageIDInvalid } topic, err := s.getForumTopic(ctx, db, req.ChannelID, req.ReplyTo.TopMessageID) if err != nil { return nil, domain.ErrReplyMessageIDInvalid } if topic.Hidden { return nil, domain.ErrReplyMessageIDInvalid } if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { return nil, domain.ErrChannelWriteForbidden } reply := cloneMessageReply(req.ReplyTo) reply.MessageID = 0 reply.Peer = channelPeer reply.TopMessageID = topic.TopicID reply.ForumTopic = true return reply, nil } target, err := s.getChannelMessage(ctx, db, req.ChannelID, req.ReplyTo.MessageID) if err != nil { if errors.Is(err, domain.ErrMessageIDInvalid) || errors.Is(err, pgx.ErrNoRows) { return nil, domain.ErrReplyMessageIDInvalid } return nil, err } if target.Deleted || target.ID <= member.AvailableMinID { return nil, domain.ErrReplyMessageIDInvalid } reply := cloneMessageReply(req.ReplyTo) reply.MessageID = target.ID reply.Peer = channelPeer reply.TopMessageID = target.ID if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 { reply.TopMessageID = target.ReplyTo.TopMessageID } if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != reply.TopMessageID { return nil, domain.ErrReplyMessageIDInvalid } if channel.Forum && reply.TopMessageID > 0 { if topic, err := s.getForumTopic(ctx, db, req.ChannelID, reply.TopMessageID); err == nil && !topic.Hidden { if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { return nil, domain.ErrChannelWriteForbidden } reply.ForumTopic = true } else if err != nil && !errors.Is(err, domain.ErrMessageIDInvalid) { return nil, err } } return reply, nil } func (s *ChannelStore) duplicateChannelMessage(ctx context.Context, channelID, userID, randomID int64) (domain.SendChannelMessageResult, bool, error) { row := s.db.QueryRow(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages WHERE channel_id = $1 AND sender_user_id = $2 AND random_id = $3`, channelID, userID, randomID) msg, err := scanChannelMessage(row) if errors.Is(err, pgx.ErrNoRows) { return domain.SendChannelMessageResult{}, false, nil } if err != nil { return domain.SendChannelMessageResult{}, false, err } channel, err := getChannelByID(ctx, s.db, channelID) if err != nil { return domain.SendChannelMessageResult{}, false, err } event, err := s.eventForChannelMessage(ctx, channelID, msg.ID) if err != nil { return domain.SendChannelMessageResult{}, false, err } if event.Message.ID != 0 { msg = event.Message } return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Duplicate: true}, true, nil } func (s *ChannelStore) eventForChannelMessage(ctx context.Context, channelID int64, messageID int) (domain.ChannelUpdateEvent, error) { row := s.db.QueryRow(ctx, ` SELECT channel_id, pts, pts_count, date, event_type, message_id, message_ids::text, sender_user_id, user_ids::text, payload::text FROM channel_update_events WHERE channel_id = $1 AND message_id = $2 AND event_type = $3 ORDER BY pts ASC LIMIT 1`, channelID, messageID, string(domain.ChannelUpdateNewMessage)) event, _, err := scanChannelEvent(row) return event, err } func (s *ChannelStore) insertServiceMessage(ctx context.Context, tx pgx.Tx, channel domain.Channel, senderUserID int64, date int, action domain.ChannelMessageAction, reserved *[]reservedChannelPts) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) { msgID, err := s.msgIDs.NextChannelMessageID(ctx, channel.ID) if err != nil { return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("allocate channel service message id: %w", err) } pts, err := s.pts.NextChannelPts(ctx, channel.ID) if err != nil { return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("allocate channel service pts: %w", err) } reserveChannelPts(reserved, channel.ID, pts, 1) msg := domain.ChannelMessage{ ChannelID: channel.ID, ID: msgID, SenderUserID: senderUserID, From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, Date: date, Post: channel.Broadcast, Action: &action, Pts: pts, } event := domain.ChannelUpdateEvent{ ChannelID: channel.ID, Type: domain.ChannelUpdateNewMessage, Pts: pts, PtsCount: 1, Date: date, Message: msg, SenderUserID: senderUserID, UserIDs: append([]int64(nil), action.UserIDs...), } if err := insertChannelMessageTx(ctx, tx, msg); err != nil { return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err } if err := insertChannelEventTx(ctx, tx, event); err != nil { return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err } if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, channel.ID, msgID, pts); err != nil { return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("update channel service top: %w", err) } return msg, event, nil } func transientChannelParticipantEvent(channelID, actorUserID int64, previous, participant domain.ChannelMember, date int) domain.ChannelUpdateEvent { return domain.ChannelUpdateEvent{ ChannelID: channelID, Type: domain.ChannelUpdateParticipant, Date: date, SenderUserID: actorUserID, UserIDs: uniqueNonZeroInt64s(actorUserID, previous.UserID, previous.InviterUserID, participant.UserID, participant.InviterUserID), Previous: previous, Participant: participant, } } func (s *ChannelStore) deleteChannelMessagesTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, member domain.ChannelMember, ids []int, actorUserID int64, date int, reserved *[]reservedChannelPts) ([]int, domain.ChannelUpdateEvent, domain.Channel, error) { if len(ids) == 0 { return nil, domain.ChannelUpdateEvent{}, channel, nil } id32, ordered, err := validUniqueChannelMessageIDs(ids) if err != nil { return nil, domain.ChannelUpdateEvent{}, channel, err } rows, err := tx.Query(ctx, ` SELECT `+channelMessageColumns+` FROM channel_messages WHERE channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted ORDER BY id`, channel.ID, id32) if err != nil { return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("list channel messages for delete: %w", err) } byID := make(map[int]domain.ChannelMessage, len(ordered)) for rows.Next() { msg, err := scanChannelMessage(rows) if err != nil { rows.Close() return nil, domain.ChannelUpdateEvent{}, channel, err } byID[msg.ID] = msg } if err := rows.Err(); err != nil { rows.Close() return nil, domain.ChannelUpdateEvent{}, channel, err } rows.Close() deleted := make([]int, 0, len(ordered)) for _, id := range ordered { msg, ok := byID[id] if !ok { continue } if msg.SenderUserID != actorUserID && !canDeleteAnyChannelMessage(member) { return nil, domain.ChannelUpdateEvent{}, channel, domain.ErrChannelAdminRequired } deleted = append(deleted, id) if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ ChannelID: channel.ID, UserID: actorUserID, Date: date, Type: domain.ChannelAdminLogDeleteMessage, Message: &msg, Query: msg.Body, }); err != nil { return nil, domain.ChannelUpdateEvent{}, channel, err } } if len(deleted) == 0 { return nil, domain.ChannelUpdateEvent{}, channel, nil } pts, err := s.nextChannelPtsN(ctx, channel.ID, len(deleted)) if err != nil { return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("allocate channel delete pts: %w", err) } reserveChannelPts(reserved, channel.ID, pts, len(deleted)) deleted32 := int32s(deleted) if _, err := tx.Exec(ctx, ` UPDATE channel_messages SET deleted = true, pts = $3, updated_at = now() WHERE channel_id = $1 AND id = ANY($2::int[])`, channel.ID, deleted32, pts); err != nil { return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("soft delete channel messages: %w", err) } if err := deleteChannelUnreadMentionsTx(ctx, tx, channel.ID, deleted); err != nil { return nil, domain.ChannelUpdateEvent{}, channel, err } if err := refreshChannelUnreadReactionsCountsForMessagesTx(ctx, tx, channel.ID, deleted); err != nil { return nil, domain.ChannelUpdateEvent{}, channel, err } topID, err := topNonDeletedChannelMessageID(ctx, tx, channel.ID) if err != nil { return nil, domain.ChannelUpdateEvent{}, channel, err } if _, err := tx.Exec(ctx, ` UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, channel.ID, topID, pts); err != nil { return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("update channel top after delete: %w", err) } channel.TopMessageID = topID channel.Pts = pts event := domain.ChannelUpdateEvent{ ChannelID: channel.ID, Type: domain.ChannelUpdateDeleteMessages, Pts: pts, PtsCount: len(deleted), Date: date, MessageIDs: append([]int(nil), deleted...), SenderUserID: actorUserID, } if err := insertChannelEventTx(ctx, tx, event); err != nil { return nil, domain.ChannelUpdateEvent{}, channel, err } return deleted, event, channel, nil } func (s *ChannelStore) nextChannelPtsN(ctx context.Context, channelID int64, count int) (int, error) { if count <= 1 { return s.pts.NextChannelPts(ctx, channelID) } if ranges, ok := s.pts.(store.ChannelPtsRangeAllocator); ok { return ranges.NextChannelPtsN(ctx, channelID, count) } var pts int var err error for i := 0; i < count; i++ { pts, err = s.pts.NextChannelPts(ctx, channelID) if err != nil { return 0, err } } return pts, nil } type reservedChannelPts struct { channelID int64 pts int count int } func reserveChannelPts(items *[]reservedChannelPts, channelID int64, pts, count int) { if items == nil || channelID == 0 || pts == 0 { return } if count <= 0 { count = 1 } *items = append(*items, reservedChannelPts{channelID: channelID, pts: pts, count: count}) } func (s *ChannelStore) recordChannelPtsGaps(ctx context.Context, items []reservedChannelPts, date int) { if len(items) == 0 { return } if date == 0 { date = nowUnix() } for _, item := range items { count := item.count if count <= 0 { count = 1 } _, _ = s.db.Exec(ctx, ` INSERT INTO channel_update_events ( channel_id, pts, pts_count, date, event_type, message_id, message_ids, sender_user_id, user_ids, payload ) VALUES ($1,$2,$3,$4,$5,0,'[]'::jsonb,0,'[]'::jsonb,'{}'::jsonb) ON CONFLICT (channel_id, pts) DO NOTHING`, item.channelID, item.pts, count, date, string(domain.ChannelUpdateNoop)) } } func validUniqueChannelMessageIDs(ids []int) ([]int32, []int, error) { seen := make(map[int]struct{}, len(ids)) out := make([]int, 0, len(ids)) for _, id := range ids { if id <= 0 || id > domain.MaxMessageBoxID { return nil, nil, domain.ErrMessageIDInvalid } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } return int32s(out), out, nil } func topNonDeletedChannelMessageID(ctx context.Context, db sqlcgen.DBTX, channelID int64) (int, error) { var id int if err := db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1 AND NOT deleted`, channelID).Scan(&id); err != nil { return 0, fmt.Errorf("select channel top after delete: %w", err) } return id, nil } func visibleChannelTopAfter(ctx context.Context, db sqlcgen.DBTX, channelID int64, availableMinID int, fallbackDate int) (int, int, error) { var id, date int err := db.QueryRow(ctx, ` SELECT id, message_date FROM channel_messages WHERE channel_id = $1 AND id > $2 AND NOT deleted ORDER BY id DESC LIMIT 1`, channelID, availableMinID).Scan(&id, &date) if errors.Is(err, pgx.ErrNoRows) { return 0, fallbackDate, nil } if err != nil { return 0, 0, fmt.Errorf("select visible channel top: %w", err) } return id, date, nil } func insertChannelTx(ctx context.Context, tx pgx.Tx, ch domain.Channel) error { rights, err := marshalJSON(ch.DefaultBannedRights, "{}") if err != nil { return err } reactions, err := marshalJSON(ch.ReactionPolicy, "{}") if err != nil { return err } if _, err := tx.Exec(ctx, ` INSERT INTO channels ( id, access_hash, creator_user_id, title, about, username, broadcast, megagroup, forum, forum_tabs, autotranslation, restricted_sponsored, broadcast_messages_allowed, send_paid_messages_stars, noforwards, join_to_send, join_request, signatures, pre_history_hidden, participants_hidden, antispam, linked_chat_id, slowmode_seconds, default_banned_rights, available_reactions, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, emoji_status_document_id, emoji_status_until, participants_count, admins_count, kicked_count, banned_count, top_message_id, pinned_message_id, pts, ttl_period, date, deleted ) VALUES ($1,$2,$3,$4,$5,NULLIF($6,''),$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,$41,$42,$43)`, ch.ID, ch.AccessHash, ch.CreatorUserID, ch.Title, ch.About, ch.Username, ch.Broadcast, ch.Megagroup, ch.Forum, ch.ForumTabs, ch.Autotranslation, ch.RestrictedSponsored, ch.BroadcastMessagesAllowed, ch.SendPaidMessagesStars, ch.NoForwards, ch.JoinToSend, ch.JoinRequest, ch.Signatures, ch.PreHistoryHidden, ch.ParticipantsHidden, ch.AntiSpam, ch.LinkedChatID, ch.SlowmodeSeconds, rights, reactions, ch.Color.HasColor, ch.Color.Color, ch.Color.BackgroundEmojiID, ch.ProfileColor.HasColor, ch.ProfileColor.Color, ch.ProfileColor.BackgroundEmojiID, ch.EmojiStatus.DocumentID, ch.EmojiStatus.Until, ch.ParticipantsCount, ch.AdminsCount, ch.KickedCount, ch.BannedCount, ch.TopMessageID, ch.PinnedMessageID, ch.Pts, ch.TTLPeriod, ch.Date, ch.Deleted); err != nil { return fmt.Errorf("insert channel: %w", err) } return nil } func upsertChannelMemberTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, member domain.ChannelMember) error { adminRights, err := marshalJSON(member.AdminRights, "{}") if err != nil { return err } bannedRights, err := marshalJSON(member.BannedRights, "{}") if err != nil { return err } if _, err := tx.Exec(ctx, ` INSERT INTO channel_members ( channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights, banned_rights, rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT (channel_id, user_id) DO UPDATE SET inviter_user_id = EXCLUDED.inviter_user_id, role = EXCLUDED.role, status = EXCLUDED.status, joined_at = EXCLUDED.joined_at, left_at = EXCLUDED.left_at, admin_rights = EXCLUDED.admin_rights, banned_rights = EXCLUDED.banned_rights, rank = EXCLUDED.rank, available_min_id = GREATEST(channel_members.available_min_id, EXCLUDED.available_min_id), available_min_pts = GREATEST(channel_members.available_min_pts, EXCLUDED.available_min_pts), read_inbox_max_id = GREATEST(channel_members.read_inbox_max_id, EXCLUDED.read_inbox_max_id), updated_at = now()`, member.ChannelID, member.UserID, member.InviterUserID, string(member.Role), string(member.Status), member.JoinedAt, member.LeftAt, adminRights, bannedRights, member.Rank, member.AvailableMinID, member.AvailableMinPts, member.ReadInboxMaxID, member.ReadOutboxMaxID, member.UnreadMark, member.SlowmodeLastSendDate); err != nil { return fmt.Errorf("upsert channel member: %w", err) } return upsertUserChannelMemberIndexTx(ctx, tx, channel, member) } func upsertUserChannelMemberIndexTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, member domain.ChannelMember) error { if channel.ID == 0 || member.UserID == 0 { return nil } if _, err := tx.Exec(ctx, ` INSERT INTO user_channel_member_index ( user_id, channel_id, status, megagroup, broadcast, deleted ) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (user_id, channel_id) DO UPDATE SET status = EXCLUDED.status, megagroup = EXCLUDED.megagroup, broadcast = EXCLUDED.broadcast, deleted = EXCLUDED.deleted, updated_at = now()`, member.UserID, channel.ID, string(member.Status), channel.Megagroup, channel.Broadcast, channel.Deleted); err != nil { return fmt.Errorf("upsert user channel member index: %w", err) } return nil } func markUserChannelMemberIndexDeletedTx(ctx context.Context, tx pgx.Tx, channelID int64, deleted bool) error { if channelID == 0 { return nil } if _, err := tx.Exec(ctx, ` UPDATE user_channel_member_index SET deleted = $2, updated_at = now() WHERE channel_id = $1`, channelID, deleted); err != nil { return fmt.Errorf("mark user channel member index deleted: %w", err) } return nil } func insertChannelMessageTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMessage) error { entities, err := encodeMessageEntities(msg.Entities) if err != nil { return err } reply, err := marshalJSON(msg.ReplyTo, "{}") if err != nil { return err } forward, err := marshalJSON(msg.Forward, "{}") if err != nil { return err } action, err := marshalJSON(msg.Action, "{}") if err != nil { return err } media, err := encodeMessageMedia(msg.Media) if err != nil { return err } var sendAsType sql.NullString var sendAsID sql.NullInt64 if msg.SendAs != nil && msg.SendAs.ID != 0 { sendAsType = sql.NullString{String: string(msg.SendAs.Type), Valid: true} sendAsID = sql.NullInt64{Int64: msg.SendAs.ID, Valid: true} } if msg.From.Type == "" { msg.From = domain.Peer{Type: domain.PeerTypeUser, ID: msg.SenderUserID} } replyMsgID, replyTopID := 0, 0 replyPeerType := "" replyPeerID := int64(0) if msg.ReplyTo != nil { replyMsgID = msg.ReplyTo.MessageID replyTopID = msg.ReplyTo.TopMessageID replyPeerType = string(msg.ReplyTo.Peer.Type) replyPeerID = msg.ReplyTo.Peer.ID } discussionChannelID, discussionMessageID := int64(0), 0 if msg.Discussion != nil { discussionChannelID = msg.Discussion.ChannelID discussionMessageID = msg.Discussion.MessageID } if _, err := tx.Exec(ctx, ` INSERT INTO channel_messages ( channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id, send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body, entities, reply_to, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27)`, msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, string(msg.From.Type), msg.From.ID, sendAsType, sendAsID, msg.Date, msg.EditDate, msg.Post, msg.Silent, msg.NoForwards, msg.Body, entities, reply, replyMsgID, replyPeerType, replyPeerID, replyTopID, forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media); err != nil { return fmt.Errorf("insert channel message: %w", err) } return nil } func updateForumTopicTopMessageTx(ctx context.Context, tx pgx.Tx, channelID int64, msg domain.ChannelMessage) error { if msg.ReplyTo == nil || !msg.ReplyTo.ForumTopic || msg.ReplyTo.TopMessageID <= 0 { return nil } if _, err := tx.Exec(ctx, ` UPDATE channel_forum_topics SET top_message_id = $3, date = $4, updated_at = now() WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, channelID, msg.ReplyTo.TopMessageID, msg.ID, msg.Date); err != nil { return fmt.Errorf("update forum topic top message: %w", err) } return nil } func insertChannelEventTx(ctx context.Context, tx pgx.Tx, event domain.ChannelUpdateEvent) error { ids, err := marshalJSON(event.MessageIDs, "[]") if err != nil { return err } userIDs, err := marshalJSON(event.UserIDs, "[]") if err != nil { return err } payloadData := map[string]any{ "message_id": event.Message.ID, "pinned": event.Pinned, } if event.Message.ID != 0 { payloadData["message"] = event.Message } if event.Previous.UserID != 0 { payloadData["previous_participant"] = event.Previous } if event.Participant.UserID != 0 { payloadData["participant"] = event.Participant } payload, err := marshalJSON(payloadData, "{}") if err != nil { return err } if _, err := tx.Exec(ctx, ` INSERT INTO channel_update_events ( channel_id, pts, pts_count, date, event_type, message_id, message_ids, sender_user_id, user_ids, payload ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, event.ChannelID, event.Pts, event.PtsCount, event.Date, string(event.Type), event.Message.ID, ids, event.SenderUserID, userIDs, payload); err != nil { return fmt.Errorf("insert channel event: %w", err) } return nil } func (s *ChannelStore) insertChannelAdminLogTx(ctx context.Context, tx pgx.Tx, event domain.ChannelAdminLogEvent) error { if event.ChannelID == 0 || event.UserID == 0 || event.Type == "" { return nil } if event.Date == 0 { event.Date = nowUnix() } id, err := nextChannelAdminLogIDTx(ctx, tx, event.ChannelID) if err != nil { return err } prevParticipant, err := marshalJSON(event.PrevParticipant, "{}") if err != nil { return err } newParticipant, err := marshalJSON(event.NewParticipant, "{}") if err != nil { return err } participant, err := marshalJSON(event.Participant, "{}") if err != nil { return err } message, err := marshalJSON(event.Message, "{}") if err != nil { return err } prevMessage, err := marshalJSON(event.PrevMessage, "{}") if err != nil { return err } newMessage, err := marshalJSON(event.NewMessage, "{}") if err != nil { return err } query := adminLogSearchText(event) if _, err := tx.Exec(ctx, ` INSERT INTO channel_admin_log_events ( channel_id, id, actor_user_id, event_date, event_type, prev_string, new_string, prev_bool, new_bool, prev_int, new_int, prev_participant, new_participant, participant, message, prev_message, new_message, query ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, event.ChannelID, id, event.UserID, event.Date, string(event.Type), event.PrevString, event.NewString, event.PrevBool, event.NewBool, event.PrevInt, event.NewInt, prevParticipant, newParticipant, participant, message, prevMessage, newMessage, query); err != nil { return fmt.Errorf("insert channel admin log: %w", err) } return nil } func nextChannelAdminLogIDTx(ctx context.Context, tx pgx.Tx, channelID int64) (int64, error) { var id int64 if err := tx.QueryRow(ctx, ` UPDATE channels SET admin_log_seq = admin_log_seq + 1, updated_at = now() WHERE id = $1 RETURNING admin_log_seq`, channelID).Scan(&id); err != nil { return 0, fmt.Errorf("allocate channel admin log id: %w", err) } return id, nil } func upsertChannelDialogTx(ctx context.Context, tx pgx.Tx, userID int64, channel domain.Channel, top domain.ChannelMessage, readInboxMaxID, readOutboxMaxID int) error { topDate := top.Date if topDate == 0 { topDate = channel.Date } unread, err := countChannelUnreadMessages(ctx, tx, userID, channel.ID, readInboxMaxID, channel.TopMessageID) if err != nil { return err } if _, err := tx.Exec(ctx, ` INSERT INTO channel_dialogs ( user_id, channel_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark ) VALUES ($1,$2,$3,$4,$5,$6,$7,false) ON CONFLICT (user_id, channel_id) DO UPDATE SET top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id), top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date), read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id), read_outbox_max_id = GREATEST(channel_dialogs.read_outbox_max_id, EXCLUDED.read_outbox_max_id), unread_count = ( SELECT COUNT(*)::int FROM channel_messages msg WHERE msg.channel_id = channel_dialogs.channel_id AND msg.id > GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id) AND msg.id <= GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id) AND NOT msg.deleted AND msg.sender_user_id <> channel_dialogs.user_id ), unread_mark = false, updated_at = now()`, userID, channel.ID, channel.TopMessageID, topDate, readInboxMaxID, readOutboxMaxID, unread); err != nil { return fmt.Errorf("upsert channel dialog: %w", err) } return nil } func upsertChannelDialogsForMessageTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, top domain.ChannelMessage, selfReadUserID int64) error { if channel.ID == 0 || top.ID == 0 { return nil } if !shouldSynchronouslyUpsertChannelDialogs(channel) { return nil } topDate := top.Date if topDate == 0 { topDate = channel.Date } if _, err := tx.Exec(ctx, ` WITH active AS ( SELECT m.user_id, CASE WHEN m.user_id = $4 THEN GREATEST(m.read_inbox_max_id, $2) ELSE m.read_inbox_max_id END AS read_inbox_max_id, CASE WHEN m.user_id = $4 THEN GREATEST(m.read_outbox_max_id, $2) ELSE m.read_outbox_max_id END AS read_outbox_max_id FROM channel_members m WHERE m.channel_id = $1 AND m.status = 'active' AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) AND $2 > m.available_min_id ) INSERT INTO channel_dialogs ( user_id, channel_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark ) SELECT user_id, $1, $2, $3, read_inbox_max_id, read_outbox_max_id, ( SELECT COUNT(*)::int FROM channel_messages msg WHERE msg.channel_id = $1 AND msg.id > active.read_inbox_max_id AND msg.id <= $2 AND NOT msg.deleted AND msg.sender_user_id <> active.user_id ), false FROM active ON CONFLICT (user_id, channel_id) DO UPDATE SET top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id), top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date), read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id), read_outbox_max_id = GREATEST(channel_dialogs.read_outbox_max_id, EXCLUDED.read_outbox_max_id), unread_count = ( SELECT COUNT(*)::int FROM channel_messages msg WHERE msg.channel_id = channel_dialogs.channel_id AND msg.id > GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id) AND msg.id <= GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id) AND NOT msg.deleted AND msg.sender_user_id <> channel_dialogs.user_id ), unread_mark = CASE WHEN channel_dialogs.user_id = $4 THEN false ELSE channel_dialogs.unread_mark END, updated_at = now()`, channel.ID, top.ID, topDate, selfReadUserID); err != nil { return fmt.Errorf("upsert channel message dialogs: %w", err) } return nil } func shouldSynchronouslyUpsertChannelDialogs(channel domain.Channel) bool { if channel.Broadcast { return false } return channel.ParticipantsCount > 0 && channel.ParticipantsCount <= domain.MaxSynchronousChannelDialogFanout } func insertChannelUnreadMentionsTx(ctx context.Context, tx pgx.Tx, channelID int64, msg domain.ChannelMessage, senderUserID int64, userIDs []int64) error { candidates := uniqueChannelUserIDs(userIDs, senderUserID) if len(candidates) == 0 || msg.ID == 0 { return nil } if len(candidates) > domain.MaxChannelMentionRecipients { candidates = candidates[:domain.MaxChannelMentionRecipients] } topID := channelMentionTopID(msg) mediaUnread := !msg.Media.IsZero() if _, err := tx.Exec(ctx, ` WITH input(user_id) AS ( SELECT DISTINCT unnest($4::bigint[]) ), active AS ( SELECT i.user_id FROM input i JOIN channel_members m ON m.channel_id = $1 AND m.user_id = i.user_id WHERE m.status = 'active' AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) AND $2 > m.available_min_id AND $2 > m.read_inbox_max_id LIMIT $6 ), inserted AS ( INSERT INTO channel_unread_mentions (user_id, channel_id, message_id, top_message_id, media_unread) SELECT user_id, $1, $2, $3, $7 FROM active ON CONFLICT DO NOTHING RETURNING user_id ) INSERT INTO channel_dialogs ( user_id, channel_id, top_message_id, top_message_date, unread_mentions_count ) SELECT user_id, $1, $2, $5, 1 FROM inserted ON CONFLICT (user_id, channel_id) DO UPDATE SET top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id), top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date), unread_mentions_count = channel_dialogs.unread_mentions_count + 1, updated_at = now()`, channelID, msg.ID, topID, candidates, msg.Date, domain.MaxChannelMentionRecipients, mediaUnread); err != nil { return fmt.Errorf("insert channel unread mentions: %w", err) } return nil } func readChannelMentionsTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, topMsgID, limit int) (int, int, error) { var cleared, remaining int if err := tx.QueryRow(ctx, ` WITH target AS ( SELECT user_id, channel_id, message_id FROM channel_unread_mentions WHERE user_id = $1 AND channel_id = $2 AND ($3 = 0 OR top_message_id = $3) ORDER BY message_id DESC LIMIT $4 ), deleted AS ( DELETE FROM channel_unread_mentions um USING target t WHERE um.user_id = t.user_id AND um.channel_id = t.channel_id AND um.message_id = t.message_id RETURNING um.message_id ), remaining_scoped AS ( SELECT COUNT(*)::int AS count FROM channel_unread_mentions WHERE user_id = $1 AND channel_id = $2 AND ($3 = 0 OR top_message_id = $3) ), remaining_all AS ( SELECT COUNT(*)::int AS count FROM channel_unread_mentions WHERE user_id = $1 AND channel_id = $2 ), updated_dialog AS ( UPDATE channel_dialogs SET unread_mentions_count = (SELECT count FROM remaining_all), updated_at = now() WHERE user_id = $1 AND channel_id = $2 ) SELECT (SELECT COUNT(*)::int FROM deleted), (SELECT count FROM remaining_scoped)`, userID, channelID, topMsgID, limit).Scan(&cleared, &remaining); err != nil { return 0, 0, fmt.Errorf("read channel mentions: %w", err) } return cleared, remaining, nil } func readChannelReactionsTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, topMsgID, limit int) (int, int, error) { var cleared, remaining int if err := tx.QueryRow(ctx, ` WITH member_scope AS ( SELECT available_min_id FROM channel_members WHERE user_id = $1 AND channel_id = $2 ), target_messages AS ( SELECT DISTINCT r.message_id FROM channel_message_reactions r JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id JOIN member_scope ms ON true WHERE r.sender_user_id = $1 AND r.channel_id = $2 AND r.unread AND r.reacted_user_id <> $1 AND cm.id > ms.available_min_id AND NOT cm.deleted AND ($3 = 0 OR cm.id = $3 OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $3) ORDER BY r.message_id DESC LIMIT $4 ), updated AS ( UPDATE channel_message_reactions r SET unread = false, updated_at = now() WHERE r.sender_user_id = $1 AND r.channel_id = $2 AND r.message_id IN (SELECT message_id FROM target_messages) AND r.unread RETURNING r.message_id ), remaining_scoped AS ( SELECT COUNT(DISTINCT r.message_id)::int AS count FROM channel_message_reactions r JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id JOIN member_scope ms ON true WHERE r.sender_user_id = $1 AND r.channel_id = $2 AND r.unread AND r.reacted_user_id <> $1 AND cm.id > ms.available_min_id AND NOT cm.deleted AND ($3 = 0 OR cm.id = $3 OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $3) ), remaining_all AS ( SELECT COUNT(DISTINCT r.message_id)::int AS count FROM channel_message_reactions r JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id JOIN member_scope ms ON true WHERE r.sender_user_id = $1 AND r.channel_id = $2 AND r.unread AND r.reacted_user_id <> $1 AND cm.id > ms.available_min_id AND NOT cm.deleted ), updated_dialog AS ( UPDATE channel_dialogs SET unread_reactions_count = (SELECT count FROM remaining_all), updated_at = now() WHERE user_id = $1 AND channel_id = $2 ) SELECT (SELECT COUNT(DISTINCT message_id)::int FROM updated), (SELECT count FROM remaining_scoped)`, userID, channelID, topMsgID, limit).Scan(&cleared, &remaining); err != nil { return 0, 0, fmt.Errorf("read channel reactions: %w", err) } return cleared, remaining, nil } func clearChannelUnreadReactionsForMessageIDsTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, ids []int32) ([]int, error) { if userID == 0 || channelID == 0 || len(ids) == 0 { return nil, nil } rows, err := tx.Query(ctx, ` UPDATE channel_message_reactions SET unread = false, updated_at = now() WHERE sender_user_id = $1 AND channel_id = $2 AND message_id = ANY($3::int[]) AND unread AND reacted_user_id <> $1 RETURNING message_id`, userID, channelID, ids) if err != nil { return nil, fmt.Errorf("clear visible channel unread reactions: %w", err) } clearedSet := make(map[int]struct{}) for rows.Next() { var id int if err := rows.Scan(&id); err != nil { rows.Close() return nil, err } clearedSet[id] = struct{}{} } if err := rows.Err(); err != nil { rows.Close() return nil, err } rows.Close() if len(clearedSet) == 0 { return nil, nil } cleared := make([]int, 0, len(clearedSet)) for id := range clearedSet { cleared = append(cleared, id) } sort.Sort(sort.Reverse(sort.IntSlice(cleared))) if err := refreshChannelUnreadReactionsCountTx(ctx, tx, userID, channelID); err != nil { return nil, err } return cleared, nil } func refreshChannelUnreadReactionsCountTx(ctx context.Context, tx pgx.Tx, userID, channelID int64) error { if userID == 0 || channelID == 0 { return nil } if _, err := tx.Exec(ctx, ` WITH active AS ( SELECT m.available_min_id FROM channel_members m WHERE m.user_id = $1 AND m.channel_id = $2 AND m.status = 'active' AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) ), counts AS ( SELECT COUNT(DISTINCT r.message_id)::int AS count FROM channel_message_reactions r JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id JOIN active a ON true WHERE r.sender_user_id = $1 AND r.channel_id = $2 AND r.unread AND r.reacted_user_id <> $1 AND cm.id > a.available_min_id AND NOT cm.deleted ) INSERT INTO channel_dialogs (user_id, channel_id, unread_reactions_count) SELECT $1, $2, counts.count FROM active, counts ON CONFLICT (user_id, channel_id) DO UPDATE SET unread_reactions_count = EXCLUDED.unread_reactions_count, updated_at = now()`, userID, channelID); err != nil { return fmt.Errorf("refresh channel unread reactions count: %w", err) } return nil } func deleteChannelUnreadMentionsTx(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) error { if len(ids) == 0 { return nil } if _, err := tx.Exec(ctx, ` WITH deleted AS ( DELETE FROM channel_unread_mentions WHERE channel_id = $1 AND message_id = ANY($2::int[]) RETURNING user_id ), affected AS ( SELECT DISTINCT user_id FROM deleted ), counts AS ( SELECT user_id, COUNT(*)::int AS count FROM channel_unread_mentions WHERE channel_id = $1 AND user_id IN (SELECT user_id FROM affected) GROUP BY user_id ) UPDATE channel_dialogs d SET unread_mentions_count = COALESCE(c.count, 0), updated_at = now() FROM affected a LEFT JOIN counts c ON c.user_id = a.user_id WHERE d.channel_id = $1 AND d.user_id = a.user_id`, channelID, int32s(ids)); err != nil { return fmt.Errorf("delete channel unread mentions: %w", err) } return nil } func refreshChannelUnreadReactionsCountsForMessagesTx(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) error { if len(ids) == 0 { return nil } rows, err := tx.Query(ctx, ` SELECT DISTINCT sender_user_id FROM channel_message_reactions WHERE channel_id = $1 AND message_id = ANY($2::int[]) AND sender_user_id <> 0`, channelID, int32s(ids)) if err != nil { return fmt.Errorf("list channel unread reaction owners: %w", err) } defer rows.Close() userIDs := make([]int64, 0) for rows.Next() { var userID int64 if err := rows.Scan(&userID); err != nil { return err } userIDs = append(userIDs, userID) } if err := rows.Err(); err != nil { return err } for _, userID := range userIDs { if err := refreshChannelUnreadReactionsCountTx(ctx, tx, userID, channelID); err != nil { return err } } return nil } func deleteChannelUnreadMentionsUpToTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, maxID int) error { if maxID <= 0 { return nil } var deleted int if err := tx.QueryRow(ctx, ` WITH deleted AS ( DELETE FROM channel_unread_mentions WHERE user_id = $1 AND channel_id = $2 AND message_id <= $3 RETURNING message_id ), remaining_all AS ( SELECT COUNT(*)::int AS count FROM channel_unread_mentions WHERE user_id = $1 AND channel_id = $2 ), updated_dialog AS ( UPDATE channel_dialogs SET unread_mentions_count = (SELECT count FROM remaining_all), updated_at = now() WHERE user_id = $1 AND channel_id = $2 ) SELECT COUNT(*)::int FROM deleted`, userID, channelID, maxID).Scan(&deleted); err != nil { return fmt.Errorf("delete channel unread mentions up to: %w", err) } return nil } func channelMentionTopID(msg domain.ChannelMessage) int { if msg.ReplyTo == nil { return 0 } if msg.ReplyTo.TopMessageID > 0 { return msg.ReplyTo.TopMessageID } return msg.ReplyTo.MessageID } func scanChannelWithMember(row rowScanner) (domain.Channel, domain.ChannelMember, error) { var ch domain.Channel var member domain.ChannelMember var defaultRights, reactionPolicy, adminRights, bannedRights string var role, status string if err := row.Scan( &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.LinkedChatID, &ch.SlowmodeSeconds, &defaultRights, &reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, &member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status, &member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank, &member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate, ); err != nil { return domain.Channel{}, domain.ChannelMember{}, err } member.Role = domain.ChannelMemberRole(role) member.Status = domain.ChannelMemberStatus(status) _ = json.Unmarshal([]byte(defaultRights), &ch.DefaultBannedRights) _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) _ = json.Unmarshal([]byte(adminRights), &member.AdminRights) _ = json.Unmarshal([]byte(bannedRights), &member.BannedRights) return ch, member, nil } func scanChannel(row rowScanner) (domain.Channel, error) { var ch domain.Channel var rights, reactionPolicy string if err := row.Scan( &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.LinkedChatID, &ch.SlowmodeSeconds, &rights, &reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, ); err != nil { return domain.Channel{}, err } _ = json.Unmarshal([]byte(rights), &ch.DefaultBannedRights) _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) return ch, nil } func scanChannelWithViewerMember(row rowScanner) (domain.Channel, bool, error) { var ch domain.Channel var viewerMember bool var rights, reactionPolicy string if err := row.Scan( &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.LinkedChatID, &ch.SlowmodeSeconds, &rights, &reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, &viewerMember, ); err != nil { return domain.Channel{}, false, err } _ = json.Unmarshal([]byte(rights), &ch.DefaultBannedRights) _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) return ch, viewerMember, nil } func scanChannelMember(row rowScanner) (domain.ChannelMember, error) { var member domain.ChannelMember var adminRights, bannedRights string var role, status string if err := row.Scan( &member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status, &member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank, &member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate, ); err != nil { return domain.ChannelMember{}, err } member.Role = domain.ChannelMemberRole(role) member.Status = domain.ChannelMemberStatus(status) _ = json.Unmarshal([]byte(adminRights), &member.AdminRights) _ = json.Unmarshal([]byte(bannedRights), &member.BannedRights) return member, nil } func discussionGroupUpdateResult(changed map[int64]domain.Channel) domain.DiscussionGroupUpdateResult { ids := make([]int64, 0, len(changed)) for id := range changed { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) out := domain.DiscussionGroupUpdateResult{Channels: make([]domain.Channel, 0, len(ids))} for _, id := range ids { out.Channels = append(out.Channels, changed[id]) } return out } func scanChannelMemberWithCount(row rowScanner) (domain.ChannelMember, int, error) { var member domain.ChannelMember var adminRights, bannedRights string var role, status string var count int if err := row.Scan( &member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status, &member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank, &member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate, &count, ); err != nil { return domain.ChannelMember{}, 0, err } member.Role = domain.ChannelMemberRole(role) member.Status = domain.ChannelMemberStatus(status) _ = json.Unmarshal([]byte(adminRights), &member.AdminRights) _ = json.Unmarshal([]byte(bannedRights), &member.BannedRights) return member, count, nil } func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.Dialog, error) { var ch domain.Channel var rights, reactionPolicy string var topID, topDate, folderID, readInbox, readOutbox, unreadCount, pinnedOrder, unreadMentions, unreadReactions int var pinned, unreadMark, viewForumAsMessages bool if err := row.Scan( &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.LinkedChatID, &ch.SlowmodeSeconds, &rights, &reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, &topID, &topDate, &folderID, &readInbox, &readOutbox, &unreadCount, &pinned, &pinnedOrder, &unreadMark, &unreadMentions, &unreadReactions, &viewForumAsMessages, ); err != nil { return domain.Channel{}, domain.Dialog{}, err } _ = json.Unmarshal([]byte(rights), &ch.DefaultBannedRights) _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) dialog := domain.Dialog{ Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}, FolderID: folderID, TopMessage: topID, TopMessageDate: topDate, ReadInboxMaxID: readInbox, ReadOutboxMaxID: readOutbox, UnreadCount: unreadCount, UnreadMentions: unreadMentions, UnreadReactions: unreadReactions, Pinned: pinned, PinnedOrder: pinnedOrder, UnreadMark: unreadMark, ViewForumAsMessages: viewForumAsMessages, } _ = userID return ch, dialog, nil } func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) { var msg domain.ChannelMessage var fromType string var sendAsType sql.NullString var sendAsID sql.NullInt64 var replyMsgID, replyTopID int var replyPeerType string var replyPeerID int64 var discussionChannelID int64 var discussionMessageID int var entities, reply, forward, action string var mediaJSON string if err := row.Scan( &msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID, &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, ); err != nil { return domain.ChannelMessage{}, err } msg.From.Type = domain.PeerType(fromType) if sendAsType.Valid && sendAsID.Valid { msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64} } parsedEntities, err := decodeMessageEntities(entities) if err != nil { return domain.ChannelMessage{}, err } msg.Entities = parsedEntities msg.ReplyTo = channelMessageReplyFromColumns(decodeJSONPtr[domain.MessageReply](reply), replyMsgID, replyPeerType, replyPeerID, replyTopID) msg.Forward = decodeJSONPtr[domain.MessageForward](forward) if discussionChannelID != 0 && discussionMessageID != 0 { msg.Discussion = &domain.ChannelDiscussionRef{ChannelID: discussionChannelID, MessageID: discussionMessageID} } msg.Action = decodeJSONPtr[domain.ChannelMessageAction](action) msg.Media, err = decodeMessageMedia(mediaJSON) if err != nil { return domain.ChannelMessage{}, err } return msg, nil } func scanChannelForumTopic(row rowScanner) (domain.ChannelForumTopic, error) { var topic domain.ChannelForumTopic if err := row.Scan( &topic.ChannelID, &topic.TopicID, &topic.CreatorUserID, &topic.Title, &topic.IconColor, &topic.IconEmojiID, &topic.TitleMissing, &topic.Closed, &topic.Hidden, &topic.Pinned, &topic.PinnedOrder, &topic.Date, &topic.TopMessageID, &topic.ReadInboxMaxID, &topic.ReadOutboxMaxID, &topic.UnreadCount, &topic.UnreadMentionsCount, &topic.UnreadReactionsCount, &topic.UnreadPollVotesCount, ); err != nil { return domain.ChannelForumTopic{}, err } return topic, nil } func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, error) { var msg domain.ChannelMessage var fromType string var sendAsType sql.NullString var sendAsID sql.NullInt64 var replyMsgID, replyTopID int var replyPeerType string var replyPeerID int64 var discussionChannelID int64 var discussionMessageID int var entities, reply, forward, action string var count int var mediaJSON string if err := row.Scan( &msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID, &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, &count, ); err != nil { return domain.ChannelMessage{}, 0, err } msg.From.Type = domain.PeerType(fromType) if sendAsType.Valid && sendAsID.Valid { msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64} } parsedEntities, err := decodeMessageEntities(entities) if err != nil { return domain.ChannelMessage{}, 0, err } msg.Entities = parsedEntities msg.ReplyTo = channelMessageReplyFromColumns(decodeJSONPtr[domain.MessageReply](reply), replyMsgID, replyPeerType, replyPeerID, replyTopID) msg.Forward = decodeJSONPtr[domain.MessageForward](forward) if discussionChannelID != 0 && discussionMessageID != 0 { msg.Discussion = &domain.ChannelDiscussionRef{ChannelID: discussionChannelID, MessageID: discussionMessageID} } msg.Action = decodeJSONPtr[domain.ChannelMessageAction](action) msg.Media, err = decodeMessageMedia(mediaJSON) if err != nil { return domain.ChannelMessage{}, 0, err } return msg, count, nil } func scanChannelMessagePeerReaction(row rowScanner, viewerUserID int64) (domain.ChannelMessagePeerReaction, error) { var out domain.ChannelMessagePeerReaction var reactionType, reactionValue string if err := row.Scan( &out.ChannelID, &out.MessageID, &out.UserID, &out.SenderUserID, &reactionType, &reactionValue, &out.Big, &out.Unread, &out.ChosenOrder, &out.Date, ); err != nil { return domain.ChannelMessagePeerReaction{}, err } out.My = out.UserID == viewerUserID out.Reaction = domain.MessageReaction{ Type: domain.MessageReactionType(reactionType), Emoticon: reactionValue, } return out, nil } func channelMessageReplyFromColumns(reply *domain.MessageReply, msgID int, peerType string, peerID int64, topID int) *domain.MessageReply { if reply != nil { if reply.MessageID == 0 { reply.MessageID = msgID } if reply.TopMessageID == 0 { reply.TopMessageID = topID } if reply.Peer.ID == 0 && peerType != "" && peerID != 0 { reply.Peer = domain.Peer{Type: domain.PeerType(peerType), ID: peerID} } if reply.MessageID <= 0 && reply.TopMessageID <= 0 { return nil } return reply } if msgID <= 0 && topID <= 0 { return nil } out := &domain.MessageReply{ MessageID: msgID, TopMessageID: topID, } if peerType != "" && peerID != 0 { out.Peer = domain.Peer{Type: domain.PeerType(peerType), ID: peerID} } return out } func scanChannelEvent(row rowScanner) (domain.ChannelUpdateEvent, int, error) { var event domain.ChannelUpdateEvent var typ string var messageID int var messageIDs, userIDs, payload string if err := row.Scan( &event.ChannelID, &event.Pts, &event.PtsCount, &event.Date, &typ, &messageID, &messageIDs, &event.SenderUserID, &userIDs, &payload, ); err != nil { return domain.ChannelUpdateEvent{}, 0, err } event.Type = domain.ChannelUpdateEventType(typ) _ = json.Unmarshal([]byte(messageIDs), &event.MessageIDs) _ = json.Unmarshal([]byte(userIDs), &event.UserIDs) var data struct { Pinned bool `json:"pinned"` Message domain.ChannelMessage `json:"message"` PreviousParticipant domain.ChannelMember `json:"previous_participant"` Participant domain.ChannelMember `json:"participant"` } _ = json.Unmarshal([]byte(payload), &data) event.Pinned = data.Pinned if data.Message.ID != 0 { event.Message = data.Message } event.Previous = data.PreviousParticipant event.Participant = data.Participant return event, messageID, nil } func scanChannelAdminLogEvent(row rowScanner) (domain.ChannelAdminLogEvent, error) { var event domain.ChannelAdminLogEvent var typ string var prevParticipant, newParticipant, participant, message, prevMessage, newMessage string if err := row.Scan( &event.ChannelID, &event.ID, &event.UserID, &event.Date, &typ, &event.PrevString, &event.NewString, &event.PrevBool, &event.NewBool, &event.PrevInt, &event.NewInt, &prevParticipant, &newParticipant, &participant, &message, &prevMessage, &newMessage, &event.Query, ); err != nil { return domain.ChannelAdminLogEvent{}, err } event.Type = domain.ChannelAdminLogEventType(typ) event.PrevParticipant = decodeJSONPtr[domain.ChannelMember](prevParticipant) event.NewParticipant = decodeJSONPtr[domain.ChannelMember](newParticipant) event.Participant = decodeJSONPtr[domain.ChannelMember](participant) event.Message = decodeJSONPtr[domain.ChannelMessage](message) event.PrevMessage = decodeJSONPtr[domain.ChannelMessage](prevMessage) event.NewMessage = decodeJSONPtr[domain.ChannelMessage](newMessage) return event, nil } func channelDialogToDialog(dialog domain.ChannelDialog) domain.Dialog { return domain.Dialog{ Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID}, FolderID: dialog.FolderID, TopMessage: dialog.TopMessageID, TopMessageDate: dialog.TopMessageDate, ReadInboxMaxID: dialog.ReadInboxMaxID, ReadOutboxMaxID: dialog.ReadOutboxMaxID, UnreadCount: dialog.UnreadCount, UnreadMentions: dialog.UnreadMentions, UnreadReactions: dialog.UnreadReactions, Pinned: dialog.Pinned, PinnedOrder: dialog.PinnedOrder, UnreadMark: dialog.UnreadMark, ViewForumAsMessages: dialog.ViewForumAsMessages, } } func channelDialogMatchesFilter(dialog domain.Dialog, channel domain.Channel, filter domain.DialogFilter) bool { if filter.HasFolderID { if filter.FolderID < domain.DialogCustomFolderMinID { if dialog.FolderID != filter.FolderID { return false } } else if filter.Folder == nil { return false } } if filter.PinnedOnly && !dialog.Pinned { return false } if filter.ExcludePinned && dialog.Pinned { return false } if !channelDialogAfterOffset(dialog, filter) { return false } if filter.Folder == nil { return true } folder := filter.Folder if peerInDialogFolder(dialog.Peer, folder.ExcludePeers) { return false } if folder.ExcludeRead && dialog.UnreadCount == 0 && !dialog.UnreadMark { return false } if folder.ExcludeArchived && dialog.FolderID == domain.DialogArchiveFolderID { return false } if peerInDialogFolder(dialog.Peer, folder.IncludePeers) || peerInDialogFolder(dialog.Peer, folder.PinnedPeers) { return true } if channel.Megagroup && folder.Groups { return true } if channel.Broadcast && folder.Broadcasts { return true } return !folder.Groups && !folder.Broadcasts && len(folder.IncludePeers) == 0 } func channelDialogAfterOffset(dialog domain.Dialog, filter domain.DialogFilter) bool { if filter.OffsetDate <= 0 && filter.OffsetID <= 0 { if filter.HasOffsetPeer && filter.OffsetPeer == dialog.Peer { return false } return true } if filter.OffsetDate > 0 { if dialog.TopMessageDate != filter.OffsetDate { return dialog.TopMessageDate < filter.OffsetDate } if filter.OffsetID <= 0 { return false } if dialog.TopMessage != filter.OffsetID { return dialog.TopMessage < filter.OffsetID } if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { return dialog.Peer.ID < filter.OffsetPeer.ID } return false } if dialog.TopMessage != filter.OffsetID { return dialog.TopMessage < filter.OffsetID } if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { return dialog.Peer.ID < filter.OffsetPeer.ID } return false } func peerInDialogFolder(peer domain.Peer, items []domain.DialogFolderPeer) bool { for _, item := range items { if item.Peer == peer { return true } } return false } func channelFolderPeerIDs(primary []domain.DialogFolderPeer, rest ...[]domain.DialogFolderPeer) []int64 { total := len(primary) for _, items := range rest { total += len(items) } seen := make(map[int64]struct{}, minInt(total, domain.MaxDialogFolderPeers)) out := make([]int64, 0, minInt(total, domain.MaxDialogFolderPeers)) appendOne := func(items []domain.DialogFolderPeer) { for _, item := range items { if len(out) >= domain.MaxDialogFolderPeers { return } if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 { continue } if _, ok := seen[item.Peer.ID]; ok { continue } seen[item.Peer.ID] = struct{}{} out = append(out, item.Peer.ID) } } appendOne(primary) for _, items := range rest { appendOne(items) } return out } func validateChannelMemberVisible(member domain.ChannelMember) error { switch member.Status { case domain.ChannelMemberActive: if member.BannedRights.ViewMessages { return domain.ErrChannelUserBanned } return nil case domain.ChannelMemberBanned, domain.ChannelMemberKicked: return domain.ErrChannelUserBanned default: return domain.ErrChannelPrivate } } func canPostChannel(member domain.ChannelMember) bool { return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostMessages) } func canSendChannelMessage(channel domain.Channel, member domain.ChannelMember) bool { if channel.Broadcast { return canPostChannel(member) } if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { return true } return !channel.DefaultBannedRights.SendMessages && !member.BannedRights.SendMessages } func canInviteToChannel(channel domain.Channel, member domain.ChannelMember) bool { if member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) { return true } return channel.Megagroup && !channel.DefaultBannedRights.InviteUsers && !member.BannedRights.InviteUsers } func isChannelAdmin(member domain.ChannelMember) bool { return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin } func canChangeChannelInfo(member domain.ChannelMember) bool { return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.ChangeInfo) } func canManageDiscussionBroadcast(member domain.ChannelMember) bool { return canChangeChannelInfo(member) } func canManageDiscussionGroup(member domain.ChannelMember) bool { return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) } func validDiscussionGroup(channel domain.Channel) bool { return channel.Megagroup && !channel.Broadcast && !channel.Forum && !channel.Deleted } func canAddChannelAdmins(member domain.ChannelMember) bool { return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.AddAdmins) } func canBanChannelUsers(member domain.ChannelMember) bool { return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.BanUsers) } func canExportChannelInvite(member domain.ChannelMember) bool { return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) } func canPinChannelMessages(channel domain.Channel, member domain.ChannelMember) bool { if member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) { return true } return channel.Megagroup && !channel.DefaultBannedRights.PinMessages && !member.BannedRights.PinMessages } func canManageForumTopic(channel domain.Channel, member domain.ChannelMember, topic domain.ChannelForumTopic, userID int64) bool { if topic.CreatorUserID == userID { return true } return canPinChannelMessages(channel, member) } func canEditChannelMessage(member domain.ChannelMember) bool { return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.EditMessages) } func canDeleteAnyChannelMessage(member domain.ChannelMember) bool { return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.DeleteMessages) } func channelSlowModeWait(channel domain.Channel, member domain.ChannelMember, now int) int { if channel.SlowmodeSeconds <= 0 || member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { return 0 } next := member.SlowmodeLastSendDate + channel.SlowmodeSeconds if now >= next { return 0 } return next - now } func boolPtr(v bool) *bool { return &v } func channelInitialAvailableMinID(channel domain.Channel) int { if channel.PreHistoryHidden { return channel.TopMessageID } return 0 } func channelInitialAvailableMinPts(channel domain.Channel) int { return channel.Pts } func publicPreviewableChannel(channel domain.Channel) bool { return !channel.Deleted && (channel.Broadcast || channel.Megagroup) && strings.TrimSpace(channel.Username) != "" } func publicPreviewMember(channel domain.Channel, userID int64, existing domain.ChannelMember, found bool) domain.ChannelMember { member := domain.ChannelMember{ ChannelID: channel.ID, UserID: userID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberLeft, AvailableMinID: channelInitialAvailableMinID(channel), AvailableMinPts: channelInitialAvailableMinPts(channel), ReadInboxMaxID: channel.TopMessageID, ReadOutboxMaxID: channel.TopMessageID, } if found { member.InviterUserID = existing.InviterUserID member.JoinedAt = existing.JoinedAt member.LeftAt = existing.LeftAt member.AvailableMinID = maxInt(member.AvailableMinID, existing.AvailableMinID) member.AvailableMinPts = maxInt(member.AvailableMinPts, existing.AvailableMinPts) member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, existing.ReadInboxMaxID) member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, existing.ReadOutboxMaxID) } return member } func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog { topMessageID := channel.TopMessageID if topMessageID <= member.AvailableMinID { topMessageID = 0 } return domain.ChannelDialog{ UserID: userID, ChannelID: channel.ID, TopMessageID: topMessageID, TopMessageDate: channel.Date, ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID), ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID), } } func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool { return rights == domain.ChannelAdminRights{} } func adminRightsSubset(want, have domain.ChannelAdminRights) bool { return (!want.ChangeInfo || have.ChangeInfo) && (!want.PostMessages || have.PostMessages) && (!want.EditMessages || have.EditMessages) && (!want.DeleteMessages || have.DeleteMessages) && (!want.BanUsers || have.BanUsers) && (!want.InviteUsers || have.InviteUsers) && (!want.PinMessages || have.PinMessages) && (!want.AddAdmins || have.AddAdmins) && (!want.ManageCall || have.ManageCall) && (!want.Anonymous || have.Anonymous) } func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool { return rights == domain.ChannelBannedRights{} } func adminLogBanType(previous, next domain.ChannelMember) domain.ChannelAdminLogEventType { if next.Status == domain.ChannelMemberKicked || next.BannedRights.ViewMessages { return domain.ChannelAdminLogParticipantKick } if previous.Status == domain.ChannelMemberKicked || previous.BannedRights.ViewMessages { return domain.ChannelAdminLogParticipantUnkick } if !zeroChannelBannedRights(next.BannedRights) { return domain.ChannelAdminLogParticipantBan } return domain.ChannelAdminLogParticipantUnban } func adminLogEventTypesForFilter(filter domain.ChannelAdminLogFilter) []string { if filter.Empty() { return nil } types := make([]string, 0, 16) add := func(enabled bool, typ domain.ChannelAdminLogEventType) { if enabled { types = append(types, string(typ)) } } add(filter.Join, domain.ChannelAdminLogParticipantJoin) add(filter.Leave, domain.ChannelAdminLogParticipantLeave) add(filter.Invite || filter.Invites, domain.ChannelAdminLogParticipantInvite) add(filter.Ban, domain.ChannelAdminLogParticipantBan) add(filter.Unban, domain.ChannelAdminLogParticipantUnban) add(filter.Kick, domain.ChannelAdminLogParticipantKick) add(filter.Unkick, domain.ChannelAdminLogParticipantUnkick) add(filter.Promote, domain.ChannelAdminLogParticipantPromote) add(filter.Demote, domain.ChannelAdminLogParticipantDemote) if filter.Info { types = append(types, string(domain.ChannelAdminLogChangeTitle), string(domain.ChannelAdminLogChangeUsername), string(domain.ChannelAdminLogChangeLinkedChat), string(domain.ChannelAdminLogToggleSlowMode), ) } if filter.Settings { types = append(types, string(domain.ChannelAdminLogToggleSignatures), string(domain.ChannelAdminLogTogglePreHistoryHidden), string(domain.ChannelAdminLogToggleAntiSpam), string(domain.ChannelAdminLogToggleAutotranslation), ) } add(filter.Forums || filter.Settings, domain.ChannelAdminLogToggleForum) add(filter.Pinned, domain.ChannelAdminLogUpdatePinned) add(filter.Edit, domain.ChannelAdminLogEditMessage) add(filter.Delete, domain.ChannelAdminLogDeleteMessage) add(filter.Send, domain.ChannelAdminLogSendMessage) return types } func adminLogSearchText(event domain.ChannelAdminLogEvent) string { parts := []string{ event.Query, event.PrevString, event.NewString, } for _, msg := range []*domain.ChannelMessage{event.Message, event.PrevMessage, event.NewMessage} { if msg != nil { parts = append(parts, msg.Body) } } return strings.ToLower(strings.TrimSpace(strings.Join(parts, " "))) } func adminLogLikePattern(query string) string { query = strings.ReplaceAll(query, `\`, `\\`) query = strings.ReplaceAll(query, `%`, `\%`) query = strings.ReplaceAll(query, `_`, `\_`) return "%" + query + "%" } func refreshChannelCountsTx(ctx context.Context, tx pgx.Tx, channel domain.Channel) (domain.Channel, error) { var participants, admins, kicked, banned int rows, err := tx.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, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date FROM channel_members WHERE channel_id = $1`, channel.ID) if err != nil { return domain.Channel{}, fmt.Errorf("list channel members for counts: %w", err) } defer rows.Close() for rows.Next() { member, err := scanChannelMember(rows) if err != nil { return domain.Channel{}, err } if member.Status == domain.ChannelMemberKicked { kicked++ } if member.Status != domain.ChannelMemberActive { continue } participants++ if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { admins++ } if !zeroChannelBannedRights(member.BannedRights) { banned++ } } if err := rows.Err(); err != nil { return domain.Channel{}, err } if _, err := tx.Exec(ctx, ` UPDATE channels SET participants_count = $2, admins_count = $3, kicked_count = $4, banned_count = $5, updated_at = now() WHERE id = $1`, channel.ID, participants, admins, kicked, banned); err != nil { return domain.Channel{}, fmt.Errorf("refresh channel counts: %w", err) } channel.ParticipantsCount = participants channel.AdminsCount = admins channel.KickedCount = kicked channel.BannedCount = banned return channel, nil } func creatorChannelMember(channelID, userID int64, date int) domain.ChannelMember { return domain.ChannelMember{ ChannelID: channelID, UserID: userID, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive, JoinedAt: date, AdminRights: domain.ChannelAdminRights{ ChangeInfo: true, PostMessages: true, EditMessages: true, DeleteMessages: true, BanUsers: true, InviteUsers: true, PinMessages: true, AddAdmins: true, ManageCall: true, }, } } func collectChannelEventRefs(event domain.ChannelUpdateEvent, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) { if event.SenderUserID != 0 { userRefs[event.SenderUserID] = struct{}{} } for _, id := range event.UserIDs { if id != 0 { userRefs[id] = struct{}{} } } for _, member := range []domain.ChannelMember{event.Previous, event.Participant} { if member.UserID != 0 { userRefs[member.UserID] = struct{}{} } if member.InviterUserID != 0 { userRefs[member.InviterUserID] = struct{}{} } } collectChannelMessageRefs(event.Message, currentChannelID, userRefs, channelRefs) } func collectChannelMessageRefs(msg domain.ChannelMessage, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) { if msg.SenderUserID != 0 { userRefs[msg.SenderUserID] = struct{}{} } addPeerRef(msg.From, currentChannelID, userRefs, channelRefs) if msg.SendAs != nil { addPeerRef(*msg.SendAs, currentChannelID, userRefs, channelRefs) } if msg.Forward != nil { addPeerRef(msg.Forward.From, currentChannelID, userRefs, channelRefs) } if msg.ReplyTo != nil { addPeerRef(msg.ReplyTo.Peer, currentChannelID, userRefs, channelRefs) } if msg.Action != nil { for _, id := range msg.Action.UserIDs { if id != 0 { userRefs[id] = struct{}{} } } } } func addPeerRef(peer domain.Peer, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) { switch peer.Type { case domain.PeerTypeUser: if peer.ID != 0 { userRefs[peer.ID] = struct{}{} } case domain.PeerTypeChannel: if peer.ID != 0 && peer.ID != currentChannelID { channelRefs[peer.ID] = struct{}{} } } } func mapKeysInt64(items map[int64]struct{}) []int64 { if len(items) == 0 { return nil } out := make([]int64, 0, len(items)) for id := range items { if id != 0 { out = append(out, id) } } sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) return out } func uniqueChannelUserIDs(ids []int64, exclude int64) []int64 { seen := make(map[int64]struct{}, len(ids)) out := make([]int64, 0, len(ids)) for _, id := range ids { if id == 0 || id == exclude { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } return out } func uniqueNonZeroInt64s(items ...int64) []int64 { seen := make(map[int64]struct{}, len(items)) out := make([]int64, 0, len(items)) for _, item := range items { if item == 0 { continue } if _, ok := seen[item]; ok { continue } seen[item] = struct{}{} out = append(out, item) } return out } func channelMemberIDs(members []domain.ChannelMember) []int64 { out := make([]int64, 0, len(members)) for _, member := range members { if member.UserID != 0 { out = append(out, member.UserID) } } return out } func marshalJSON(v any, empty string) ([]byte, error) { if v == nil { return []byte(empty), nil } raw, err := json.Marshal(v) if err != nil { return nil, err } if string(raw) == "null" { return []byte(empty), nil } return raw, nil } func int64s(ids []int64) []int64 { return append([]int64(nil), ids...) } func minInt(a, b int) int { if a < b { return a } return b } func insertChannelInviteTx(ctx context.Context, tx pgx.Tx, invite domain.ChannelInvite) error { if _, err := tx.Exec(ctx, ` INSERT INTO channel_invites ( channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, expire_date, usage_limit, usage_count, requested_count, created_at, updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NULLIF($9,0),NULLIF($10,0),$11,$12,to_timestamp($13),to_timestamp($13))`, invite.ChannelID, invite.InviteID, invite.Hash, invite.AdminUserID, invite.Title, invite.Permanent, invite.Revoked, invite.RequestNeeded, invite.ExpireDate, invite.UsageLimit, invite.UsageCount, invite.RequestedCount, invite.Date); err != nil { return fmt.Errorf("insert channel invite: %w", err) } if _, err := tx.Exec(ctx, ` INSERT INTO channel_invite_hashes (hash, channel_id, invite_id) VALUES ($1,$2,$3) ON CONFLICT (hash) DO UPDATE SET channel_id = EXCLUDED.channel_id, invite_id = EXCLUDED.invite_id, updated_at = now()`, invite.Hash, invite.ChannelID, invite.InviteID); err != nil { return fmt.Errorf("insert channel invite hash: %w", err) } return nil } func (s *ChannelStore) getInviteByHash(ctx context.Context, db sqlcgen.DBTX, hash string) (domain.Channel, domain.ChannelInvite, error) { return s.getInviteByHashLocked(ctx, db, hash, false) } func (s *ChannelStore) getInviteByHashForUpdate(ctx context.Context, tx pgx.Tx, hash string) (domain.Channel, domain.ChannelInvite, error) { return s.getInviteByHashLocked(ctx, tx, hash, true) } func (s *ChannelStore) getInviteByHashLocked(ctx context.Context, db sqlcgen.DBTX, hash string, forUpdate bool) (domain.Channel, domain.ChannelInvite, error) { lockClause := "" if forUpdate { lockClause = " FOR UPDATE OF i" } row := db.QueryRow(ctx, ` SELECT `+channelColumns+`, i.channel_id, i.invite_id, i.hash, i.admin_user_id, i.title, i.permanent, i.revoked, i.request_needed, COALESCE(i.expire_date, 0), COALESCE(i.usage_limit, 0), i.usage_count, i.requested_count, EXTRACT(EPOCH FROM i.created_at)::int FROM channel_invite_hashes h JOIN channel_invites i ON i.channel_id = h.channel_id AND i.invite_id = h.invite_id JOIN channels c ON c.id = i.channel_id AND NOT c.deleted WHERE h.hash = $1 AND NOT i.revoked`+lockClause, hash) var ch domain.Channel var invite domain.ChannelInvite var rights, reactionPolicy string if err := row.Scan( &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.LinkedChatID, &ch.SlowmodeSeconds, &rights, &reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, &invite.ChannelID, &invite.InviteID, &invite.Hash, &invite.AdminUserID, &invite.Title, &invite.Permanent, &invite.Revoked, &invite.RequestNeeded, &invite.ExpireDate, &invite.UsageLimit, &invite.UsageCount, &invite.RequestedCount, &invite.Date, ); err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.Channel{}, domain.ChannelInvite{}, domain.ErrInviteHashInvalid } return domain.Channel{}, domain.ChannelInvite{}, err } _ = json.Unmarshal([]byte(rights), &ch.DefaultBannedRights) _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) return ch, invite, nil } func (s *ChannelStore) getInviteByChannelHash(ctx context.Context, db sqlcgen.DBTX, channelID int64, hash string, forUpdate bool) (domain.ChannelInvite, error) { lockClause := "" if forUpdate { lockClause = " FOR UPDATE" } row := db.QueryRow(ctx, ` SELECT channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, COALESCE(expire_date, 0), COALESCE(usage_limit, 0), usage_count, requested_count, EXTRACT(EPOCH FROM created_at)::int FROM channel_invites WHERE channel_id = $1 AND hash = $2`+lockClause, channelID, strings.TrimSpace(hash)) invite, err := scanChannelInvite(row) if errors.Is(err, pgx.ErrNoRows) { return domain.ChannelInvite{}, domain.ErrInviteHashInvalid } return invite, err } func (s *ChannelStore) getInviteByID(ctx context.Context, db sqlcgen.DBTX, channelID, inviteID int64, forUpdate bool) (domain.ChannelInvite, error) { lockClause := "" if forUpdate { lockClause = " FOR UPDATE" } row := db.QueryRow(ctx, ` SELECT channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, COALESCE(expire_date, 0), COALESCE(usage_limit, 0), usage_count, requested_count, EXTRACT(EPOCH FROM created_at)::int FROM channel_invites WHERE channel_id = $1 AND invite_id = $2`+lockClause, channelID, inviteID) invite, err := scanChannelInvite(row) if errors.Is(err, pgx.ErrNoRows) { return domain.ChannelInvite{}, domain.ErrInviteHashInvalid } return invite, err } func scanChannelInvite(row rowScanner) (domain.ChannelInvite, error) { var invite domain.ChannelInvite err := row.Scan( &invite.ChannelID, &invite.InviteID, &invite.Hash, &invite.AdminUserID, &invite.Title, &invite.Permanent, &invite.Revoked, &invite.RequestNeeded, &invite.ExpireDate, &invite.UsageLimit, &invite.UsageCount, &invite.RequestedCount, &invite.Date, ) return invite, err } func (s *ChannelStore) newPostgresReplacementInvite(old domain.ChannelInvite, date int) (domain.ChannelInvite, error) { inviteID, err := randomPositiveInt64() if err != nil { return domain.ChannelInvite{}, err } hash, err := randomInviteHash() if err != nil { return domain.ChannelInvite{}, err } if date == 0 { date = nowUnix() } return domain.ChannelInvite{ ChannelID: old.ChannelID, InviteID: inviteID, Hash: hash, AdminUserID: old.AdminUserID, Permanent: old.Permanent, Date: date, }, nil } func (s *ChannelStore) getPendingInviteImporterTx(ctx context.Context, tx pgx.Tx, channelID, userID int64, forUpdate bool) (domain.ChannelInviteImporter, error) { lockClause := "" if forUpdate { lockClause = " FOR UPDATE" } row := tx.QueryRow(ctx, ` SELECT channel_id, invite_id, user_id, date, requested, approved_by, via_chatlist, about FROM channel_invite_importers WHERE channel_id = $1 AND user_id = $2 AND requested`+lockClause, channelID, userID) var importer domain.ChannelInviteImporter err := row.Scan(&importer.ChannelID, &importer.InviteID, &importer.UserID, &importer.Date, &importer.Requested, &importer.ApprovedBy, &importer.ViaChatlist, &importer.About) if errors.Is(err, pgx.ErrNoRows) { return domain.ChannelInviteImporter{}, domain.ErrHideRequesterMissing } return importer, err } func deletePendingInviteImporterTx(ctx context.Context, tx pgx.Tx, invite domain.ChannelInvite, userID int64) error { tag, err := tx.Exec(ctx, ` DELETE FROM channel_invite_importers WHERE channel_id = $1 AND user_id = $2 AND requested`, invite.ChannelID, userID) if err != nil { return fmt.Errorf("delete pending channel invite importer: %w", err) } if tag.RowsAffected() == 0 { return domain.ErrHideRequesterMissing } if invite.InviteID == 0 { return nil } if _, err := tx.Exec(ctx, ` UPDATE channel_invites SET requested_count = CASE WHEN requested_count > 0 THEN requested_count - 1 ELSE 0 END, updated_at = now() WHERE channel_id = $1 AND invite_id = $2`, invite.ChannelID, invite.InviteID); err != nil { return fmt.Errorf("decrement channel invite requested count: %w", err) } return nil } func decodeJSONPtr[T any](raw string) *T { if raw == "" || raw == "{}" || raw == "null" { return nil } var out T if err := json.Unmarshal([]byte(raw), &out); err != nil { return nil } return &out } func randomChannelAccessHash() (int64, error) { return randomPositiveInt64() } func randomPositiveInt64() (int64, error) { var b [8]byte if _, err := rand.Read(b[:]); err != nil { return 0, fmt.Errorf("rand int64: %w", err) } return int64(binary.LittleEndian.Uint64(b[:]) & ((1 << 63) - 1)), nil } func randomInviteHash() (string, error) { var b [18]byte if _, err := rand.Read(b[:]); err != nil { return "", fmt.Errorf("rand invite hash: %w", err) } return base64.RawURLEncoding.EncodeToString(b[:]), nil } func nowUnix() int { return int(time.Now().Unix()) } func isUniqueViolation(err error) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == "23505" } func isRetryablePostgresTxError(err error) bool { var pgErr *pgconn.PgError if !errors.As(err, &pgErr) { return false } return pgErr.Code == "40P01" || pgErr.Code == "40001" } type pgChannelIDAllocator struct { db sqlcgen.DBTX } func (a pgChannelIDAllocator) NextChannelID(ctx context.Context) (int64, error) { current, err := a.CurrentChannelID(ctx) if err != nil { return 0, err } return current + 1, nil } func (a pgChannelIDAllocator) CurrentChannelID(ctx context.Context) (int64, error) { var id int64 err := a.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channels`).Scan(&id) return id, err } type pgChannelPtsAllocator struct { db sqlcgen.DBTX } func (a pgChannelPtsAllocator) NextChannelPts(ctx context.Context, channelID int64) (int, error) { current, err := a.CurrentChannelPts(ctx, channelID) if err != nil { return 0, err } return current + 1, nil } func (a pgChannelPtsAllocator) NextChannelPtsN(ctx context.Context, channelID int64, count int) (int, error) { if count <= 0 { count = 1 } current, err := a.CurrentChannelPts(ctx, channelID) if err != nil { return 0, err } return current + count, nil } func (a pgChannelPtsAllocator) CurrentChannelPts(ctx context.Context, channelID int64) (int, error) { var pts int err := a.db.QueryRow(ctx, `SELECT COALESCE(MAX(pts), 0) FROM channel_update_events WHERE channel_id = $1`, channelID).Scan(&pts) return pts, err } type pgChannelMessageIDAllocator struct { db sqlcgen.DBTX } func (a pgChannelMessageIDAllocator) NextChannelMessageID(ctx context.Context, channelID int64) (int, error) { current, err := a.CurrentChannelMessageID(ctx, channelID) if err != nil { return 0, err } return current + 1, nil } func (a pgChannelMessageIDAllocator) CurrentChannelMessageID(ctx context.Context, channelID int64) (int, error) { var id int err := a.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1`, channelID).Scan(&id) return id, err }