owpengram-server/internal/app/stories/service.go

560 lines
18 KiB
Go

package stories
import (
"context"
"strings"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// Service owns Telegram story read/watch state.
type Service struct {
stories store.StoryStore
channelAccess ChannelStoryAccess
}
// ChannelStoryAccess validates channel story mutation rights without leaking TL
// types into the app layer.
type ChannelStoryAccess interface {
CanPostStory(ctx context.Context, userID, channelID int64) error
CanEditStory(ctx context.Context, userID, channelID int64) error
CanDeleteStory(ctx context.Context, userID, channelID int64) error
CanPinStory(ctx context.Context, userID, channelID int64) error
}
// Option adjusts optional story service dependencies.
type Option func(*Service)
// WithChannelStoryAccess enables channel story publishing/admin checks.
func WithChannelStoryAccess(access ChannelStoryAccess) Option {
return func(s *Service) { s.channelAccess = access }
}
// NewService creates a story service.
func NewService(stories store.StoryStore, opts ...Option) *Service {
s := &Service{stories: stories}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *Service) CreateStory(ctx context.Context, userID int64, req domain.StoryCreateRequest) (domain.StoryCreateResult, error) {
if err := s.authorizeStoryOwner(ctx, userID, req.Owner, storyOwnerPost); err != nil {
return domain.StoryCreateResult{}, err
}
if req.RandomID == 0 {
return domain.StoryCreateResult{}, domain.ErrStoryIDInvalid
}
if req.Media == nil {
return domain.StoryCreateResult{}, domain.ErrStoryNotFound
}
req.Period = normalizeStoryPeriod(req.Period)
if req.Period == 0 {
return domain.StoryCreateResult{}, domain.ErrStoryPeriodInvalid
}
if s == nil || s.stories == nil {
return domain.StoryCreateResult{}, domain.ErrStoryNotFound
}
return s.stories.CreateStory(ctx, req)
}
// UpsertStory stores a story snapshot. It is used by tests now and by the
// publishing flow once sendStory/editStory are promoted from stubs.
func (s *Service) UpsertStory(ctx context.Context, req domain.UpsertStoryRequest) (domain.Story, error) {
if s == nil || s.stories == nil {
return domain.Story{}, domain.ErrStoryNotFound
}
return s.stories.UpsertStory(ctx, req)
}
func (s *Service) GetAllStories(ctx context.Context, viewerUserID int64, hidden bool, now, limit int) (domain.StoryList, error) {
return s.GetAllStoriesPage(ctx, viewerUserID, hidden, now, domain.StoryListCursor{}, limit)
}
func (s *Service) GetAllStoriesPage(ctx context.Context, viewerUserID int64, hidden bool, now int, cursor domain.StoryListCursor, limit int) (domain.StoryList, error) {
if s == nil || s.stories == nil || viewerUserID == 0 {
return domain.StoryList{}, nil
}
if cursor.Set {
if err := validateStoryListCursor(cursor); err != nil {
return domain.StoryList{}, domain.ErrStoryOffsetInvalid
}
}
return s.stories.ListActiveStoriesPage(ctx, viewerUserID, hidden, now, cursor, clampStoryLimit(limit))
}
func (s *Service) GetAllStoriesDigest(ctx context.Context, viewerUserID int64, hidden bool, now int) (domain.StoryListDigest, error) {
if s == nil || s.stories == nil || viewerUserID == 0 {
return domain.StoryListDigest{}, nil
}
return s.stories.ActiveStoriesDigest(ctx, viewerUserID, hidden, now)
}
func (s *Service) ListOwnerActiveStories(ctx context.Context, userID int64, owner domain.Peer, now, limit int) (domain.StoryList, error) {
if err := validateStoryOwner(userID, owner); err != nil {
return domain.StoryList{}, err
}
if s == nil || s.stories == nil {
return domain.StoryList{}, nil
}
return s.stories.ListOwnerActiveStories(ctx, owner, now, clampStoryLimit(limit))
}
func (s *Service) GetPeerStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (domain.PeerStories, error) {
if s == nil || s.stories == nil || viewerUserID == 0 {
return domain.PeerStories{Peer: peer}, nil
}
return s.stories.GetPeerStories(ctx, viewerUserID, peer, now)
}
func (s *Service) GetStoriesByID(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, now int) (domain.StoryList, error) {
ids, err := normalizeStoryIDsNonEmpty(ids)
if err != nil {
return domain.StoryList{}, err
}
if s == nil || s.stories == nil || viewerUserID == 0 {
return domain.StoryList{}, nil
}
return s.stories.GetStoriesByID(ctx, viewerUserID, peer, ids, now)
}
func (s *Service) GetStoriesArchive(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) {
if err := s.authorizeStoryOwner(ctx, viewerUserID, peer, storyOwnerEdit); err != nil {
return domain.StoryList{}, err
}
if s == nil || s.stories == nil {
return domain.StoryList{}, nil
}
return s.stories.ListStoriesArchive(ctx, viewerUserID, peer, offsetID, clampStoryArchiveLimit(limit), now)
}
func (s *Service) GetPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) {
if s == nil || s.stories == nil || viewerUserID == 0 {
return domain.StoryList{}, nil
}
return s.stories.ListPinnedStories(ctx, viewerUserID, peer, offsetID, clampStoryLimit(limit), now)
}
func (s *Service) HasPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (bool, error) {
if s == nil || s.stories == nil || viewerUserID == 0 {
return false, nil
}
return s.stories.HasPinnedStories(ctx, viewerUserID, peer, now)
}
func (s *Service) ListReadStates(ctx context.Context, viewerUserID int64) ([]domain.StoryReadState, error) {
if s == nil || s.stories == nil || viewerUserID == 0 {
return nil, nil
}
return s.stories.ListReadStates(ctx, viewerUserID)
}
func (s *Service) GetPeerMaxIDs(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.RecentStory, error) {
if len(peers) > domain.MaxStoryIDs {
return nil, domain.ErrStoryIDInvalid
}
if s == nil || s.stories == nil || viewerUserID == 0 || len(peers) == 0 {
out := make([]domain.RecentStory, len(peers))
for i, peer := range peers {
out[i].Peer = peer
}
return out, nil
}
return s.stories.GetPeerMaxIDs(ctx, viewerUserID, peers, now)
}
func (s *Service) GetPeerHiddenStates(ctx context.Context, viewerUserID int64, peers []domain.Peer) (map[domain.Peer]bool, error) {
if len(peers) > domain.MaxStoryIDs {
return nil, domain.ErrStoryIDInvalid
}
if s == nil || s.stories == nil || viewerUserID == 0 || len(peers) == 0 {
return map[domain.Peer]bool{}, nil
}
return s.stories.GetPeerHiddenStates(ctx, viewerUserID, peers)
}
func (s *Service) GetPeerStoryProjections(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.PeerStoryProjection, error) {
if len(peers) > domain.MaxStoryIDs {
return nil, domain.ErrStoryIDInvalid
}
out := make([]domain.PeerStoryProjection, len(peers))
for i, peer := range peers {
out[i].Peer = peer
out[i].Recent.Peer = peer
}
if s == nil || s.stories == nil || viewerUserID == 0 || len(peers) == 0 {
return out, nil
}
return s.stories.GetPeerStoryProjections(ctx, viewerUserID, peers, now)
}
func (s *Service) ReadStories(ctx context.Context, viewerUserID int64, peer domain.Peer, maxID, date int) (domain.StoryReadResult, error) {
if maxID <= 0 || maxID > domain.MaxStoryID {
return domain.StoryReadResult{}, domain.ErrStoryIDInvalid
}
if s == nil || s.stories == nil || viewerUserID == 0 {
return domain.StoryReadResult{ViewerID: viewerUserID, Peer: peer, Date: date}, nil
}
recent, err := s.stories.GetPeerMaxIDs(ctx, viewerUserID, []domain.Peer{peer}, date)
if err != nil {
return domain.StoryReadResult{}, err
}
if len(recent) == 0 || recent[0].MaxID <= 0 {
return domain.StoryReadResult{ViewerID: viewerUserID, Peer: peer, Date: date}, nil
}
if maxID > recent[0].MaxID {
maxID = recent[0].MaxID
}
return s.stories.MarkRead(ctx, viewerUserID, peer, maxID, date)
}
func (s *Service) IncrementViews(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, date int) (int, error) {
ids, err := normalizeStoryIDsNonEmpty(ids)
if err != nil {
return 0, err
}
if s == nil || s.stories == nil || viewerUserID == 0 {
return 0, nil
}
return s.stories.IncrementViews(ctx, viewerUserID, peer, ids, date)
}
func (s *Service) SendReaction(ctx context.Context, viewerUserID int64, peer domain.Peer, storyID int, reaction *domain.MessageReaction, date int) (domain.StoryReactionResult, error) {
if storyID <= 0 || storyID > domain.MaxStoryID {
return domain.StoryReactionResult{}, domain.ErrStoryIDInvalid
}
if s == nil || s.stories == nil || viewerUserID == 0 {
return domain.StoryReactionResult{ViewerID: viewerUserID, Peer: peer, StoryID: storyID, Reaction: reaction, Date: date}, nil
}
return s.stories.SetReaction(ctx, viewerUserID, peer, storyID, reaction, date)
}
func (s *Service) GetStoryViewsList(ctx context.Context, viewerUserID int64, req domain.StoryViewListRequest) (domain.StoryViewList, error) {
if err := s.authorizeStoryOwner(ctx, viewerUserID, req.Owner, storyOwnerEdit); err != nil {
return domain.StoryViewList{}, err
}
if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID {
return domain.StoryViewList{}, domain.ErrStoryIDInvalid
}
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
return domain.StoryViewList{}, err
}
if s == nil || s.stories == nil {
return domain.StoryViewList{}, nil
}
req.ViewerUserID = viewerUserID
req.Limit = clampStoryInteractionLimit(req.Limit)
req.Query = normalizeStoryViewQuery(req.Query)
return s.stories.ListStoryViews(ctx, req)
}
func (s *Service) GetStoryReactionsList(ctx context.Context, viewerUserID int64, req domain.StoryReactionListRequest) (domain.StoryReactionList, error) {
if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID {
return domain.StoryReactionList{}, domain.ErrStoryIDInvalid
}
if req.Owner.Type == domain.PeerTypeChannel {
if viewerUserID == 0 || !req.CanViewOwnerInteractions {
return domain.StoryReactionList{}, domain.ErrStoryPeerInvalid
}
} else {
return domain.StoryReactionList{}, domain.ErrStoryPeerInvalid
}
if err := domain.ValidateStoryReactionInteractionOffset(req.Offset, req.ForwardsFirst); err != nil {
return domain.StoryReactionList{}, err
}
if s == nil || s.stories == nil {
return domain.StoryReactionList{}, nil
}
req.ViewerUserID = viewerUserID
req.Limit = clampStoryInteractionLimit(req.Limit)
return s.stories.ListStoryReactions(ctx, req)
}
func (s *Service) GetStoryPublicForwards(ctx context.Context, viewerUserID int64, req domain.StoryPublicForwardListRequest) (domain.StoryPublicForwardList, error) {
if err := s.authorizeStoryOwner(ctx, viewerUserID, req.Owner, storyOwnerEdit); err != nil {
return domain.StoryPublicForwardList{}, err
}
if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID {
return domain.StoryPublicForwardList{}, domain.ErrStoryIDInvalid
}
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
return domain.StoryPublicForwardList{}, err
}
if s == nil || s.stories == nil {
return domain.StoryPublicForwardList{}, nil
}
req.ViewerUserID = viewerUserID
req.Limit = clampStoryInteractionLimit(req.Limit)
return s.stories.ListStoryPublicForwards(ctx, req)
}
func (s *Service) CanViewStoryStats(ctx context.Context, userID int64, peer domain.Peer) error {
return s.authorizeStoryOwner(ctx, userID, peer, storyOwnerEdit)
}
func (s *Service) ListStoryViewerIDs(ctx context.Context, userID int64, owner domain.Peer, storyID, limit int) ([]int64, error) {
if err := validateStoryOwner(userID, owner); err != nil {
return nil, err
}
if storyID <= 0 || storyID > domain.MaxStoryID {
return nil, domain.ErrStoryIDInvalid
}
if s == nil || s.stories == nil {
return nil, nil
}
return s.stories.ListStoryViewerIDs(ctx, owner, storyID, clampStoryPrivacyFanoutLimit(limit))
}
func (s *Service) EditStory(ctx context.Context, userID int64, req domain.StoryEditRequest) (domain.StoryEditResult, error) {
if err := s.authorizeStoryOwner(ctx, userID, req.Owner, storyOwnerEdit); err != nil {
return domain.StoryEditResult{}, err
}
if req.ID <= 0 || req.ID > domain.MaxStoryID {
return domain.StoryEditResult{}, domain.ErrStoryIDInvalid
}
if !req.UpdateMedia && !req.UpdateCaption && !req.UpdatePrivacy && !req.UpdateMediaAreas {
return domain.StoryEditResult{}, domain.ErrStoryNotModified
}
if req.UpdateMedia && req.Media == nil {
return domain.StoryEditResult{}, domain.ErrStoryNotFound
}
if s == nil || s.stories == nil {
return domain.StoryEditResult{}, domain.ErrStoryNotFound
}
return s.stories.EditStory(ctx, req)
}
func (s *Service) DeleteStories(ctx context.Context, userID int64, peer domain.Peer, ids []int, date int) (domain.StoryMutationResult, error) {
if err := s.authorizeStoryOwner(ctx, userID, peer, storyOwnerDelete); err != nil {
return domain.StoryMutationResult{}, err
}
ids, err := normalizeStoryIDsNonEmpty(ids)
if err != nil {
return domain.StoryMutationResult{}, err
}
if s == nil || s.stories == nil {
return domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...)}, nil
}
return s.stories.DeleteStories(ctx, peer, ids, date)
}
func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, ids []int, pinned bool, date int) (domain.StoryMutationResult, error) {
if err := s.authorizeStoryOwner(ctx, userID, peer, storyOwnerPin); err != nil {
return domain.StoryMutationResult{}, err
}
ids, err := normalizeStoryIDs(ids)
if err != nil {
return domain.StoryMutationResult{}, err
}
if s == nil || s.stories == nil {
return domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...)}, nil
}
return s.stories.TogglePinned(ctx, peer, ids, pinned, date)
}
func (s *Service) TogglePinnedToTop(ctx context.Context, userID int64, peer domain.Peer, ids []int) error {
if err := s.authorizeStoryOwner(ctx, userID, peer, storyOwnerPin); err != nil {
return err
}
ids, err := normalizeStoryPinnedToTopIDs(ids)
if err != nil {
return err
}
if s == nil || s.stories == nil {
return nil
}
return s.stories.TogglePinnedToTop(ctx, peer, ids)
}
func (s *Service) TogglePeerStoriesHidden(ctx context.Context, viewerUserID int64, peer domain.Peer, hidden bool) error {
if s == nil || s.stories == nil || viewerUserID == 0 {
return nil
}
return s.stories.SetPeerHidden(ctx, viewerUserID, peer, hidden)
}
func (s *Service) CanSendStory(ctx context.Context, viewerUserID int64, peer domain.Peer) (int, error) {
if err := s.authorizeStoryOwner(ctx, viewerUserID, peer, storyOwnerPost); err != nil {
return 0, err
}
return domain.DefaultStoryCanSendRemaining, nil
}
type storyOwnerAction int
const (
storyOwnerPost storyOwnerAction = iota
storyOwnerEdit
storyOwnerDelete
storyOwnerPin
)
func (s *Service) authorizeStoryOwner(ctx context.Context, userID int64, peer domain.Peer, action storyOwnerAction) error {
if userID == 0 {
return domain.ErrStoryPeerInvalid
}
switch peer.Type {
case domain.PeerTypeUser:
if peer.ID == userID {
return nil
}
case domain.PeerTypeChannel:
if peer.ID == 0 || s == nil || s.channelAccess == nil {
return domain.ErrStoryPeerInvalid
}
switch action {
case storyOwnerPost:
return s.channelAccess.CanPostStory(ctx, userID, peer.ID)
case storyOwnerEdit:
return s.channelAccess.CanEditStory(ctx, userID, peer.ID)
case storyOwnerDelete:
return s.channelAccess.CanDeleteStory(ctx, userID, peer.ID)
case storyOwnerPin:
return s.channelAccess.CanPinStory(ctx, userID, peer.ID)
}
}
return domain.ErrStoryPeerInvalid
}
func validateStoryIDs(ids []int) error {
if len(ids) > domain.MaxStoryIDs {
return domain.ErrStoryIDInvalid
}
for _, id := range ids {
if id <= 0 || id > domain.MaxStoryID {
return domain.ErrStoryIDInvalid
}
}
return nil
}
func validateStoryIDsNonEmpty(ids []int) error {
if len(ids) == 0 {
return domain.ErrStoryIDInvalid
}
return validateStoryIDs(ids)
}
func normalizeStoryIDsNonEmpty(ids []int) ([]int, error) {
if err := validateStoryIDsNonEmpty(ids); err != nil {
return nil, err
}
return normalizeStoryIDsUnchecked(ids), nil
}
func normalizeStoryIDs(ids []int) ([]int, error) {
if err := validateStoryIDs(ids); err != nil {
return nil, err
}
return normalizeStoryIDsUnchecked(ids), nil
}
func normalizeStoryPinnedToTopIDs(ids []int) ([]int, error) {
ids, err := normalizeStoryIDs(ids)
if err != nil {
return nil, err
}
if len(ids) > domain.MaxStoryPinnedToTop {
return nil, domain.ErrStoryIDInvalid
}
return ids, nil
}
func normalizeStoryIDsUnchecked(ids []int) []int {
seen := make(map[int]struct{}, len(ids))
out := make([]int, 0, len(ids))
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func validateStoryOwner(userID int64, peer domain.Peer) error {
if userID == 0 {
return domain.ErrStoryPeerInvalid
}
if peer.Type == domain.PeerTypeUser && peer.ID == userID {
return nil
}
return domain.ErrStoryPeerInvalid
}
func validateStoryListCursor(cursor domain.StoryListCursor) error {
if !cursor.Set {
return nil
}
if cursor.Date <= 0 || cursor.Peer.ID <= 0 {
return domain.ErrStoryOffsetInvalid
}
switch cursor.Peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
return nil
default:
return domain.ErrStoryOffsetInvalid
}
}
func normalizeStoryPeriod(period int) int {
if period == 0 {
return domain.DefaultStoryPeriod
}
if validStoryPeriod(period) {
return period
}
return 0
}
func validStoryPeriod(period int) bool {
switch period {
case 6 * 3600, 12 * 3600, domain.DefaultStoryPeriod, 2 * domain.DefaultStoryPeriod:
return true
default:
return false
}
}
func clampStoryLimit(limit int) int {
if limit <= 0 || limit > domain.MaxStoryListLimit {
return domain.MaxStoryListLimit
}
return limit
}
func clampStoryArchiveLimit(limit int) int {
if limit == 0 {
return 0
}
if limit < 0 || limit > domain.MaxStoryListLimit {
return domain.MaxStoryListLimit
}
return limit
}
func clampStoryInteractionLimit(limit int) int {
if limit <= 0 || limit > domain.MaxStoryInteractionListLimit {
return domain.MaxStoryInteractionListLimit
}
return limit
}
func clampStoryPrivacyFanoutLimit(limit int) int {
if limit <= 0 || limit > domain.MaxStoryPrivacyFanoutTargets {
return domain.MaxStoryPrivacyFanoutTargets
}
return limit
}
func normalizeStoryViewQuery(query string) string {
query = strings.TrimSpace(query)
runes := []rune(query)
if len(runes) > domain.MaxStoryViewQueryLength {
return string(runes[:domain.MaxStoryViewQueryLength])
}
return query
}