Ctrl K
ComunidadGuías…

internal/store/postgres/channel_updates.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package postgres

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"

	"github.com/jackc/pgx/v5"
	"go.uber.org/zap"

	"telesrv/internal/domain"
	"telesrv/internal/store/postgres/sqlcgen"
)

var errChannelDifferenceCutChanged = errors.New("channel difference stable cut changed")

func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) {
	for attempt := 0; attempt < 3; attempt++ {
		diff, retry, err := s.listChannelDifferenceAttempt(ctx, req)
		if !retry {
			return diff, err
		}
		if s.rowCache != nil {
			s.rowCache.delete(req.ChannelID)
		}
		if s.differenceCache != nil {
			s.differenceCache.deleteChannel(req.ChannelID)
		}
	}
	return domain.ChannelDifference{}, fmt.Errorf("list channel difference: stable cut changed repeatedly for channel %d", req.ChannelID)
}

func (s *ChannelStore) listChannelDifferenceAttempt(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, bool, error) {
	channel, member, preview, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
	if err != nil {
		return domain.ChannelDifference{}, false, err
	}
	if req.Pts < 0 {
		return domain.ChannelDifference{}, false, domain.ErrPersistentTimestamp
	}
	if req.Pts > channel.Pts {
		if s.rowCache != nil {
			// A committed send can reach a client before LISTEN/NOTIFY has
			// invalidated the shared channel row. Only an authoritative read
			// can prove that this cursor is in the future. Retry through the
			// normal viewer/event path after invalidating a lagging cache.
			current, err := getChannelByID(ctx, s.db, req.ChannelID)
			if err != nil {
				return domain.ChannelDifference{}, false, err
			}
			if req.Pts <= current.Pts {
				return domain.ChannelDifference{}, true, nil
			}
		}
		return domain.ChannelDifference{}, false, 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
	}
	// A current-PTS request has no retention range to prove. Access, membership,
	// available_min_pts and future/stale bounds were already checked above; do
	// not spend two pool acquisitions reading a checkpoint and an empty event
	// range during reconnect storms.
	if req.Pts == channel.Pts {
		diff := domain.ChannelDifference{
			Channel: channel,
			Self:    member,
			Pts:     channel.Pts,
			Final:   true,
			Timeout: 30,
		}
		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{}, false, err
			}
			diff.Dialog = dialog
		}
		return diff, false, nil
	}
	key := channelDifferenceBaseKey{
		channelID:     req.ChannelID,
		requestPts:    req.Pts,
		capturedPts:   channel.Pts,
		capturedTopID: channel.TopMessageID,
		limit:         limit,
	}
	sharedBase := !channel.Monoforum && s.differenceCache != nil
	load := func() (channelDifferenceBase, error) {
		return s.loadChannelDifferenceBase(ctx, channel, member, req.UserID, req.Pts, limit, sharedBase)
	}
	var base channelDifferenceBase
	if channel.Monoforum || s.differenceCache == nil {
		base, err = load()
	} else {
		base, err = s.differenceCache.getOrLoad(ctx, key, load)
	}
	if errors.Is(err, errChannelDifferenceCutChanged) {
		return domain.ChannelDifference{}, true, nil
	}
	if err != nil {
		return domain.ChannelDifference{}, false, err
	}
	if base.tooLong {
		diff := domain.ChannelDifference{
			Channel: channel,
			Self:    member,
			Pts:     channel.Pts,
			Final:   true,
			TooLong: true,
			Timeout: 30,
		}
		for _, msg := range base.messages {
			if member.AvailableMinID > 0 && msg.ID <= member.AvailableMinID {
				continue
			}
			if !channelMessageVisibleToViewer(channel, member, req.UserID, msg) {
				continue
			}
			diff.NewMessages = append(diff.NewMessages, msg)
		}
		if err := populateChannelDifferenceUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages, base); err != nil {
			return domain.ChannelDifference{}, false, 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{}, false, err
			}
			diff.Dialog = dialog
		}
		return diff, false, nil
	}
	diff := domain.ChannelDifference{Channel: channel, Self: member, Pts: channel.Pts, Final: true, Timeout: 30}
	var visibleMonoforumMessageIDs map[int]struct{}
	if channel.Monoforum && !member.CanManageDirectMessages() {
		messageIDs := make([]int, 0)
		for _, event := range base.events {
			messageIDs = append(messageIDs, event.MessageIDs...)
		}
		visibleMonoforumMessageIDs, err = s.monoforumVisibleMessageIDs(ctx, req.ChannelID, req.UserID, messageIDs)
		if err != nil {
			return domain.ChannelDifference{}, false, err
		}
	}
	for _, event := range base.events {
		visibleEvent, ok := domain.FilterChannelUpdateEventForAvailableMinID(event, member.AvailableMinID)
		if !ok {
			continue
		}
		event = visibleEvent
		if channel.Monoforum && !member.CanManageDirectMessages() {
			event, ok = filterMonoforumEventForUser(event, req.UserID, visibleMonoforumMessageIDs)
			if !ok {
				continue
			}
		}
		if preview && event.Type == domain.ChannelUpdateParticipant {
			continue
		}
		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 len(diff.Events) == 0 {
		diff.Pts = base.lastPts
	} else if base.lastPts > diff.Pts {
		diff.Pts = base.lastPts
	}
	if err := populateChannelDifferenceUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages, base); err != nil {
		return domain.ChannelDifference{}, false, err
	}
	// Batch-fill the unread/mention flags for message-bearing events in OtherUpdates in one go
	// (previously an N+1 of one SQL per event).
	otherMsgs := make([]domain.ChannelMessage, 0, len(diff.OtherUpdates))
	otherIdx := make([]int, 0, len(diff.OtherUpdates))
	for i := range diff.OtherUpdates {
		if diff.OtherUpdates[i].Message.ID == 0 {
			continue
		}
		otherMsgs = append(otherMsgs, diff.OtherUpdates[i].Message)
		otherIdx = append(otherIdx, i)
	}
	if len(otherMsgs) > 0 {
		if err := populateChannelDifferenceUnreadFlags(ctx, s.db, req.UserID, otherMsgs, base); err != nil {
			return domain.ChannelDifference{}, false, err
		}
		for j, i := range otherIdx {
			diff.OtherUpdates[i].Message = otherMsgs[j]
		}
	}
	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{}, false, err
		}
		diff.Dialog = dialog
	}
	diff.Final = base.lastPts >= channel.Pts
	return diff, false, nil
}

func (s *ChannelStore) loadChannelDifferenceBase(
	ctx context.Context,
	channel domain.Channel,
	member domain.ChannelMember,
	viewerUserID int64,
	requestPts int,
	limit int,
	loadMentionCandidates bool,
) (channelDifferenceBase, error) {
	checkpoint, err := getChannelUpdateCheckpoint(ctx, s.db, channel.ID)
	if err != nil {
		return channelDifferenceBase{}, err
	}
	base := channelDifferenceBase{
		retainedThroughPts: checkpoint.RetainedThroughPts,
		lastPts:            requestPts,
	}
	if requestPts < checkpoint.RetainedThroughPts || channel.Pts-requestPts > limit {
		base.tooLong = true
		args := []any{channel.ID, channel.TopMessageID}
		where := "channel_id = $1 AND id <= $2 AND NOT deleted"
		// Non-monoforum pages are viewer-independent: apply available_min after
		// the shared lookup. Monoforum latest-100 selection is viewer-specific,
		// so that path bypasses the shared cache and keeps its predicate here.
		if channel.Monoforum {
			if member.AvailableMinID > 0 {
				args = append(args, member.AvailableMinID)
				where += fmt.Sprintf(" AND id > $%d", len(args))
			}
			if !member.CanManageDirectMessages() {
				args = append(args, viewerUserID)
				where += fmt.Sprintf(" AND saved_peer_type = 'user' AND saved_peer_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 channelDifferenceBase{}, fmt.Errorf("list channel too long messages: %w", err)
		}
		for rows.Next() {
			msg, err := scanChannelMessage(rows)
			if err != nil {
				rows.Close()
				return channelDifferenceBase{}, err
			}
			base.messages = append(base.messages, msg)
		}
		if err := rows.Err(); err != nil {
			rows.Close()
			return channelDifferenceBase{}, err
		}
		rows.Close()
		if loadMentionCandidates {
			if err := s.loadChannelDifferenceMentionCandidates(ctx, channel.ID, &base); err != nil {
				return channelDifferenceBase{}, err
			}
		}
		if err := s.verifyChannelDifferenceCut(ctx, channel, checkpoint.RetainedThroughPts); err != nil {
			return channelDifferenceBase{}, err
		}
		return base, 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 AND pts <= $3
ORDER BY pts ASC
LIMIT $4`, channel.ID, requestPts, channel.Pts, limit)
	if err != nil {
		return channelDifferenceBase{}, fmt.Errorf("list channel difference: %w", err)
	}
	for rows.Next() {
		event, messageID, err := scanChannelEvent(rows)
		if err != nil {
			rows.Close()
			return channelDifferenceBase{}, err
		}
		ptsCount := event.PtsCount
		if ptsCount <= 0 {
			ptsCount = 1
		}
		if event.Pts != base.lastPts+ptsCount {
			s.log.Warn("channel_difference_stopped_at_gap",
				zap.String("scope", "channel"),
				zap.Int64("channel_id", channel.ID),
				zap.Int("request_pts", requestPts),
				zap.Int("channel_pts", channel.Pts),
				zap.Int("returned_pts", base.lastPts),
				zap.Int("expected_pts", base.lastPts+ptsCount),
				zap.Int("got_pts", event.Pts),
				zap.Int("got_pts_count", ptsCount),
				zap.String("event_type", string(event.Type)),
				zap.Int("limit", limit),
			)
			break
		}
		if messageID != 0 && event.Message.ID == 0 {
			event.Message, err = s.getChannelMessageAtOrBeforePts(ctx, channel.ID, messageID, channel.Pts)
			if err != nil {
				rows.Close()
				return channelDifferenceBase{}, err
			}
		}
		base.lastPts = event.Pts
		base.events = append(base.events, event)
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		return channelDifferenceBase{}, err
	}
	rows.Close()
	if loadMentionCandidates {
		if err := s.loadChannelDifferenceMentionCandidates(ctx, channel.ID, &base); err != nil {
			return channelDifferenceBase{}, err
		}
	}
	if err := s.verifyChannelDifferenceCut(ctx, channel, checkpoint.RetainedThroughPts); err != nil {
		return channelDifferenceBase{}, err
	}
	return base, nil
}

func (s *ChannelStore) loadChannelDifferenceMentionCandidates(ctx context.Context, channelID int64, base *channelDifferenceBase) error {
	if base == nil {
		return nil
	}
	base.candidatesKnown = true
	base.mentionCandidateIDs = make(map[int]struct{})
	messageIDs := make([]int, 0, len(base.messages)+len(base.events))
	seen := make(map[int]struct{}, cap(messageIDs))
	add := func(id int) {
		if id <= 0 {
			return
		}
		if _, ok := seen[id]; ok {
			return
		}
		seen[id] = struct{}{}
		messageIDs = append(messageIDs, id)
	}
	for _, message := range base.messages {
		add(message.ID)
	}
	for _, event := range base.events {
		add(event.Message.ID)
	}
	if len(messageIDs) == 0 {
		return nil
	}
	rows, err := s.db.Query(ctx, `
SELECT DISTINCT message_id
FROM channel_unread_mention_index
WHERE channel_id = $1 AND message_id = ANY($2::int[])`, channelID, int32s(messageIDs))
	if err != nil {
		return fmt.Errorf("load channel difference mention candidates: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		var messageID int
		if err := rows.Scan(&messageID); err != nil {
			return err
		}
		base.mentionCandidateIDs[messageID] = struct{}{}
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("read channel difference mention candidates: %w", err)
	}
	return nil
}

func populateChannelDifferenceUnreadFlags(
	ctx context.Context,
	db sqlcgen.DBTX,
	viewerUserID int64,
	messages []domain.ChannelMessage,
	base channelDifferenceBase,
) error {
	if !base.candidatesKnown {
		return populateChannelMessageUnreadFlags(ctx, db, viewerUserID, messages)
	}
	selected := make([]domain.ChannelMessage, 0, len(messages))
	indexes := make([]int, 0, len(messages))
	for i, message := range messages {
		if _, ok := base.mentionCandidateIDs[message.ID]; !ok {
			continue
		}
		selected = append(selected, message)
		indexes = append(indexes, i)
	}
	if len(selected) == 0 {
		return nil
	}
	if err := populateChannelMessageUnreadFlags(ctx, db, viewerUserID, selected); err != nil {
		return err
	}
	for i, messageIndex := range indexes {
		messages[messageIndex].Mentioned = selected[i].Mentioned
		messages[messageIndex].MediaUnread = selected[i].MediaUnread
	}
	return nil
}

func (s *ChannelStore) verifyChannelDifferenceCut(ctx context.Context, captured domain.Channel, retainedThroughPts int) error {
	var pts, topMessageID, floor int
	err := s.db.QueryRow(ctx, `
SELECT c.pts, c.top_message_id, cp.retained_through_pts
FROM channels c
JOIN channel_update_checkpoints cp ON cp.channel_id = c.id
WHERE c.id = $1 AND NOT c.deleted`, captured.ID).Scan(&pts, &topMessageID, &floor)
	if err != nil {
		return fmt.Errorf("verify channel difference cut: %w", err)
	}
	if pts != captured.Pts || topMessageID != captured.TopMessageID || floor != retainedThroughPts {
		return errChannelDifferenceCutChanged
	}
	return nil
}

func (s *ChannelStore) getChannelMessageAtOrBeforePts(ctx context.Context, channelID int64, messageID, capturedPts int) (domain.ChannelMessage, error) {
	msg, err := scanChannelMessage(s.db.QueryRow(ctx, `
SELECT `+channelMessageColumns+`
FROM channel_messages
WHERE channel_id = $1 AND id = $2 AND pts <= $3`, channelID, messageID, capturedPts))
	if err != nil {
		return domain.ChannelMessage{}, fmt.Errorf("load channel difference legacy message at stable cut: %w", err)
	}
	return msg, nil
}

func (s *ChannelStore) monoforumVisibleMessageIDs(ctx context.Context, channelID, userID int64, ids []int) (map[int]struct{}, error) {
	visible := make(map[int]struct{})
	if len(ids) == 0 {
		return visible, nil
	}
	rows, err := s.db.Query(ctx, `
SELECT id
FROM channel_messages
WHERE channel_id = $1
  AND id = ANY($2::int[])
  AND saved_peer_type = 'user'
  AND saved_peer_id = $3`, channelID, int32s(ids), userID)
	if err != nil {
		return nil, fmt.Errorf("list visible monoforum message ids: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		var id int
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		visible[id] = struct{}{}
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return visible, nil
}

func filterMonoforumEventForUser(event domain.ChannelUpdateEvent, userID int64, visibleMessageIDs map[int]struct{}) (domain.ChannelUpdateEvent, bool) {
	if event.Message.ID != 0 {
		return event, event.Message.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: userID})
	}
	if len(event.MessageIDs) == 0 {
		return event, false
	}
	ids := make([]int, 0, len(event.MessageIDs))
	for _, id := range event.MessageIDs {
		if _, ok := visibleMessageIDs[id]; ok {
			ids = append(ids, id)
		}
	}
	if len(ids) == 0 {
		return event, false
	}
	event.MessageIDs = ids
	return event, true
}

func (s *ChannelStore) MaxChannelPts(ctx context.Context, channelID int64) (int, error) {
	var pts int
	err := s.db.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, channelID).Scan(&pts)
	return pts, err
}

func (s *ChannelStore) MaxChannelPtsBatch(ctx context.Context, channelIDs []int64) (map[int64]int, error) {
	out := make(map[int64]int, len(channelIDs))
	if len(channelIDs) == 0 {
		return out, nil
	}
	rows, err := s.db.Query(ctx, `SELECT id, pts FROM channels WHERE id = ANY($1::bigint[])`, channelIDs)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		var channelID int64
		var pts int
		if err := rows.Scan(&channelID, &pts); err != nil {
			return nil, err
		}
		out[channelID] = pts
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return out, 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) reserveChannelPts(ctx context.Context, db sqlcgen.DBTX, channelID int64) (int, error) {
	return s.reserveChannelPtsN(ctx, db, channelID, 1)
}

func (s *ChannelStore) reserveChannelPtsN(ctx context.Context, db sqlcgen.DBTX, channelID int64, count int) (int, error) {
	count = normalizePtsCount(count)
	caller := traceCaller(2)
	pts, err := reserveChannelPts(ctx, db, channelID, count)
	if err != nil {
		s.log.Warn("pts_reserve_failed",
			zap.String("scope", "channel"),
			zap.Int64("channel_id", channelID),
			zap.Int("pts_count", count),
			zap.String("caller", traceCaller(2)),
			zap.Error(err),
			zap.Error(ctx.Err()),
		)
		return 0, err
	}
	s.log.Debug("pts_reserve",
		zap.String("scope", "channel"),
		zap.Int64("channel_id", channelID),
		zap.Int("pts", pts),
		zap.Int("pts_count", maxInt(count, 1)),
		zap.String("caller", caller),
	)
	return pts, nil
}

func reserveChannelPts(ctx context.Context, db sqlcgen.DBTX, channelID int64, count int) (int, error) {
	count = normalizePtsCount(count)
	if channelID == 0 {
		return 0, fmt.Errorf("channel pts: missing channel id")
	}
	var pts int
	if err := db.QueryRow(ctx, `
UPDATE channels
SET pts = pts + $2,
    updated_at = now()
WHERE id = $1
RETURNING pts`, channelID, count).Scan(&pts); err != nil {
		return 0, fmt.Errorf("reserve channel pts: %w", err)
	}
	return pts, 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)
	}
	// The checkpoint is updated in the same business transaction as the event row. Retention may
	// later remove the row, but account-level dirty-channel recovery still has the latest date/pts.
	if _, err := tx.Exec(ctx, `
INSERT INTO channel_update_checkpoints (
    channel_id, retained_through_pts, latest_event_date, latest_pts
) VALUES ($1, 0, $2, $3)
ON CONFLICT (channel_id) DO UPDATE SET
    latest_event_date = GREATEST(channel_update_checkpoints.latest_event_date, EXCLUDED.latest_event_date),
    latest_pts = GREATEST(channel_update_checkpoints.latest_pts, EXCLUDED.latest_pts),
    updated_at = now()`, event.ChannelID, event.Date, event.Pts); err != nil {
		return fmt.Errorf("upsert channel update checkpoint: %w", err)
	}
	return nil
}

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 channelInitialAvailableMinPts(channel domain.Channel) int {
	return channel.Pts
}

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)
	add(filter.EditRank, domain.ChannelAdminLogParticipantEditRank)
	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.ChannelAdminLogToggleSignatureProfiles),
			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
}