Ctrl K
ComunidadGuías…

internal/app/channels/service.go

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

Archivo original ↗Preguntar a KiBot
package channels

import (
	"context"
	"errors"
	"strings"
	"time"
	"unicode/utf8"

	"telesrv/internal/app/readmodel"
	"telesrv/internal/domain"
	"telesrv/internal/store"
)

// Service exposes channel/supergroup business operations.
type Service struct {
	channels store.ChannelStore
	bots     BotProfileResolver
	versions store.ReadModelVersionStore
	sendGate SendPermissionChecker

	viewCache         *channelViewReadModelCache
	resolveCache      *channelResolveReadModelCache
	mediaCountCache   *mediaCountReadModelCache
	participantCache  *participantsReadModelCache
	activeIDsCache    *activeChannelIDsReadModelCache
	activeIDsShared   store.ActiveChannelIDsPageCache
	activeIDsLoader   store.ActiveChannelIDsPageLoader
	activeIDsMetrics  ActiveChannelIDsReadModelMetrics
	botMemberIDsCache *activeBotMemberIDsCache
}

type Option func(*Service)

type SendPermissionChecker interface {
	CanSendMessages(ctx context.Context, userID int64) error
}

// channelStatsStore is an optional capability kept out of the broad
// store.ChannelStore contract.  It lets focused test stores stay small while
// both production backends expose the complete bounded stats read model.
type channelStatsStore interface {
	GetChannelStats(ctx context.Context, req domain.ChannelStatsRequest) (domain.ChannelStats, error)
	GetChannelMessageStats(ctx context.Context, req domain.ChannelMessageStatsRequest) (domain.ChannelMessageStats, error)
	ListChannelMessagePublicForwards(ctx context.Context, req domain.ChannelMessagePublicForwardListRequest) (domain.ChannelMessagePublicForwardList, error)
}

// NewService creates a channel service.
func NewService(channels store.ChannelStore, opts ...Option) *Service {
	s := &Service{
		channels:          channels,
		viewCache:         newChannelViewReadModelCache(defaultChannelViewReadModelTTL),
		resolveCache:      newChannelResolveReadModelCache(defaultChannelResolveReadModelTTL),
		mediaCountCache:   newMediaCountReadModelCache(defaultMediaCountReadModelTTL),
		participantCache:  newParticipantsReadModelCache(defaultParticipantsReadModelTTL),
		activeIDsCache:    newActiveChannelIDsReadModelCache(0, defaultActiveChannelIDsReadModelTTL),
		botMemberIDsCache: newActiveBotMemberIDsCache(),
	}
	for _, opt := range opts {
		opt(s)
	}
	return s
}

// WithBotProfileResolver enables group bot membership and privacy policy checks.
func WithBotProfileResolver(bots BotProfileResolver) Option {
	return func(s *Service) {
		s.bots = bots
	}
}

// WithReadModelVersions enables version-token guarded channel full-view caching.
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
	return func(s *Service) {
		s.versions = v
	}
}

// WithActiveChannelIDsReadModel installs the production shared readiness page
// cache and its bounded authoritative cold loader. Supplying the shared cache
// without either durable versions or a loader is a configuration error at read
// time; the service never silently falls back to per-session PostgreSQL reads.
func WithActiveChannelIDsReadModel(
	shared store.ActiveChannelIDsPageCache,
	loader store.ActiveChannelIDsPageLoader,
	maxEntries int,
	ttl time.Duration,
	metrics ActiveChannelIDsReadModelMetrics,
) Option {
	return func(s *Service) {
		s.activeIDsShared = shared
		s.activeIDsLoader = loader
		s.activeIDsMetrics = metrics
		s.activeIDsCache = newActiveChannelIDsReadModelCache(maxEntries, ttl)
	}
}

func WithSendPermissionChecker(c SendPermissionChecker) Option {
	return func(s *Service) {
		s.sendGate = c
	}
}

// CreateMegagroupFromCreateChat handles messages.createChat by directly creating a megagroup.
func (s *Service) CreateMegagroupFromCreateChat(ctx context.Context, userID int64, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) {
	req.CreatorUserID = userID
	req.Broadcast = false
	req.Megagroup = true
	return s.CreateChannel(ctx, userID, req)
}

// CreateChannel creates a broadcast channel or megagroup.
func (s *Service) CreateChannel(ctx context.Context, userID int64, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.CreateChannelResult{}, domain.ErrChannelInvalid
	}
	if req.CreatorUserID == 0 {
		req.CreatorUserID = userID
	}
	if req.CreatorUserID != userID {
		return domain.CreateChannelResult{}, domain.ErrChannelInvalid
	}
	if strings.TrimSpace(req.Title) == "" {
		return domain.CreateChannelResult{}, domain.ErrChannelTitleInvalid
	}
	if len(req.MemberUserIDs) > domain.MaxChannelInviteUsers {
		return domain.CreateChannelResult{}, domain.ErrChannelInvalid
	}
	if err := s.rejectBlockedBotInvites(ctx, req.MemberUserIDs); err != nil {
		return domain.CreateChannelResult{}, err
	}
	if !req.Broadcast && !req.Megagroup {
		req.Broadcast = true
	}
	res, err := s.channels.CreateChannel(ctx, req)
	if err != nil {
		return res, err
	}
	s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(userID, res.Members)...)
	// Official semantics: creation immediately generates the creator's permanent primary
	// link (DrKLO calls getExportedChatInvites right after creating a channel and takes
	// invites[0]). A failure does not block creation — the ListExportedInvites first page
	// self-heals and fills it in as a fallback.
	if res.Channel.ID != 0 {
		_, _ = s.channels.EnsurePermanentInvite(ctx, res.Channel.ID, userID, req.Date)
	}
	return res, nil
}

// GetChannel returns channel data personalized for userID.
func (s *Service) GetChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.ChannelView{}, domain.ErrChannelInvalid
	}
	return s.channels.GetChannel(ctx, userID, channelID)
}

type linkedDiscussionChannelStore interface {
	GetLinkedDiscussionChannel(ctx context.Context, viewerUserID, sourceChannelID int64) (domain.ChannelView, error)
}

type discussionReadTargetStore interface {
	ResolveDiscussionReadTarget(ctx context.Context, userID, sourceChannelID int64, sourceMessageID, readMaxID int) (domain.ChannelDiscussionReadTarget, error)
}

// GetLinkedDiscussionChannel exposes the linked discussion peer to members of
// its source broadcast without broadening ordinary private-group visibility.
func (s *Service) GetLinkedDiscussionChannel(ctx context.Context, userID, sourceChannelID int64) (domain.ChannelView, error) {
	if s == nil || s.channels == nil || userID == 0 || sourceChannelID == 0 {
		return domain.ChannelView{}, domain.ErrChannelInvalid
	}
	provider, ok := s.channels.(linkedDiscussionChannelStore)
	if !ok {
		return domain.ChannelView{}, domain.ErrChannelInvalid
	}
	return provider.GetLinkedDiscussionChannel(ctx, userID, sourceChannelID)
}

// ResolveDiscussionReadTarget returns only the linked target/root/read boundary
// required by messages.readDiscussion, avoiding the full discussion payload.
func (s *Service) ResolveDiscussionReadTarget(ctx context.Context, userID, sourceChannelID int64, sourceMessageID, readMaxID int) (domain.ChannelDiscussionReadTarget, error) {
	if s == nil || s.channels == nil || userID == 0 || sourceChannelID == 0 || sourceMessageID <= 0 || readMaxID < 0 {
		return domain.ChannelDiscussionReadTarget{}, domain.ErrChannelInvalid
	}
	provider, ok := s.channels.(discussionReadTargetStore)
	if !ok {
		return domain.ChannelDiscussionReadTarget{}, domain.ErrChannelInvalid
	}
	return provider.ResolveDiscussionReadTarget(ctx, userID, sourceChannelID, sourceMessageID, readMaxID)
}

// GetChannelReadModel returns the full channel view through a version-token guarded
// read model cache. It is intended for read-only RPC projection paths, not write
// permission checks.
func (s *Service) GetChannelReadModel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.ChannelView{}, domain.ErrChannelInvalid
	}
	return s.cachedChannelView(ctx, userID, channelID)
}

// ResolveChannel is a lightweight version of GetChannel (access check + Channel/Self only,
// skipping the dialog/boost queries), for peer resolution paths that need only the
// access_hash / channel flags. Access semantics match GetChannel.
func (s *Service) ResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.ChannelView{}, domain.ErrChannelInvalid
	}
	return s.cachedResolveChannel(ctx, userID, channelID)
}

// SearchChannelMedia returns messages in a channel belonging to the given media category (the shared-media tab).
func (s *Service) SearchChannelMedia(ctx context.Context, userID, channelID int64, req domain.MediaSearchRequest) (domain.ChannelHistory, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	return s.channels.SearchChannelMedia(ctx, userID, channelID, req)
}

// CountChannelMediaCategories returns exact counts of messages visible to the current viewer in a channel, aggregated by base media category.
func (s *Service) CountChannelMediaCategories(ctx context.Context, userID, channelID int64) (domain.MediaCategoryCounts, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.MediaCategoryCounts{}, domain.ErrChannelInvalid
	}
	return s.cachedChannelMediaCounts(ctx, userID, channelID)
}

// GetStats returns bounded aggregates derived from durable channel facts.
func (s *Service) GetStats(ctx context.Context, userID int64, req domain.ChannelStatsRequest) (domain.ChannelStats, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || !req.Period.Valid() {
		return domain.ChannelStats{}, domain.ErrChannelInvalid
	}
	provider, ok := s.channels.(channelStatsStore)
	if !ok {
		return domain.ChannelStats{}, domain.ErrChannelInvalid
	}
	req.ViewerUserID = userID
	return provider.GetChannelStats(ctx, req)
}

// GetMessageStats returns view/reaction event buckets for one exact post.
func (s *Service) GetMessageStats(ctx context.Context, userID int64, req domain.ChannelMessageStatsRequest) (domain.ChannelMessageStats, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
		req.MessageID > domain.MaxMessageBoxID || !req.Period.Valid() {
		return domain.ChannelMessageStats{}, domain.ErrMessageIDInvalid
	}
	provider, ok := s.channels.(channelStatsStore)
	if !ok {
		return domain.ChannelMessageStats{}, domain.ErrChannelInvalid
	}
	req.ViewerUserID = userID
	return provider.GetChannelMessageStats(ctx, req)
}

// ListMessagePublicForwards returns only public destination posts with a
// validated seek cursor; private forwards never cross this boundary.
func (s *Service) ListMessagePublicForwards(ctx context.Context, userID int64, req domain.ChannelMessagePublicForwardListRequest) (domain.ChannelMessagePublicForwardList, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
		req.MessageID > domain.MaxMessageBoxID || req.Limit <= 0 || req.Limit > domain.MaxChannelMessagePublicForwards {
		return domain.ChannelMessagePublicForwardList{}, domain.ErrChannelInvalid
	}
	if _, err := domain.ParseChannelMessagePublicForwardCursor(req.Offset); err != nil {
		return domain.ChannelMessagePublicForwardList{}, err
	}
	provider, ok := s.channels.(channelStatsStore)
	if !ok {
		return domain.ChannelMessagePublicForwardList{}, domain.ErrChannelInvalid
	}
	req.ViewerUserID = userID
	return provider.ListChannelMessagePublicForwards(ctx, req)
}

// GetChannels returns channel data personalized for userID, ordered by the first occurrence in channelIDs.
func (s *Service) GetChannels(ctx context.Context, userID int64, channelIDs []int64) ([]domain.ChannelView, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return nil, domain.ErrChannelInvalid
	}
	ids := uniqueNonZero(channelIDs)
	if len(ids) == 0 {
		return nil, nil
	}
	if s.versions != nil {
		out := make([]domain.ChannelView, 0, len(ids))
		for _, id := range ids {
			view, err := s.GetChannelReadModel(ctx, userID, id)
			if err != nil {
				if errors.Is(err, domain.ErrChannelPrivate) || errors.Is(err, domain.ErrChannelInvalid) {
					continue
				}
				return s.channels.GetChannels(ctx, userID, ids)
			}
			out = append(out, view)
		}
		return out, nil
	}
	return s.channels.GetChannels(ctx, userID, ids)
}

// GetChannelsAuthoritative bypasses the app-level versioned read model for a
// durable channel_state refresh. PostgreSQL GetChannels is a bounded direct
// projection query, so the returned flag snapshot cannot be the pre-commit
// value that the event is intended to invalidate.
func (s *Service) GetChannelsAuthoritative(ctx context.Context, userID int64, channelIDs []int64) ([]domain.ChannelView, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return nil, domain.ErrChannelInvalid
	}
	ids := uniqueNonZero(channelIDs)
	if len(ids) == 0 {
		return nil, nil
	}
	return s.channels.GetChannels(ctx, userID, ids)
}

// GetJoinableChannel returns a channel shell so RPC can verify access hash before join.
func (s *Service) GetJoinableChannel(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.GetChannelByID(ctx, channelID)
}

// GetChannelByID returns the non-personalized channel base row for internal admin use.
func (s *Service) GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error) {
	if s == nil || s.channels == nil || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.GetChannelByID(ctx, channelID)
}

// GetParticipants returns a bounded participants page.
func (s *Service) GetParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.ChannelParticipantList{}, domain.ErrChannelInvalid
	}
	if utf8.RuneCountInString(filter.Query) > domain.MaxChannelParticipantsQueryLength {
		return domain.ChannelParticipantList{}, domain.ErrChannelInvalid
	}
	return s.cachedParticipants(ctx, userID, channelID, filter, offset, limit)
}

// GetParticipant returns one participant.
func (s *Service) GetParticipant(ctx context.Context, userID, channelID, participantUserID int64) (domain.ChannelMember, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 || participantUserID == 0 {
		return domain.ChannelMember{}, domain.ErrChannelInvalid
	}
	return s.channels.GetParticipant(ctx, userID, channelID, participantUserID)
}

// FutureCreatorAfterLeave returns the member that will become creator if userID leaves.
func (s *Service) FutureCreatorAfterLeave(ctx context.Context, userID, channelID int64) (domain.ChannelMember, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.ChannelMember{}, domain.ErrChannelInvalid
	}
	return s.channels.FutureCreatorAfterLeave(ctx, channelID, userID)
}

// InviteToChannel invites users to a channel/supergroup.
func (s *Service) InviteToChannel(ctx context.Context, userID, channelID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 || len(userIDs) == 0 {
		return domain.CreateChannelResult{}, domain.ErrChannelInvalid
	}
	if len(userIDs) > domain.MaxChannelInviteUsers {
		return domain.CreateChannelResult{}, domain.ErrChannelInvalid
	}
	if err := s.rejectBlockedBotInvites(ctx, userIDs); err != nil {
		return domain.CreateChannelResult{}, err
	}
	res, err := s.channels.InviteToChannel(ctx, channelID, userID, userIDs, date)
	if err == nil {
		s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(0, res.Members)...)
		s.participantCache.invalidateChannel(channelID)
		s.invalidateActiveBotMemberIDs(channelID)
	}
	return res, err
}

// JoinChannel joins current user to a channel/supergroup.
func (s *Service) JoinChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.CreateChannelResult{}, domain.ErrChannelInvalid
	}
	res, err := s.channels.JoinChannel(ctx, channelID, userID, date)
	if err == nil {
		s.invalidateActiveChannelIDs(userID)
		s.participantCache.invalidateChannel(channelID)
		s.invalidateActiveBotMemberIDs(channelID)
	}
	return res, err
}

// LeaveChannel leaves current user from a channel/supergroup.
func (s *Service) LeaveChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.CreateChannelResult{}, domain.ErrChannelInvalid
	}
	res, err := s.channels.LeaveChannel(ctx, channelID, userID, date)
	if err == nil {
		s.invalidateActiveChannelIDs(userID)
		s.participantCache.invalidateChannel(channelID)
		s.invalidateActiveBotMemberIDs(channelID)
	}
	return res, err
}

// EditTitle edits a channel/supergroup title.
func (s *Service) EditTitle(ctx context.Context, userID int64, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.EditChannelTitleResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || strings.TrimSpace(req.Title) == "" {
		return domain.EditChannelTitleResult{}, domain.ErrChannelTitleInvalid
	}
	return s.channels.EditChannelTitle(ctx, req)
}

// SetWallpaper sets or clears the channel/supergroup wallpaper.
func (s *Service) SetWallpaper(ctx context.Context, userID int64, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.SetChannelWallpaperResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 {
		return domain.SetChannelWallpaperResult{}, domain.ErrChannelInvalid
	}
	return s.channels.SetChannelWallpaper(ctx, req)
}

// EditAbout edits a channel/supergroup description.
func (s *Service) EditAbout(ctx context.Context, userID int64, req domain.EditChannelAboutRequest) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.EditChannelAbout(ctx, req)
}

// EditAdmin edits a participant's admin rights.
func (s *Service) EditAdmin(ctx context.Context, userID int64, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || req.MemberID == 0 || len(req.Rank) > domain.MaxChannelAdminRankLength {
		return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
	}
	res, err := s.channels.EditChannelAdmin(ctx, req)
	if err == nil {
		s.invalidateActiveChannelIDs(req.MemberID)
		s.participantCache.invalidateChannel(req.ChannelID)
		s.invalidateActiveBotMemberIDs(req.ChannelID)
	}
	return res, err
}

// TransferOwnership transfers a channel/supergroup to another active member.
func (s *Service) TransferOwnership(ctx context.Context, userID int64, req domain.TransferChannelOwnershipRequest) (domain.TransferChannelOwnershipResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.TransferChannelOwnershipResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || req.NewOwnerID == 0 || req.NewOwnerID == userID {
		return domain.TransferChannelOwnershipResult{}, domain.ErrChannelInvalid
	}
	res, err := s.channels.TransferChannelOwnership(ctx, req)
	if err == nil {
		s.invalidateActiveChannelIDs(req.UserID, req.NewOwnerID)
		s.participantCache.invalidateChannel(req.ChannelID)
		s.invalidateActiveBotMemberIDs(req.ChannelID)
	}
	return res, err
}

// EditMemberRank sets or clears a participant's member tag without touching
// their role or admin rights.
func (s *Service) EditMemberRank(ctx context.Context, userID int64, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || req.MemberID == 0 || len(req.Rank) > domain.MaxChannelAdminRankLength {
		return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid
	}
	res, err := s.channels.EditChannelMemberRank(ctx, req)
	if err == nil {
		s.participantCache.invalidateChannel(req.ChannelID)
		s.invalidateActiveBotMemberIDs(req.ChannelID)
	}
	return res, err
}

// EditBanned edits a participant's banned rights.
func (s *Service) EditBanned(ctx context.Context, userID int64, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || req.Participant.Type != domain.PeerTypeUser || req.Participant.ID == 0 {
		return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid
	}
	res, err := s.channels.EditChannelBanned(ctx, req)
	if err == nil {
		s.invalidateActiveChannelIDs(req.Participant.ID)
		s.participantCache.invalidateChannel(req.ChannelID)
		s.invalidateActiveBotMemberIDs(req.ChannelID)
	}
	return res, err
}

// EditDefaultBannedRights edits the channel/supergroup default restrictions.
func (s *Service) EditDefaultBannedRights(ctx context.Context, userID int64, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.EditChannelDefaultBannedRights(ctx, req)
}

// DeleteChannel deletes a channel/supergroup.
func (s *Service) DeleteChannel(ctx context.Context, userID int64, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.DeleteChannelResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 {
		return domain.DeleteChannelResult{}, domain.ErrChannelInvalid
	}
	res, err := s.channels.DeleteChannel(ctx, req)
	if err == nil {
		s.invalidateActiveChannelIDs(uniqueUserIDs(append([]int64{userID}, res.Recipients...)...)...)
		s.invalidateActiveBotMemberIDs(req.ChannelID)
	}
	return res, err
}

// CheckUsername checks whether a channel username is syntactically valid and free.
func (s *Service) CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error) {
	// channelID 0 checks a name for a channel that is still being created.
	if s == nil || s.channels == nil || userID == 0 || channelID < 0 {
		return false, domain.ErrChannelInvalid
	}
	username = normalizeChannelUsername(username)
	if !validChannelUsername(username) {
		return false, domain.ErrUsernameInvalid
	}
	return s.channels.CheckUsername(ctx, userID, channelID, username)
}

// UpdateUsername sets or clears a channel public username.
func (s *Service) UpdateUsername(ctx context.Context, userID int64, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	req.Username = normalizeChannelUsername(req.Username)
	if req.Username != "" && !validChannelUsername(req.Username) {
		return domain.Channel{}, domain.ErrUsernameInvalid
	}
	return s.channels.UpdateUsername(ctx, req)
}

// SetVerified sets or clears the channel/supergroup verified badge through the internal admin path.
func (s *Service) SetVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error) {
	if s == nil || s.channels == nil || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetChannelVerified(ctx, channelID, verified)
}

// SetScamFake sets or clears the channel/supergroup scam and fake flags through the internal admin path.
func (s *Service) SetScamFake(ctx context.Context, channelID int64, scam, fake bool) (domain.Channel, error) {
	if s == nil || s.channels == nil || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	if scam && fake {
		return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
	}
	return s.channels.SetChannelScamFake(ctx, channelID, scam, fake)
}

// AdminSetSettings applies a moderation-settings patch through the admin path (no permission checks).
func (s *Service) AdminSetSettings(ctx context.Context, channelID int64, patch domain.ChannelAdminSettings) (domain.Channel, error) {
	if s == nil || s.channels == nil || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetChannelAdminSettings(ctx, channelID, patch)
}

// AdminSetUsername force-sets or clears a channel username through the admin path.
func (s *Service) AdminSetUsername(ctx context.Context, channelID int64, username string) (domain.Channel, error) {
	if s == nil || s.channels == nil || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetChannelUsernameAdmin(ctx, channelID, username)
}

// AdminSetColor force-sets a channel name/profile color through the admin path.
func (s *Service) AdminSetColor(ctx context.Context, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
	if s == nil || s.channels == nil || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetChannelColorAdmin(ctx, channelID, forProfile, color)
}

// AdminSetEmojiStatus force-sets or clears a channel emoji status through the admin path.
func (s *Service) AdminSetEmojiStatus(ctx context.Context, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
	if s == nil || s.channels == nil || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetChannelEmojiStatusAdmin(ctx, channelID, status)
}

// AdminSetPhoto changes the channel base photo without fabricating a member
// action or service message. It is a non-PTS administrative metadata repair.
func (s *Service) AdminSetPhoto(ctx context.Context, channelID int64, photo domain.Photo) (domain.Channel, error) {
	if s == nil || s.channels == nil || channelID == 0 || photo.ID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetChannelPhotoAdmin(ctx, channelID, photo)
}

// ListAdminedPublicChannels returns public channels/supergroups administered by user.
func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return nil, nil
	}
	return s.channels.ListAdminedPublicChannels(ctx, userID)
}

// ListCommunityLinkableChannels returns owned/administered channels that are not
// already linked to another Community. Private megagroups are valid candidates.
func (s *Service) ListCommunityLinkableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return nil, nil
	}
	return s.channels.ListCommunityLinkableChannels(ctx, userID)
}

// ListStoryPostableChannels returns channels where user can publish stories.
func (s *Service) ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return nil, nil
	}
	return s.channels.ListStoryPostableChannels(ctx, userID)
}

// ListSendAsChannels returns the broadcast channels the user may post messages as in groups.
func (s *Service) ListSendAsChannels(ctx context.Context, userID int64) ([]domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return nil, nil
	}
	return s.channels.ListSendAsChannels(ctx, userID)
}

// ResolvePublicUsername resolves a public channel/supergroup username visible to userID.
func (s *Service) ResolvePublicUsername(ctx context.Context, userID int64, username string) (domain.Channel, bool, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.Channel{}, false, domain.ErrChannelInvalid
	}
	username = normalizeChannelUsername(username)
	// Public resolution also covers Fragment-style collectible usernames,
	// whose protocol minimum is four characters. Channel username mutation
	// remains on the ordinary 5..32 validation path above.
	if !domain.ValidCollectibleUsername(username) {
		return domain.Channel{}, false, domain.ErrUsernameInvalid
	}
	return s.channels.ResolvePublicChannelUsername(ctx, userID, username)
}

// SearchPublicChannels returns public username channels/supergroups for contacts.search.
func (s *Service) SearchPublicChannels(ctx context.Context, userID int64, query string, limit int) (domain.PublicChannelSearchResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.PublicChannelSearchResult{}, domain.ErrChannelInvalid
	}
	query = normalizeChannelUsername(query)
	if query == "" {
		return domain.PublicChannelSearchResult{}, nil
	}
	if utf8.RuneCountInString(query) > domain.MaxPublicChannelSearchQueryLength {
		return domain.PublicChannelSearchResult{}, domain.ErrChannelInvalid
	}
	return s.channels.SearchPublicChannels(ctx, userID, query, capLimit(limit, domain.MaxPublicChannelSearchLimit))
}

// SetSignatures toggles channel post signatures.
func (s *Service) SetSignatures(ctx context.Context, userID, channelID int64, enabled, profiles bool) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetSignatures(ctx, userID, channelID, enabled, profiles)
}

// SetPhoto sets/clears the channel photo (photo==nil means clear).
func (s *Service) SetPhoto(ctx context.Context, userID, channelID int64, photo *domain.Photo, date int) (domain.SetChannelPhotoResult, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.SetChannelPhotoResult{}, domain.ErrChannelInvalid
	}
	return s.channels.SetChannelPhoto(ctx, userID, channelID, photo, date)
}

// SetPreHistoryHidden toggles hidden history for new supergroup members.
func (s *Service) SetPreHistoryHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetPreHistoryHidden(ctx, userID, channelID, enabled)
}

// SetParticipantsHidden toggles whether non-admins can view the full member list.
func (s *Service) SetParticipantsHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetParticipantsHidden(ctx, userID, channelID, enabled)
}

// SetForum toggles forum topics and their preferred TDesktop layout for a megagroup.
func (s *Service) SetForum(ctx context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetForum(ctx, userID, channelID, enabled, tabs)
}

// SetAutotranslation toggles channel autotranslation for all users.
func (s *Service) SetAutotranslation(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetAutotranslation(ctx, userID, channelID, enabled)
}

// SetRestrictedSponsored toggles the channelFull restricted_sponsored state.
func (s *Service) SetRestrictedSponsored(ctx context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetRestrictedSponsored(ctx, userID, channelID, restricted)
}

// SetPaidMessagesPrice stores the currently advertised paid-message price state.
func (s *Service) SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.ChannelPaidMessagesPriceResult, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 || stars < 0 {
		return domain.ChannelPaidMessagesPriceResult{}, domain.ErrChannelInvalid
	}
	return s.channels.SetPaidMessagesPrice(ctx, userID, channelID, stars, broadcastMessagesAllowed)
}

// SetAntiSpam toggles native antispam mode for a supergroup.
func (s *Service) SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetAntiSpam(ctx, userID, channelID, enabled)
}

// SetSlowMode changes the supergroup slow mode interval in seconds.
func (s *Service) SetSlowMode(ctx context.Context, userID, channelID int64, seconds int) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 || !domain.ValidChannelSlowModeSeconds(seconds) {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetSlowMode(ctx, userID, channelID, seconds)
}

// SetBoostsToUnblockRestrictions stores the boost threshold that lets boosted
// members bypass default send-message restrictions.
func (s *Service) SetBoostsToUnblockRestrictions(ctx context.Context, userID, channelID int64, boosts int) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 || boosts < 0 || boosts > domain.MaxChannelBoostsToUnblockRestrictions {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetBoostsToUnblockRestrictions(ctx, userID, channelID, boosts)
}

// SetNoForwards toggles channel/supergroup content protection.
func (s *Service) SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetNoForwards(ctx, userID, channelID, enabled)
}

// SetHistoryTTL updates channel/supergroup message auto-delete period.
func (s *Service) SetHistoryTTL(ctx context.Context, userID, channelID int64, period int, date int) (domain.Channel, []int64, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 || period < 0 {
		return domain.Channel{}, nil, domain.ErrChannelInvalid
	}
	ttl, ok := s.channels.(store.ChannelHistoryTTLStore)
	if !ok {
		return domain.Channel{}, nil, domain.ErrChannelInvalid
	}
	return ttl.SetChannelHistoryTTL(ctx, userID, channelID, period, date)
}

// ClaimExpiredMessages returns expired channel delete batches for the TTL worker.
func (s *Service) ClaimExpiredMessages(ctx context.Context, now, limit int) ([]domain.DeleteChannelMessagesRequest, error) {
	if s == nil || s.channels == nil {
		return nil, nil
	}
	ttl, ok := s.channels.(store.ChannelHistoryTTLStore)
	if !ok {
		return nil, nil
	}
	return ttl.ClaimExpiredChannelMessages(ctx, now, limit)
}

// SetJoinToSend toggles whether non-members must join before sending in a megagroup.
func (s *Service) SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetJoinToSend(ctx, userID, channelID, enabled)
}

// SetJoinRequest toggles public join request approval for a megagroup.
func (s *Service) SetJoinRequest(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetJoinRequest(ctx, userID, channelID, enabled)
}

// SetAvailableReactions stores the bounded reaction policy for a channel/supergroup.
func (s *Service) SetAvailableReactions(ctx context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	if len(policy.Emoticons)+len(policy.CustomEmojiIDs) > domain.MaxChannelReactionTypes ||
		policy.Limit < 0 ||
		policy.Limit > domain.MaxChannelReactionsLimit {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	for _, emoticon := range policy.Emoticons {
		if strings.TrimSpace(emoticon) == "" || utf8.RuneCountInString(emoticon) > domain.MaxChannelReactionEmoticonLength {
			return domain.Channel{}, domain.ErrChannelInvalid
		}
	}
	for _, documentID := range policy.CustomEmojiIDs {
		if documentID <= 0 {
			return domain.Channel{}, domain.ErrChannelInvalid
		}
	}
	return s.channels.SetAvailableReactions(ctx, userID, channelID, policy)
}

// SetColor stores the channel message/profile accent color.
func (s *Service) SetColor(ctx context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetColor(ctx, userID, channelID, forProfile, color)
}

// SetEmojiStatus stores or clears the channel emoji status.
func (s *Service) SetEmojiStatus(ctx context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	if status.DocumentID < 0 || status.Until < 0 {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	return s.channels.SetEmojiStatus(ctx, userID, channelID, status)
}

func (s *Service) GetPremiumBoostStatus(ctx context.Context, userID, channelID int64, now int) (domain.PremiumBoostStatus, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 || now < 0 {
		return domain.PremiumBoostStatus{}, domain.ErrChannelInvalid
	}
	return s.channels.GetPremiumBoostStatus(ctx, userID, channelID, now)
}

func (s *Service) ListPremiumBoosts(ctx context.Context, userID, channelID int64, gifts bool, offset string, limit, now int) (domain.PremiumBoostList, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 || now < 0 || len(offset) > domain.MaxPremiumBoostsOffsetBytes {
		return domain.PremiumBoostList{}, domain.ErrChannelInvalid
	}
	if limit <= 0 {
		limit = domain.MaxPremiumBoostsListLimit
	}
	if limit > domain.MaxPremiumBoostsListLimit {
		limit = domain.MaxPremiumBoostsListLimit
	}
	return s.channels.ListPremiumBoosts(ctx, userID, channelID, gifts, offset, limit, now)
}

func (s *Service) GetPremiumMyBoosts(ctx context.Context, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error) {
	if s == nil || s.channels == nil || userID == 0 || now < 0 || premiumUntil < 0 {
		return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid
	}
	return s.channels.GetPremiumMyBoosts(ctx, userID, now, premiumUntil)
}

func (s *Service) ApplyPremiumBoost(ctx context.Context, userID, channelID int64, slots []int, now, premiumUntil int) (domain.PremiumMyBoosts, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 || len(slots) == 0 || len(slots) > domain.MaxPremiumBoostSlotsPerApply || now < 0 || premiumUntil < 0 {
		return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid
	}
	for _, slot := range slots {
		if slot <= 0 {
			return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid
		}
	}
	if premiumUntil <= now {
		return domain.PremiumMyBoosts{}, domain.ErrPremiumRequired
	}
	return s.channels.ApplyPremiumBoost(ctx, userID, channelID, slots, now, premiumUntil)
}

func (s *Service) GetPremiumUserBoosts(ctx context.Context, userID, channelID, targetUserID int64, now int) (domain.PremiumBoostList, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 || targetUserID == 0 || now < 0 {
		return domain.PremiumBoostList{}, domain.ErrChannelInvalid
	}
	return s.channels.GetPremiumUserBoosts(ctx, userID, channelID, targetUserID, now)
}

// ListAdminLog returns one bounded, channel-scoped admin log page.
func (s *Service) ListAdminLog(ctx context.Context, userID int64, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.MinID < 0 {
		req.MinID = 0
	}
	if req.UserID != userID || req.ChannelID == 0 || req.MaxID < 0 || req.MinID < 0 {
		return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid
	}
	if utf8.RuneCountInString(req.Query) > domain.MaxChannelAdminLogQueryLength || len(req.AdminUserIDs) > domain.MaxChannelAdminLogAdmins {
		return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid
	}
	req.Limit = capLimit(req.Limit, domain.MaxChannelAdminLogLimit)
	return s.channels.ListAdminLog(ctx, req)
}

// GetChannelForChangeInfo validates the current user can change channel metadata.
func (s *Service) GetChannelForChangeInfo(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) {
	view, err := s.GetChannel(ctx, userID, channelID)
	if err != nil {
		return domain.ChannelView{}, err
	}
	if view.Self.Role == domain.ChannelRoleCreator || (view.Self.Role == domain.ChannelRoleAdmin && view.Self.AdminRights.ChangeInfo) {
		return view, nil
	}
	return domain.ChannelView{}, domain.ErrChannelAdminRequired
}

// CanPostStory validates whether the current user can publish a channel story.
func (s *Service) CanPostStory(ctx context.Context, userID, channelID int64) error {
	return s.canManageStory(ctx, userID, channelID, func(rights domain.ChannelAdminRights) bool {
		return rights.PostStories
	})
}

// CanEditStory validates whether the current user can edit a channel story.
func (s *Service) CanEditStory(ctx context.Context, userID, channelID int64) error {
	return s.canManageStory(ctx, userID, channelID, func(rights domain.ChannelAdminRights) bool {
		return rights.EditStories
	})
}

// CanDeleteStory validates whether the current user can delete a channel story.
func (s *Service) CanDeleteStory(ctx context.Context, userID, channelID int64) error {
	return s.canManageStory(ctx, userID, channelID, func(rights domain.ChannelAdminRights) bool {
		return rights.DeleteStories
	})
}

// CanPinStory validates whether the current user can change channel story pin state.
func (s *Service) CanPinStory(ctx context.Context, userID, channelID int64) error {
	return s.CanEditStory(ctx, userID, channelID)
}

func (s *Service) canManageStory(ctx context.Context, userID, channelID int64, allowed func(domain.ChannelAdminRights) bool) error {
	view, err := s.GetChannel(ctx, userID, channelID)
	if err != nil {
		return err
	}
	if view.Self.Role == domain.ChannelRoleCreator {
		return nil
	}
	if view.Self.Role == domain.ChannelRoleAdmin && allowed(view.Self.AdminRights) {
		return nil
	}
	return domain.ErrChannelAdminRequired
}

// SaveDefaultSendAs persists the current user's default send-as peer for one channel/supergroup dialog.
func (s *Service) SaveDefaultSendAs(ctx context.Context, userID int64, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ChannelView{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 {
		return domain.ChannelView{}, domain.ErrChannelInvalid
	}
	return s.channels.SaveChannelDefaultSendAs(ctx, req)
}

// GetMessageViews returns channel-scoped view counters and optionally records first-time views.
func (s *Service) GetMessageViews(ctx context.Context, userID int64, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 {
		return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid
	}
	if len(req.IDs) > domain.MaxGetMessageIDs {
		return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid
	}
	for _, id := range req.IDs {
		if id <= 0 || id > domain.MaxMessageBoxID {
			return domain.ChannelMessageViewsResult{}, domain.ErrMessageIDInvalid
		}
	}
	return s.channels.GetChannelMessageViews(ctx, req)
}

// SetMessageReactions replaces the current user's reactions for one channel/supergroup message.
func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
		return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID || len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser {
		return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid
	}
	return s.channels.SetChannelMessageReactions(ctx, req)
}

// SendPaidReaction adds paid reaction stars to a broadcast channel message. The idempotent
// command, payer debit, channel credit, reaction accumulation and receipt are completed by
// the store in a single transaction.
func (s *Service) SendPaidReaction(ctx context.Context, userID int64, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
		return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID || req.Stars <= 0 || req.Stars > domain.MaxPaidReactionStarsPerRequest || req.RandomID == 0 {
		return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid
	}
	return s.channels.AddChannelMessagePaidReaction(ctx, req)
}

type paidReactionReplayStore interface {
	ReplayChannelMessagePaidReaction(ctx context.Context, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, bool, error)
}

// ReplayPaidReaction reads a completed immutable receipt before mutable RPC
// access/send-as checks. Production and the explicit memory fake implement it;
// adapters without durable command receipts simply report a miss.
func (s *Service) ReplayPaidReaction(ctx context.Context, userID int64, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, bool, error) {
	if s == nil || s.channels == nil || userID == 0 || req.RandomID == 0 {
		return domain.ChannelMessagePaidReactionResult{}, false, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID {
		return domain.ChannelMessagePaidReactionResult{}, false, domain.ErrChannelInvalid
	}
	replayer, ok := s.channels.(paidReactionReplayStore)
	if !ok {
		return domain.ChannelMessagePaidReactionResult{}, false, nil
	}
	return replayer.ReplayChannelMessagePaidReaction(ctx, req)
}

// VoteMessagePoll votes in a poll on a channel/supergroup message (empty options = retract vote).
func (s *Service) VoteMessagePoll(ctx context.Context, userID int64, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
		return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID {
		return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid
	}
	return s.channels.VoteChannelMessagePoll(ctx, req)
}

// CloseMessagePoll closes a poll on a channel/supergroup message (poll creator only).
func (s *Service) CloseMessagePoll(ctx context.Context, userID int64, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
		return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID {
		return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid
	}
	return s.channels.CloseChannelMessagePoll(ctx, req)
}

// GetMessageReactions returns reaction summaries for exact channel/supergroup message ids.
func (s *Service) GetMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 {
		return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || len(req.IDs) > domain.MaxGetMessageIDs {
		return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid
	}
	for _, id := range req.IDs {
		if id <= 0 || id > domain.MaxMessageBoxID {
			return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid
		}
	}
	return s.channels.GetChannelMessageReactions(ctx, req)
}

// ListMessageReactions returns a bounded per-peer reaction list for one message.
func (s *Service) ListMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
		return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID {
		return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid
	}
	req.Limit = capLimit(req.Limit, domain.MaxChannelMessageReactionListLimit)
	return s.channels.ListChannelMessageReactions(ctx, req)
}

type messageReactionLookupStore interface {
	FindChannelMessageReaction(ctx context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error)
}

func (s *Service) FindMessageReaction(ctx context.Context, userID int64, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 ||
		req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID ||
		req.ReactorUserID == 0 {
		return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
	}
	if req.ViewerUserID == 0 {
		req.ViewerUserID = userID
	}
	if req.ViewerUserID != userID {
		return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
	}
	lookup, ok := s.channels.(messageReactionLookupStore)
	if !ok {
		return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
	}
	return lookup.FindChannelMessageReaction(ctx, req)
}

type messageReactionUsageStore interface {
	RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error
}

type participantReactionModeratorStore interface {
	DeleteChannelParticipantReaction(ctx context.Context, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error)
	DeleteChannelParticipantReactions(ctx context.Context, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error)
}

// RecordMessageReactionUse updates account-level top/recent reaction lists.
func (s *Service) RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error {
	if s == nil || s.channels == nil || userID == 0 || len(reactions) == 0 {
		return nil
	}
	recorder, ok := s.channels.(messageReactionUsageStore)
	if !ok {
		return nil
	}
	return recorder.RecordMessageReactionUse(ctx, userID, reactions, addToRecent, date)
}

// DeleteParticipantReaction removes one participant reaction on a channel message.
func (s *Service) DeleteParticipantReaction(ctx context.Context, userID int64, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.ParticipantUserID == 0 {
		return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID {
		return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid
	}
	moderator, ok := s.channels.(participantReactionModeratorStore)
	if !ok {
		return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid
	}
	return moderator.DeleteChannelParticipantReaction(ctx, req)
}

// DeleteParticipantReactions removes a bounded page of one participant's reactions.
func (s *Service) DeleteParticipantReactions(ctx context.Context, userID int64, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 {
		return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID {
		return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid
	}
	if req.Limit <= 0 || req.Limit > domain.MaxDeleteParticipantReactionsBatch {
		req.Limit = domain.MaxDeleteParticipantReactionsBatch
	}
	moderator, ok := s.channels.(participantReactionModeratorStore)
	if !ok {
		return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid
	}
	return moderator.DeleteChannelParticipantReactions(ctx, req)
}

// TopReactions returns the current account's most frequently used message reactions.
func (s *Service) TopReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return nil, domain.ErrChannelInvalid
	}
	if limit <= 0 {
		return []domain.MessageReaction{}, nil
	}
	if limit > domain.MaxTopMessageReactions {
		limit = domain.MaxTopMessageReactions
	}
	return s.channels.ListTopMessageReactions(ctx, userID, limit)
}

// RecentReactions returns the current account's recently used message reactions.
func (s *Service) RecentReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return nil, domain.ErrChannelInvalid
	}
	if limit <= 0 {
		return []domain.MessageReaction{}, nil
	}
	if limit > domain.MaxRecentMessageReactions {
		limit = domain.MaxRecentMessageReactions
	}
	return s.channels.ListRecentMessageReactions(ctx, userID, limit)
}

// ClearRecentReactions clears the current account's recently used message reactions.
func (s *Service) ClearRecentReactions(ctx context.Context, userID int64) error {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ErrChannelInvalid
	}
	return s.channels.ClearRecentMessageReactions(ctx, userID)
}

// ReadMessageContents returns visible channel messages whose content-read state can be synced.
func (s *Service) ReadMessageContents(ctx context.Context, userID int64, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 {
		return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid
	}
	if len(req.IDs) > domain.MaxGetMessageIDs {
		return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid
	}
	for _, id := range req.IDs {
		if id <= 0 || id > domain.MaxMessageBoxID {
			return domain.ReadChannelMessageContentsResult{}, domain.ErrMessageIDInvalid
		}
	}
	return s.channels.ReadChannelMessageContents(ctx, req)
}

// GetMessageAuthor resolves the user author for one visible channel/supergroup message.
func (s *Service) GetMessageAuthor(ctx context.Context, userID int64, req domain.GetChannelMessageAuthorRequest) (domain.GetChannelMessageAuthorResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.GetChannelMessageAuthorResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 {
		return domain.GetChannelMessageAuthorResult{}, domain.ErrChannelInvalid
	}
	if req.ID <= 0 || req.ID > domain.MaxMessageBoxID {
		return domain.GetChannelMessageAuthorResult{}, domain.ErrMessageIDInvalid
	}
	history, err := s.channels.GetChannelMessages(ctx, userID, req.ChannelID, []int{req.ID})
	if err != nil {
		return domain.GetChannelMessageAuthorResult{}, err
	}
	if len(history.Messages) != 1 || history.Messages[0].SenderUserID == 0 {
		return domain.GetChannelMessageAuthorResult{}, domain.ErrMessageIDInvalid
	}
	return domain.GetChannelMessageAuthorResult{
		Channel:      history.Channel,
		MessageID:    history.Messages[0].ID,
		SenderUserID: history.Messages[0].SenderUserID,
	}, nil
}

// CreateForumTopic creates one forum topic root service message.
func (s *Service) CreateForumTopic(ctx context.Context, userID int64, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || (strings.TrimSpace(req.Title) == "" && !req.TitleMissing) {
		return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid
	}
	if utf8.RuneCountInString(req.Title) > domain.MaxChannelForumTopicTitleLength {
		return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid
	}
	return s.channels.CreateForumTopic(ctx, req)
}

// EditForumTopic edits topic metadata and emits a topic edit service message.
func (s *Service) EditForumTopic(ctx context.Context, userID int64, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || req.TopicID <= 0 || req.TopicID > domain.MaxMessageBoxID {
		return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid
	}
	if req.Title != nil {
		title := strings.TrimSpace(*req.Title)
		if title == "" || utf8.RuneCountInString(title) > domain.MaxChannelForumTopicTitleLength {
			return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid
		}
		*req.Title = title
	}
	return s.channels.EditForumTopic(ctx, req)
}

// UpdatePinnedForumTopic pins or unpins one forum topic.
func (s *Service) UpdatePinnedForumTopic(ctx context.Context, userID int64, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || req.TopicID <= 0 || req.TopicID > domain.MaxMessageBoxID {
		return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelInvalid
	}
	return s.channels.UpdatePinnedForumTopic(ctx, req)
}

// ReorderPinnedForumTopics stores a bounded pinned topic order.
func (s *Service) ReorderPinnedForumTopics(ctx context.Context, userID int64, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || len(req.Order) > domain.MaxChannelForumTopicIDs {
		return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelInvalid
	}
	for _, id := range req.Order {
		if id <= 0 || id > domain.MaxMessageBoxID {
			return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid
		}
	}
	return s.channels.ReorderPinnedForumTopics(ctx, req)
}

// DeleteForumTopicHistory deletes one bounded topic-history page.
func (s *Service) DeleteForumTopicHistory(ctx context.Context, userID int64, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || req.TopicID <= 0 || req.TopicID > domain.MaxMessageBoxID {
		return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid
	}
	return s.channels.DeleteForumTopicHistory(ctx, req)
}

// GetForumTopics returns a bounded topic page without SQL OFFSET semantics.
func (s *Service) GetForumTopics(ctx context.Context, userID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) {
	if s == nil || s.channels == nil || userID == 0 || filter.ChannelID == 0 {
		return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid
	}
	if filter.Limit < 0 || filter.Limit > domain.MaxChannelForumTopicsLimit {
		return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid
	}
	if utf8.RuneCountInString(filter.Query) > domain.MaxChannelHistoryQueryLength {
		return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid
	}
	return s.channels.ListForumTopics(ctx, userID, filter)
}

// GetForumTopicsByID returns a bounded topic id lookup.
func (s *Service) GetForumTopicsByID(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 || len(ids) > domain.MaxChannelForumTopicIDs {
		return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid
	}
	for _, id := range ids {
		if id <= 0 || id > domain.MaxMessageBoxID {
			return domain.ChannelForumTopicList{}, domain.ErrMessageIDInvalid
		}
	}
	return s.channels.GetForumTopicsByID(ctx, userID, channelID, ids)
}

// SendMessage sends one channel/supergroup message.
func (s *Service) SendMessage(ctx context.Context, userID int64, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID {
		return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
	}
	if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
		return domain.SendChannelMessageResult{}, err
	}
	if req.RandomID != 0 && !req.IdempotencyPreflighted {
		fingerprint, err := store.ChannelSendFingerprint(req)
		if err != nil {
			return domain.SendChannelMessageResult{}, err
		}
		req.IdempotencyFingerprint = fingerprint
		if replayStore, ok := s.channels.(store.ChannelSendReplayStore); ok {
			replay, found, err := replayStore.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
				ChannelID:              req.ChannelID,
				SenderUserID:           req.UserID,
				RandomID:               req.RandomID,
				IdempotencyFingerprint: fingerprint,
			})
			if err != nil || found {
				return replay, err
			}
			req.IdempotencyPreflighted = true
		}
	}
	if err := s.ensureCanSend(ctx, req.UserID); err != nil {
		return domain.SendChannelMessageResult{}, err
	}
	skipped, err := s.skippedBotDeliveryUserIDs(ctx, req)
	if err != nil {
		return domain.SendChannelMessageResult{}, err
	}
	req.SkipDeliveryUserIDs = mergeSkippedUserIDs(req.SkipDeliveryUserIDs, skipped)
	return s.channels.SendChannelMessage(ctx, req)
}

// LookupChannelSendReplay reads a regular-channel or monoforum receipt without current
// membership/send-gate checks. The authenticated caller remains bound to SenderUserID.
func (s *Service) LookupChannelSendReplay(ctx context.Context, userID int64, req domain.ChannelSendReplayRequest) (domain.SendChannelMessageResult, bool, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.SendChannelMessageResult{}, false, nil
	}
	if req.SenderUserID == 0 {
		req.SenderUserID = userID
	}
	if req.SenderUserID != userID || req.ChannelID == 0 || req.RandomID == 0 {
		return domain.SendChannelMessageResult{}, false, domain.ErrChannelInvalid
	}
	replayStore, ok := s.channels.(store.ChannelSendReplayStore)
	if !ok {
		return domain.SendChannelMessageResult{}, false, nil
	}
	return replayStore.LookupChannelSendReplay(ctx, req)
}

func (s *Service) ensureCanSend(ctx context.Context, userID int64) error {
	if s == nil || s.sendGate == nil || userID == 0 {
		return nil
	}
	return s.sendGate.CanSendMessages(ctx, userID)
}

// EditFactCheck writes or deletes a fact-check label on a message (Layer 231).
// Empty text means delete the label; permission is decided by the store as
// "the author or an admin who can edit the message".
func (s *Service) EditFactCheck(ctx context.Context, userID int64, req domain.EditFactCheckRequest) (domain.EditFactCheckResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.EditFactCheckResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || req.MessageID <= 0 {
		return domain.EditFactCheckResult{}, domain.ErrChannelInvalid
	}
	return s.channels.EditFactCheck(ctx, req)
}

// GetFactChecks batch-reads the fact-check labels of a set of messages (Layer 231).
// Reuse the exact-message read boundary, including public preview and cleared history.
func (s *Service) GetFactChecks(ctx context.Context, userID, channelID int64, messageIDs []int) (domain.FactCheckList, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 || len(messageIDs) == 0 {
		return domain.FactCheckList{}, nil
	}
	history, err := s.GetMessages(ctx, userID, channelID, messageIDs)
	if err != nil {
		return domain.FactCheckList{}, err
	}
	return domain.FactChecksForMessages(history.Messages, messageIDs), nil
}

// EditMessage edits a channel/supergroup text message.
func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || req.ID <= 0 {
		return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
	}
	if req.SetReplyMarkup {
		if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
			return domain.EditChannelMessageResult{}, err
		}
	}
	return s.channels.EditChannelMessage(ctx, req)
}

// GetInlineBotMessage returns one live channel message addressed by a signed inline id.
func (s *Service) GetInlineBotMessage(ctx context.Context, botID, channelID int64, id int) (domain.Channel, domain.ChannelMessage, bool, error) {
	if s == nil || s.channels == nil || botID == 0 || channelID == 0 || id <= 0 || id > domain.MaxMessageBoxID {
		return domain.Channel{}, domain.ChannelMessage{}, false, domain.ErrChannelInvalid
	}
	return s.channels.GetChannelMessageForInlineBot(ctx, botID, channelID, id)
}

// EditInlineBotMessage edits a channel message through its via-bot inline id.
func (s *Service) EditInlineBotMessage(ctx context.Context, botID int64, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) {
	if s == nil || s.channels == nil || botID == 0 || req.ChannelID == 0 || req.ID <= 0 || req.UserID == 0 {
		return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
	}
	if req.SetReplyMarkup {
		if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
			return domain.EditChannelMessageResult{}, err
		}
	}
	req.ViaBotEditBotID = botID
	return s.channels.EditChannelMessage(ctx, req)
}

// DeleteMessages deletes a bounded set of channel/supergroup messages.
func (s *Service) DeleteMessages(ctx context.Context, userID int64, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || len(req.IDs) == 0 {
		return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
	}
	if len(req.IDs) > domain.MaxDeleteMessageIDs {
		return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
	}
	return s.channels.DeleteChannelMessages(ctx, req)
}

type moderationChannelMessageStore interface {
	ModerationDeleteChannelMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error)
}

// ModerationDeleteMessages is the explicit server-authority deletion path used
// only by the durable moderation action worker. It never accepts a client
// identity and therefore cannot be reached by ordinary RPC permission checks.
func (s *Service) ModerationDeleteMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error) {
	if s == nil || s.channels == nil || channelID <= 0 ||
		len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
		return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
	}
	for _, id := range ids {
		if id <= 0 || id > domain.MaxMessageBoxID {
			return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
		}
	}
	store, ok := s.channels.(moderationChannelMessageStore)
	if !ok {
		return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
	}
	return store.ModerationDeleteChannelMessages(ctx, channelID, append([]int(nil), ids...), date)
}

// DeleteHistory clears the current user's history view or deletes a bounded channel history page for everyone.
func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 {
		return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid
	}
	return s.channels.DeleteChannelHistory(ctx, req)
}

// DeleteParticipantHistory deletes one bounded page of messages sent by a channel participant.
func (s *Service) DeleteParticipantHistory(ctx context.Context, userID int64, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || req.ParticipantUserID == 0 {
		return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid
	}
	return s.channels.DeleteChannelParticipantHistory(ctx, req)
}

// UpdatePinnedMessage pins or unpins a channel/supergroup message.
func (s *Service) UpdatePinnedMessage(ctx context.Context, userID int64, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID {
		return domain.UpdateChannelPinnedMessageResult{}, domain.ErrMessageIDInvalid
	}
	return s.channels.UpdatePinnedMessage(ctx, req)
}

// UnpinAllMessages clears every pinned message in a channel/supergroup.
func (s *Service) UnpinAllMessages(ctx context.Context, userID int64, req domain.UnpinAllChannelMessagesRequest) (domain.UpdateChannelPinnedMessageResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 {
		return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid
	}
	return s.channels.UnpinAllChannelMessages(ctx, req)
}

// ExportInvite exports a channel/supergroup invite link.
func (s *Service) ExportInvite(ctx context.Context, userID int64, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ExportChannelInviteResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || len(req.Title) > domain.MaxChannelInviteTitleLength {
		return domain.ExportChannelInviteResult{}, domain.ErrChannelInvalid
	}
	return s.channels.ExportInvite(ctx, req)
}

// CheckInvite checks an invite hash.
func (s *Service) CheckInvite(ctx context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.CheckChannelInviteResult{}, domain.ErrChannelInvalid
	}
	if strings.TrimSpace(hash) == "" {
		return domain.CheckChannelInviteResult{}, domain.ErrInviteHashEmpty
	}
	return s.channels.CheckInvite(ctx, userID, strings.TrimSpace(hash), date)
}

// ImportInvite imports an invite and joins the channel if possible.
func (s *Service) ImportInvite(ctx context.Context, userID int64, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.CreateChannelResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || strings.TrimSpace(req.Hash) == "" {
		return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty
	}
	req.Hash = strings.TrimSpace(req.Hash)
	res, err := s.channels.ImportInvite(ctx, req)
	if err == nil {
		s.invalidateActiveChannelIDs(userID)
	}
	return res, err
}

// ListExportedInvites returns a bounded invite management page.
func (s *Service) ListExportedInvites(ctx context.Context, userID int64, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ChannelInviteList{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || req.AdminUserID == 0 || req.OffsetDate < 0 {
		return domain.ChannelInviteList{}, domain.ErrChannelInvalid
	}
	req.OffsetHash = strings.TrimSpace(req.OffsetHash)
	req.Limit = capLimit(req.Limit, domain.MaxChannelInviteListLimit)
	// Official semantics self-healing: when an admin views the first page of their own active links,
	// the permanent primary link must exist (this fills in legacy channels/missed creation paths;
	// permission is checked inside the store).
	if req.AdminUserID == userID && !req.Revoked && req.OffsetDate == 0 && req.OffsetHash == "" {
		if _, err := s.channels.EnsurePermanentInvite(ctx, req.ChannelID, userID, 0); err != nil {
			return domain.ChannelInviteList{}, err
		}
	}
	return s.channels.ListExportedInvites(ctx, req)
}

// GetExportedInvite returns one exported invite by hash.
func (s *Service) GetExportedInvite(ctx context.Context, userID int64, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ChannelInvite{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	req.Hash = strings.TrimSpace(req.Hash)
	if req.UserID != userID || req.ChannelID == 0 || req.Hash == "" {
		return domain.ChannelInvite{}, domain.ErrInviteHashEmpty
	}
	return s.channels.GetExportedInvite(ctx, req)
}

// EditExportedInvite edits or revokes one exported invite.
func (s *Service) EditExportedInvite(ctx context.Context, userID int64, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.EditChannelInviteResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	req.Hash = strings.TrimSpace(req.Hash)
	if req.UserID != userID || req.ChannelID == 0 || req.Hash == "" {
		return domain.EditChannelInviteResult{}, domain.ErrInviteHashEmpty
	}
	if req.ExpireDate < 0 || req.UsageLimit < 0 || len(req.Title) > domain.MaxChannelInviteTitleLength {
		return domain.EditChannelInviteResult{}, domain.ErrChannelInvalid
	}
	return s.channels.EditExportedInvite(ctx, req)
}

// DeleteExportedInvite deletes one exported invite.
func (s *Service) DeleteExportedInvite(ctx context.Context, userID int64, req domain.DeleteChannelInviteRequest) error {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	req.Hash = strings.TrimSpace(req.Hash)
	if req.UserID != userID || req.ChannelID == 0 || req.Hash == "" {
		return domain.ErrInviteHashEmpty
	}
	return s.channels.DeleteExportedInvite(ctx, req)
}

// DeleteRevokedExportedInvites deletes revoked invites for one admin.
func (s *Service) DeleteRevokedExportedInvites(ctx context.Context, userID int64, req domain.DeleteRevokedChannelInvitesRequest) error {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || req.AdminUserID == 0 {
		return domain.ErrChannelInvalid
	}
	req.Limit = capLimit(req.Limit, domain.MaxChannelHideJoinRequests)
	return s.channels.DeleteRevokedExportedInvites(ctx, req)
}

// ListAdminsWithInvites returns admins that created invite links.
func (s *Service) ListAdminsWithInvites(ctx context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return nil, domain.ErrChannelInvalid
	}
	return s.channels.ListAdminsWithInvites(ctx, userID, channelID)
}

// ListInviteImporters returns users joined/requested via invite links.
func (s *Service) ListInviteImporters(ctx context.Context, userID int64, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	req.Hash = strings.TrimSpace(req.Hash)
	req.Query = strings.TrimSpace(req.Query)
	if req.UserID != userID || req.ChannelID == 0 || req.OffsetDate < 0 {
		return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid
	}
	if utf8.RuneCountInString(req.Query) > domain.MaxChannelParticipantsQueryLength {
		return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid
	}
	req.Limit = capLimit(req.Limit, domain.MaxChannelInviteListLimit)
	return s.channels.ListInviteImporters(ctx, req)
}

// PendingJoinRequests returns the bounded admin-facing join request summary.
func (s *Service) PendingJoinRequests(ctx context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error) {
	if s == nil || s.channels == nil || channelID == 0 {
		return domain.ChannelPendingJoinRequests{}, domain.ErrChannelInvalid
	}
	limit = capLimit(limit, domain.MaxChannelPendingJoinRecentRequesters)
	return s.channels.PendingJoinRequests(ctx, channelID, limit)
}

// HideChatJoinRequest approves or dismisses one pending join request.
func (s *Service) HideChatJoinRequest(ctx context.Context, userID int64, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.CreateChannelResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.ChannelID == 0 || req.TargetUserID == 0 {
		return domain.CreateChannelResult{}, domain.ErrChannelInvalid
	}
	res, err := s.channels.HideChatJoinRequest(ctx, req)
	if err == nil {
		s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(req.TargetUserID, res.Members)...)
	}
	return res, err
}

// HideAllChatJoinRequests approves or dismisses pending join requests in a bounded batch.
func (s *Service) HideAllChatJoinRequests(ctx context.Context, userID int64, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.CreateChannelResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	req.Hash = strings.TrimSpace(req.Hash)
	if req.UserID != userID || req.ChannelID == 0 {
		return domain.CreateChannelResult{}, domain.ErrChannelInvalid
	}
	req.Limit = capLimit(req.Limit, domain.MaxChannelHideJoinRequests)
	res, err := s.channels.HideAllChatJoinRequests(ctx, req)
	if err == nil {
		s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(0, res.Members)...)
	}
	return res, err
}

// ListDialogs returns current user's channel/supergroup dialog page.
func (s *Service) ListDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ChannelDialogList{}, nil
	}
	return s.channels.ListChannelDialogs(ctx, userID, filter)
}

// GetDialogs returns channel/supergroup dialogs by IDs.
func (s *Service) GetDialogs(ctx context.Context, userID int64, channelIDs []int64) (domain.ChannelDialogList, error) {
	if s == nil || s.channels == nil || userID == 0 || len(channelIDs) == 0 {
		return domain.ChannelDialogList{}, nil
	}
	if len(channelIDs) > domain.MaxDialogFolderPeers {
		return domain.ChannelDialogList{}, domain.ErrChannelInvalid
	}
	return s.channels.GetChannelDialogs(ctx, userID, channelIDs)
}

// SetViewForumAsMessages stores the current user's local forum presentation mode.
func (s *Service) SetViewForumAsMessages(ctx context.Context, userID, channelID int64, enabled bool) (bool, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return false, domain.ErrChannelInvalid
	}
	return s.channels.SetChannelViewForumAsMessages(ctx, userID, channelID, enabled)
}

// CommonChannels returns megagroups shared by userID and req.TargetUserID.
func (s *Service) CommonChannels(ctx context.Context, userID int64, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.CommonChannelsResult{}, nil
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.TargetUserID == 0 || req.TargetUserID == userID || req.MaxID < 0 {
		return domain.CommonChannelsResult{}, domain.ErrChannelInvalid
	}
	req.Limit = capLimit(req.Limit, domain.MaxCommonChannelsLimit)
	return s.channels.ListCommonChannels(ctx, req)
}

// LeftChannels returns a bounded export page of channels/supergroups the user has left.
func (s *Service) LeftChannels(ctx context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) {
	if s == nil || s.channels == nil || userID == 0 || offset < 0 || offset > domain.MaxLeftChannelsOffset {
		return domain.LeftChannelsResult{}, domain.ErrChannelInvalid
	}
	return s.channels.ListLeftChannels(ctx, userID, offset, capLimit(limit, domain.MaxLeftChannelsLimit))
}

// InactiveChannels returns least recently active channels/supergroups for limits UI.
func (s *Service) InactiveChannels(ctx context.Context, userID int64, limit int) (domain.ChannelDialogList, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ChannelDialogList{}, domain.ErrChannelInvalid
	}
	return s.channels.ListInactiveChannels(ctx, userID, capLimit(limit, domain.MaxInactiveChannelsLimit))
}

// ChannelRecommendations returns public broadcast channels for TDesktop similar/recommended UI.
func (s *Service) ChannelRecommendations(ctx context.Context, userID int64, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ChannelRecommendationsResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.SourceChannelID < 0 {
		return domain.ChannelRecommendationsResult{}, domain.ErrChannelInvalid
	}
	if req.Limit <= 0 {
		req.Limit = domain.DefaultChannelRecommendationsLimit
	} else {
		req.Limit = capLimit(req.Limit, domain.MaxChannelRecommendationsLimit)
	}
	return s.channels.ListChannelRecommendations(ctx, req)
}

// DiscussionGroups returns manageable megagroups that can be linked to a broadcast channel.
func (s *Service) DiscussionGroups(ctx context.Context, userID int64, limit int) ([]domain.Channel, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return nil, domain.ErrChannelInvalid
	}
	return s.channels.ListDiscussionGroups(ctx, userID, capLimit(limit, domain.MaxDiscussionGroupsLimit))
}

// SetDiscussionGroup links or unlinks a broadcast channel and a discussion megagroup.
func (s *Service) SetDiscussionGroup(ctx context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelInvalid
	}
	if broadcastID == 0 && groupID == 0 {
		return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified
	}
	return s.channels.SetDiscussionGroup(ctx, userID, broadcastID, groupID)
}

// GetHistory returns a channel/supergroup history page.
func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) {
	if s == nil || s.channels == nil || userID == 0 || filter.ChannelID == 0 {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	if utf8.RuneCountInString(filter.Query) > domain.MaxChannelHistoryQueryLength {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	filter.Limit = capLimit(filter.Limit, 100)
	history, err := s.channels.ListChannelHistory(ctx, userID, filter)
	if err != nil {
		return domain.ChannelHistory{}, err
	}
	return s.filterBotChannelHistory(ctx, userID, history), nil
}

// SearchPosts returns a bounded page of public channel/supergroup posts.
func (s *Service) SearchPosts(ctx context.Context, userID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	if req.OffsetRate < 0 || req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID {
		return domain.ChannelHistory{}, domain.ErrMessageIDInvalid
	}
	if req.OffsetChannelID < 0 {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	if (strings.TrimSpace(req.Query) == "") == (strings.TrimSpace(req.Hashtag) == "") {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	if strings.Contains(req.Hashtag, "#") ||
		utf8.RuneCountInString(req.Query) > domain.MaxChannelHistoryQueryLength ||
		utf8.RuneCountInString(req.Hashtag) > domain.MaxChannelHistoryQueryLength {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	req.Query = strings.TrimSpace(req.Query)
	req.Hashtag = strings.TrimSpace(req.Hashtag)
	req.Limit = capLimit(req.Limit, domain.MaxChannelSearchPostsLimit)
	return s.channels.SearchPublicPosts(ctx, userID, req)
}

// SearchJoinedMessages returns current-account-visible channel/supergroup hits for messages.searchGlobal.
func (s *Service) SearchJoinedMessages(ctx context.Context, userID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	if req.OffsetRate < 0 || req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID {
		return domain.ChannelHistory{}, domain.ErrMessageIDInvalid
	}
	if req.OffsetChannelID < 0 || req.MinDate < 0 || req.MaxDate < 0 || (req.HasFolderID && req.FolderID < 0) {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	if req.BroadcastsOnly && req.GroupsOnly {
		return domain.ChannelHistory{}, nil
	}
	req.Query = strings.TrimSpace(req.Query)
	if req.Query == "" && !req.MusicOnly {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	if utf8.RuneCountInString(req.Query) > domain.MaxChannelHistoryQueryLength {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	req.Limit = capLimit(req.Limit, domain.MaxChannelGlobalSearchLimit)
	return s.channels.SearchJoinedMessages(ctx, userID, req)
}

// GetReplies returns a bounded message thread page.
func (s *Service) GetReplies(ctx context.Context, userID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) {
	if s == nil || s.channels == nil || userID == 0 || filter.ChannelID == 0 || filter.RootMessageID <= 0 {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	if filter.RootMessageID > domain.MaxMessageBoxID || filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID || filter.MaxID < 0 || filter.MaxID > domain.MaxMessageBoxID || filter.MinID < 0 || filter.MinID > domain.MaxMessageBoxID {
		return domain.ChannelHistory{}, domain.ErrMessageIDInvalid
	}
	filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset)
	filter.Limit = capLimit(filter.Limit, domain.MaxChannelRepliesLimit)
	history, err := s.channels.ListChannelReplies(ctx, userID, filter)
	if err != nil {
		return domain.ChannelHistory{}, err
	}
	return s.filterBotChannelHistory(ctx, userID, history), nil
}

// SendMonoforumMessage sends a channel direct message (monoforum). Send permission
// (subscriber identity / monoforum admin) is checked by the RPC layer; here we only
// validate parameters and delegate to the store (the store does not require the sender
// to be a monoforum member).
func (s *Service) SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) {
	if s == nil || s.channels == nil || req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 {
		return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
	}
	if req.RandomID != 0 && !req.IdempotencyPreflighted {
		fingerprint, err := store.MonoforumSendFingerprint(req)
		if err != nil {
			return domain.SendChannelMessageResult{}, err
		}
		req.IdempotencyFingerprint = fingerprint
		if replayStore, ok := s.channels.(store.ChannelSendReplayStore); ok {
			replay, found, err := replayStore.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
				ChannelID:              req.MonoforumID,
				SenderUserID:           req.SenderUserID,
				SavedPeer:              req.SavedPeer,
				RandomID:               req.RandomID,
				IdempotencyFingerprint: fingerprint,
			})
			if err != nil || found {
				return replay, err
			}
			req.IdempotencyPreflighted = true
		}
	}
	if err := s.ensureCanSend(ctx, req.SenderUserID); err != nil {
		return domain.SendChannelMessageResult{}, err
	}
	result, err := s.channels.SendMonoforumMessage(ctx, req)
	if err != nil {
		return result, err
	}
	// The saved-peer owner gains (or refreshes) monoforum readiness visibility.
	// Evict locally at commit return; migration 0201 advances the durable token
	// and NOTIFY handles every other process.
	s.invalidateActiveChannelIDs(req.SavedPeer.ID)
	return result, nil
}

// ListMonoforumHistory fetches a subscriber's history within a channel direct message (monoforum).
func (s *Service) ListMonoforumHistory(ctx context.Context, filter domain.MonoforumHistoryFilter) (domain.ChannelHistory, error) {
	if s == nil || s.channels == nil || filter.MonoforumID == 0 || filter.SavedPeer.ID == 0 {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	if filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID {
		return domain.ChannelHistory{}, domain.ErrMessageIDInvalid
	}
	filter.Limit = capLimit(filter.Limit, domain.MaxChannelRepliesLimit)
	return s.channels.ListMonoforumHistory(ctx, filter)
}

// ListMonoforumDialogs lists a monoforum's subscriber sub-sessions (the admin-view direct
// message list). Access permission (admin only) is checked by the RPC layer.
func (s *Service) ListMonoforumDialogs(ctx context.Context, filter domain.MonoforumDialogsFilter) (domain.MonoforumDialogList, error) {
	if s == nil || s.channels == nil || filter.MonoforumID == 0 {
		return domain.MonoforumDialogList{}, domain.ErrChannelInvalid
	}
	if filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID {
		return domain.MonoforumDialogList{}, domain.ErrMessageIDInvalid
	}
	filter.Limit = capLimit(filter.Limit, domain.MaxChannelRepliesLimit)
	return s.channels.ListMonoforumDialogs(ctx, filter)
}

// ResolveMonoforumSend fetches a monoforum channel by id (membership not required) and returns whether the caller is an admin of its parent channel.
func (s *Service) ResolveMonoforumSend(ctx context.Context, viewerUserID, monoforumID int64) (domain.Channel, bool, error) {
	if s == nil || s.channels == nil || viewerUserID == 0 || monoforumID == 0 {
		return domain.Channel{}, false, domain.ErrChannelInvalid
	}
	return s.channels.ResolveMonoforumSend(ctx, viewerUserID, monoforumID)
}

// GetUnreadMentions returns a bounded unread mention page for a channel/supergroup.
func (s *Service) GetUnreadMentions(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) {
	if s == nil || s.channels == nil || userID == 0 || filter.ChannelID == 0 {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	if filter.TopMsgID < 0 || filter.TopMsgID > domain.MaxMessageBoxID ||
		filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID ||
		filter.MaxID < 0 || filter.MaxID > domain.MaxMessageBoxID ||
		filter.MinID < 0 || filter.MinID > domain.MaxMessageBoxID {
		return domain.ChannelHistory{}, domain.ErrMessageIDInvalid
	}
	filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset)
	filter.Limit = capLimit(filter.Limit, domain.MaxChannelUnreadMentionsLimit)
	return s.channels.ListChannelUnreadMentions(ctx, userID, filter)
}

// ReadMentions clears unread mentions for a channel/supergroup in a bounded batch.
func (s *Service) ReadMentions(ctx context.Context, userID int64, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.TopMsgID < 0 || req.TopMsgID > domain.MaxMessageBoxID {
		return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID {
		return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid
	}
	req.Limit = capLimit(req.Limit, domain.MaxChannelReadMentionsBatch)
	return s.channels.ReadChannelMentions(ctx, req)
}

// GetUnreadReactions returns a bounded unread reaction page for a channel/supergroup.
func (s *Service) GetUnreadReactions(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) {
	if s == nil || s.channels == nil || userID == 0 || filter.ChannelID == 0 {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	if filter.TopMsgID < 0 || filter.TopMsgID > domain.MaxMessageBoxID ||
		filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID ||
		filter.MaxID < 0 || filter.MaxID > domain.MaxMessageBoxID ||
		filter.MinID < 0 || filter.MinID > domain.MaxMessageBoxID {
		return domain.ChannelHistory{}, domain.ErrMessageIDInvalid
	}
	filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset)
	filter.Limit = capLimit(filter.Limit, domain.MaxChannelUnreadReactionsLimit)
	return s.channels.ListChannelUnreadReactions(ctx, userID, filter)
}

// ReadReactions clears unread reactions for a channel/supergroup in a bounded batch.
func (s *Service) ReadReactions(ctx context.Context, userID int64, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.TopMsgID < 0 || req.TopMsgID > domain.MaxMessageBoxID {
		return domain.ReadChannelReactionsResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID {
		return domain.ReadChannelReactionsResult{}, domain.ErrChannelInvalid
	}
	req.Limit = capLimit(req.Limit, domain.MaxChannelReadReactionsBatch)
	return s.channels.ReadChannelReactions(ctx, req)
}

// GetMessages returns a bounded exact-id channel/supergroup message batch.
func (s *Service) GetMessages(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelHistory, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	if len(ids) > domain.MaxGetMessageIDs {
		return domain.ChannelHistory{}, domain.ErrChannelInvalid
	}
	for _, id := range ids {
		if id <= 0 || id > domain.MaxMessageBoxID {
			return domain.ChannelHistory{}, domain.ErrMessageIDInvalid
		}
	}
	history, err := s.channels.GetChannelMessages(ctx, userID, channelID, ids)
	if err != nil {
		return domain.ChannelHistory{}, err
	}
	return s.filterBotChannelHistory(ctx, userID, history), nil
}

// ChannelPollFanoutViews returns per-viewer enrichment of a channel poll message for a set of
// viewers in one batch (fan-out templating, eliminating the N+1 of per-viewer GetMessages). The
// store handles member/AvailableMinID visibility + template aggregation; here we layer on bot
// history visibility filtering (replicating filterBotChannelHistory: a bot without ChatHistory
// cannot see the message → set nil).
// Returns map[viewer]: key present = evaluated (nil = not visible, the caller skips accordingly
// and needs no fallback); non-nil = the enriched poll for that viewer.
func (s *Service) ChannelPollFanoutViews(ctx context.Context, channelID int64, msgID int, viewers []int64, now int) (map[int64]*domain.MessagePoll, error) {
	if s == nil || s.channels == nil || channelID == 0 || msgID <= 0 || len(viewers) == 0 {
		return map[int64]*domain.MessagePoll{}, nil
	}
	views, err := s.channels.ChannelPollFanoutViews(ctx, channelID, msgID, viewers, now)
	if err != nil {
		return nil, err
	}
	if !views.Found {
		return map[int64]*domain.MessagePoll{}, nil
	}
	if s.bots != nil {
		for viewer, poll := range views.Polls {
			if poll == nil {
				continue
			}
			if !s.botViewerCanSeeChannelMessage(ctx, viewer, views.Message) {
				views.Polls[viewer] = nil
			}
		}
	}
	return views.Polls, nil
}

// botViewerCanSeeChannelMessage replicates filterBotChannelHistory's single-message decision:
// a non-bot or a bot with ChatHistory is always visible; for a bot without ChatHistory, whether
// that message is visible is decided by botCanSeeChannelMessage.
func (s *Service) botViewerCanSeeChannelMessage(ctx context.Context, viewer int64, msg domain.ChannelMessage) bool {
	if s.bots == nil || viewer == 0 {
		return true
	}
	profile, found, err := s.bots.BotInfo(ctx, viewer)
	if err != nil || !found || profile.ChatHistory {
		return true
	}
	visible, err := s.botCanSeeChannelMessage(ctx, viewer, msg, nil)
	return err == nil && visible
}

// ListStoryMessageForwards returns public channel/supergroup messages that
// shared a source story as messageMediaStory.
func (s *Service) ListStoryMessageForwards(ctx context.Context, userID int64, req domain.StoryMessageForwardListRequest) (domain.StoryMessageForwardList, error) {
	if s == nil || s.channels == nil || userID == 0 {
		return domain.StoryMessageForwardList{}, domain.ErrChannelInvalid
	}
	if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID || req.Owner.ID == 0 {
		return domain.StoryMessageForwardList{}, domain.ErrStoryIDInvalid
	}
	if req.Owner.Type != domain.PeerTypeUser && req.Owner.Type != domain.PeerTypeChannel {
		return domain.StoryMessageForwardList{}, domain.ErrStoryPeerInvalid
	}
	if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
		return domain.StoryMessageForwardList{}, err
	}
	req.ViewerUserID = userID
	req.Limit = capLimit(req.Limit, domain.MaxStoryInteractionListLimit)
	return s.channels.ListStoryMessageForwards(ctx, req)
}

// GetDiscussionMessage returns the root message used to open a discussion thread.
func (s *Service) GetDiscussionMessage(ctx context.Context, userID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.ChannelDiscussionMessage{}, domain.ErrChannelInvalid
	}
	if msgID <= 0 || msgID > domain.MaxMessageBoxID {
		return domain.ChannelDiscussionMessage{}, domain.ErrMessageIDInvalid
	}
	return s.channels.GetDiscussionMessage(ctx, userID, channelID, msgID)
}

// ReadHistory advances current user's channel read watermark.
func (s *Service) ReadHistory(ctx context.Context, userID int64, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 {
		return domain.ReadChannelHistoryResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID {
		return domain.ReadChannelHistoryResult{}, domain.ErrChannelInvalid
	}
	return s.channels.ReadChannelHistory(ctx, req)
}

// ReadTopicHistory advances current user's per-topic read watermark inside a forum.
func (s *Service) ReadTopicHistory(ctx context.Context, userID int64, req domain.ReadChannelTopicHistoryRequest) (domain.ReadChannelTopicHistoryResult, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.TopicID <= 0 {
		return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID {
		return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelInvalid
	}
	return s.channels.ReadChannelTopicHistory(ctx, req)
}

// GeneralForumTopic computes on the fly the state of the forum General topic (id=1) for the viewer.
func (s *Service) GeneralForumTopic(ctx context.Context, userID, channelID int64) (domain.ChannelForumTopic, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return domain.ChannelForumTopic{}, domain.ErrChannelInvalid
	}
	return s.channels.GeneralForumTopic(ctx, userID, channelID)
}

// GetMessageReadParticipants returns a bounded read receipt list for a small megagroup message.
func (s *Service) GetMessageReadParticipants(ctx context.Context, userID int64, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 {
		return domain.ChannelReadParticipantsResult{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID {
		return domain.ChannelReadParticipantsResult{}, domain.ErrChannelInvalid
	}
	req.Limit = capLimit(req.Limit, domain.MaxChannelReadParticipants)
	return s.channels.ListMessageReadParticipants(ctx, req)
}

// ActiveChannelIDsForUser pages active joined channels for one user.
func (s *Service) ActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) {
	if s == nil || s.channels == nil || userID == 0 || afterChannelID < 0 {
		return nil, domain.ErrChannelInvalid
	}
	if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout {
		limit = domain.MaxSynchronousChannelDialogFanout
	}
	return s.cachedActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
}

// DirtyActiveChannelsForUser pages active joined channels with channel events after sinceDate.
func (s *Service) DirtyActiveChannelsForUser(ctx context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error) {
	if s == nil || s.channels == nil || userID == 0 || sinceDate <= 0 || afterChannelID < 0 {
		return nil, domain.ErrChannelInvalid
	}
	if limit <= 0 || limit > domain.MaxChannelDifferenceLimit {
		limit = domain.MaxChannelDifferenceLimit
	}
	return s.channels.ListDirtyActiveChannelsForUser(ctx, userID, sinceDate, afterChannelID, limit)
}

// MaxChannelPts returns the durable channel watermark used by the fan-out saturation recovery
// sweep. It intentionally performs no viewer access check: target visibility is derived from the
// process-local joined-membership index, while getChannelDifference performs authoritative access
// validation when a client consumes the nudge.
func (s *Service) MaxChannelPts(ctx context.Context, channelID int64) (int, error) {
	if s == nil || s.channels == nil || channelID == 0 {
		return 0, domain.ErrChannelInvalid
	}
	return s.channels.MaxChannelPts(ctx, channelID)
}

// MaxChannelPtsBatch reloads a bounded recovery page in one store call. Missing ids are omitted:
// they represent channels deleted after the process-local online-membership snapshot was taken.
func (s *Service) MaxChannelPtsBatch(ctx context.Context, channelIDs []int64) (map[int64]int, error) {
	if s == nil || s.channels == nil {
		return nil, domain.ErrChannelInvalid
	}
	return s.channels.MaxChannelPtsBatch(ctx, channelIDs)
}

// ActiveMemberIDs returns a bounded list for transient online fanout such as typing.
func (s *Service) ActiveMemberIDs(ctx context.Context, userID, channelID int64, limit int) ([]int64, error) {
	if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
		return nil, domain.ErrChannelInvalid
	}
	if limit <= 0 || limit > domain.MaxChannelTypingFanout {
		limit = domain.MaxChannelTypingFanout
	}
	return s.channels.ListActiveChannelMemberIDs(ctx, userID, channelID, limit)
}

// InviteAdminMemberIDs returns active admins that can manage join requests.
func (s *Service) InviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error) {
	if s == nil || s.channels == nil || channelID == 0 {
		return nil, domain.ErrChannelInvalid
	}
	if limit <= 0 || limit > domain.MaxChannelRealtimeFanout {
		limit = domain.MaxChannelRealtimeFanout
	}
	return s.channels.ListChannelInviteAdminMemberIDs(ctx, channelID, limit)
}

// FilterActiveMemberIDs keeps only candidates that are active members of channelID.
// It is used by realtime fanout to intersect the online-user set with membership.
func (s *Service) FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) {
	if s == nil || s.channels == nil || channelID == 0 {
		return nil, domain.ErrChannelInvalid
	}
	candidates := uniqueNonZero(userIDs)
	if len(candidates) == 0 {
		return nil, nil
	}
	return s.channels.FilterActiveChannelMemberIDs(ctx, channelID, candidates)
}

// FilterMessageAudienceIDs keeps active members and currently authorized
// public-preview viewers from a bounded online candidate set. The store performs
// one batched authoritative check per bounded chunk so runtime session indexes
// never become an access-control source of truth.
func (s *Service) FilterMessageAudienceIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) {
	if s == nil || s.channels == nil || channelID == 0 {
		return nil, domain.ErrChannelInvalid
	}
	candidates := uniqueNonZero(userIDs)
	if len(candidates) == 0 {
		return nil, nil
	}
	return s.channels.FilterChannelMessageAudienceIDs(ctx, channelID, candidates)
}

// GetDifference returns channel-scoped pts difference.
func (s *Service) GetDifference(ctx context.Context, userID int64, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) {
	if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 {
		return domain.ChannelDifference{}, domain.ErrChannelInvalid
	}
	if req.UserID == 0 {
		req.UserID = userID
	}
	if req.UserID != userID {
		return domain.ChannelDifference{}, domain.ErrChannelInvalid
	}
	req.Limit = capLimit(req.Limit, domain.MaxChannelDifferenceLimit)
	diff, err := s.channels.ListChannelDifference(ctx, req)
	if err != nil {
		return domain.ChannelDifference{}, err
	}
	diff = s.filterBotChannelDifference(ctx, userID, diff)
	return diff, nil
}

// ClearDanglingPinnedMessage clears dangling pin values pointing at deleted messages (unpinAll self-healing).
func (s *Service) ClearDanglingPinnedMessage(ctx context.Context, channelID int64, messageID int) error {
	if s == nil || s.channels == nil || channelID == 0 || messageID <= 0 {
		return domain.ErrChannelInvalid
	}
	return s.channels.ClearDanglingPinnedMessage(ctx, channelID, messageID)
}

func capLimit(limit, max int) int {
	if max <= 0 {
		return limit
	}
	if limit <= 0 {
		return max
	}
	if limit > max {
		return max
	}
	return limit
}

func uniqueNonZero(ids []int64) []int64 {
	out := make([]int64, 0, len(ids))
	seen := make(map[int64]struct{}, len(ids))
	for _, id := range ids {
		if id == 0 {
			continue
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		out = append(out, id)
	}
	return out
}

func uniqueUserIDs(ids ...int64) []int64 {
	return uniqueNonZero(ids)
}

func activeMembershipUserIDsFromMembers(primary int64, members []domain.ChannelMember) []int64 {
	ids := make([]int64, 0, len(members)+1)
	if primary != 0 {
		ids = append(ids, primary)
	}
	for _, member := range members {
		ids = append(ids, member.UserID)
	}
	return uniqueNonZero(ids)
}

func (s *Service) invalidateActiveChannelIDs(userIDs ...int64) {
	if s == nil {
		return
	}
	if versionCache, ok := s.versions.(store.ReadModelVersionCache); ok {
		for _, userID := range uniqueNonZero(userIDs) {
			versionCache.InvalidateReadModel(store.ReadModelKey{
				Model:       readmodel.ModelChannelActiveIDs,
				OwnerUserID: userID,
				PeerType:    domain.PeerTypeUser,
				PeerID:      userID,
			})
		}
	}
	if s.activeIDsCache == nil {
		return
	}
	s.activeIDsCache.invalidateUsers(userIDs...)
}

func (s *Service) invalidateActiveBotMemberIDs(channelID int64) {
	if s == nil || s.botMemberIDsCache == nil {
		return
	}
	s.botMemberIDsCache.invalidateChannel(channelID)
}

func (s *Service) InvalidateActiveBotMemberIDsReadModel(channelID int64) {
	s.invalidateActiveBotMemberIDs(channelID)
}

func (s *Service) FlushActiveBotMemberIDsReadModel() {
	if s == nil || s.botMemberIDsCache == nil {
		return
	}
	s.botMemberIDsCache.flush()
}

func normalizeChannelUsername(username string) string {
	username = strings.TrimSpace(username)
	username = strings.TrimPrefix(username, "@")
	return strings.TrimSpace(username)
}

func validChannelUsername(username string) bool {
	if len(username) < 5 || len(username) > 32 {
		return false
	}
	for i := 0; i < len(username); i++ {
		c := username[i]
		switch {
		case c >= 'a' && c <= 'z':
		case c >= 'A' && c <= 'Z':
		case c >= '0' && c <= '9':
			if i == 0 {
				return false
			}
		case c == '_':
			if i == 0 {
				return false
			}
		default:
			return false
		}
	}
	return true
}