3422 lines
105 KiB
Go
3422 lines
105 KiB
Go
package rpc
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"github.com/iamxvbaba/td/tg"
|
||
"github.com/iamxvbaba/td/tgerr"
|
||
"go.uber.org/zap"
|
||
|
||
"github.com/iamxvbaba/td/tlprofile"
|
||
"telesrv/internal/compat/tdesktop"
|
||
"telesrv/internal/domain"
|
||
)
|
||
|
||
const (
|
||
maxStoryAlbumTitleLength = 12
|
||
maxStoryAllStoriesStateLength = 128
|
||
maxStorySearchPostsOffsetLength = 64
|
||
minStoryWeatherTemperatureC = -274
|
||
maxStoryWeatherTemperatureC = 1000000
|
||
storyStealthFuturePeriodSeconds = 25 * 60
|
||
storyStealthCooldownSeconds = 3 * 60 * 60
|
||
)
|
||
|
||
// registerStories 注册 TDesktop/Android 已发现的 stories.* RPC。
|
||
func (r *Router) registerStories(d *tlprofile.Dispatcher) {
|
||
registerRPC[*tg.StoriesGetAllStoriesRequest](d, tlprofile.SemanticMethodStoriesGetAllStories, func(ctx context.Context, layerRequest *tg.StoriesGetAllStoriesRequest) (any, error) {
|
||
return r.onStoriesGetAllStories(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesGetPeerStoriesRequest](d, tlprofile.SemanticMethodStoriesGetPeerStories, func(ctx context.Context, layerRequest *tg.StoriesGetPeerStoriesRequest) (any, error) {
|
||
return r.onStoriesGetPeerStories(ctx, layerRequest.
|
||
Peer)
|
||
})
|
||
registerRPC[*tg.StoriesGetStoriesByIDRequest](d, tlprofile.SemanticMethodStoriesGetStoriesByID, func(ctx context.Context, layerRequest *tg.StoriesGetStoriesByIDRequest) (any, error) {
|
||
return r.onStoriesGetStoriesByID(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesGetStoriesArchiveRequest](d, tlprofile.SemanticMethodStoriesGetStoriesArchive, func(ctx context.Context, layerRequest *tg.StoriesGetStoriesArchiveRequest) (any, error) {
|
||
return r.onStoriesGetStoriesArchive(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesGetPinnedStoriesRequest](d, tlprofile.SemanticMethodStoriesGetPinnedStories, func(ctx context.Context, layerRequest *tg.StoriesGetPinnedStoriesRequest) (any, error) {
|
||
return r.onStoriesGetPinnedStories(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesExportStoryLinkRequest](d, tlprofile.SemanticMethodStoriesExportStoryLink, func(ctx context.Context, layerRequest *tg.StoriesExportStoryLinkRequest) (any, error) {
|
||
return r.onStoriesExportStoryLink(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesReportRequest](d, tlprofile.SemanticMethodStoriesReport, func(ctx context.Context, layerRequest *tg.StoriesReportRequest) (any, error) {
|
||
return r.onStoriesReport(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesActivateStealthModeRequest](d, tlprofile.SemanticMethodStoriesActivateStealthMode, func(ctx context.Context, layerRequest *tg.StoriesActivateStealthModeRequest) (any, error) {
|
||
return r.onStoriesActivateStealthMode(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesSearchPostsRequest](d, tlprofile.SemanticMethodStoriesSearchPosts, func(ctx context.Context, layerRequest *tg.StoriesSearchPostsRequest) (any, error) {
|
||
return r.onStoriesSearchPosts(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesSendStoryRequest](d, tlprofile.SemanticMethodStoriesSendStory, func(ctx context.Context, layerRequest *tg.StoriesSendStoryRequest) (any, error) {
|
||
return r.onStoriesSendStory(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesEditStoryRequest](d, tlprofile.SemanticMethodStoriesEditStory, func(ctx context.Context, layerRequest *tg.StoriesEditStoryRequest) (any, error) {
|
||
return r.onStoriesEditStory(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesDeleteStoriesRequest](d, tlprofile.SemanticMethodStoriesDeleteStories, func(ctx context.Context, layerRequest *tg.StoriesDeleteStoriesRequest) (any, error) {
|
||
return r.onStoriesDeleteStories(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesTogglePinnedRequest](d, tlprofile.SemanticMethodStoriesTogglePinned, func(ctx context.Context, layerRequest *tg.StoriesTogglePinnedRequest) (any, error) {
|
||
return r.onStoriesTogglePinned(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesTogglePinnedToTopRequest](d, tlprofile.SemanticMethodStoriesTogglePinnedToTop, func(ctx context.Context, layerRequest *tg.StoriesTogglePinnedToTopRequest) (any, error) {
|
||
return r.onStoriesTogglePinnedToTop(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesToggleAllStoriesHiddenRequest](d, tlprofile.SemanticMethodStoriesToggleAllStoriesHidden, func(ctx context.Context, layerRequest *tg.StoriesToggleAllStoriesHiddenRequest) (any, error) {
|
||
return r.onStoriesToggleAllStoriesHidden(ctx, layerRequest.
|
||
Hidden)
|
||
})
|
||
registerRPC[*tg.StoriesCreateAlbumRequest](d, tlprofile.SemanticMethodStoriesCreateAlbum, func(ctx context.Context, layerRequest *tg.StoriesCreateAlbumRequest) (any, error) {
|
||
return r.onStoriesCreateAlbum(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesUpdateAlbumRequest](d, tlprofile.SemanticMethodStoriesUpdateAlbum, func(ctx context.Context, layerRequest *tg.StoriesUpdateAlbumRequest) (any, error) {
|
||
return r.onStoriesUpdateAlbum(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesReorderAlbumsRequest](d, tlprofile.SemanticMethodStoriesReorderAlbums, func(ctx context.Context, layerRequest *tg.StoriesReorderAlbumsRequest) (any, error) {
|
||
return r.onStoriesReorderAlbums(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesDeleteAlbumRequest](d, tlprofile.SemanticMethodStoriesDeleteAlbum, func(ctx context.Context, layerRequest *tg.StoriesDeleteAlbumRequest) (any, error) {
|
||
return r.onStoriesDeleteAlbum(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesGetAlbumsRequest](d, tlprofile.SemanticMethodStoriesGetAlbums, func(ctx context.Context, layerRequest *tg.StoriesGetAlbumsRequest) (any, error) {
|
||
return r.onStoriesGetAlbums(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesGetAlbumStoriesRequest](d, tlprofile.SemanticMethodStoriesGetAlbumStories, func(ctx context.Context, layerRequest *tg.StoriesGetAlbumStoriesRequest) (any, error) {
|
||
return r.onStoriesGetAlbumStories(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesGetAllReadPeerStoriesRequest](d, tlprofile.SemanticMethodStoriesGetAllReadPeerStories, func(ctx context.Context, layerRequest *tg.StoriesGetAllReadPeerStoriesRequest) (any, error) {
|
||
return r.onStoriesGetAllReadPeerStories(ctx)
|
||
})
|
||
registerRPC[*tg.StoriesGetPeerMaxIDsRequest](d, tlprofile.SemanticMethodStoriesGetPeerMaxIDs, func(ctx context.Context, layerRequest *tg.StoriesGetPeerMaxIDsRequest) (any, error) {
|
||
return r.onStoriesGetPeerMaxIDs(ctx, layerRequest.
|
||
ID)
|
||
})
|
||
registerRPC[*tg.StoriesReadStoriesRequest](d, tlprofile.SemanticMethodStoriesReadStories, func(ctx context.Context, layerRequest *tg.StoriesReadStoriesRequest) (any, error) {
|
||
return r.onStoriesReadStories(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesIncrementStoryViewsRequest](d, tlprofile.SemanticMethodStoriesIncrementStoryViews, func(ctx context.Context, layerRequest *tg.StoriesIncrementStoryViewsRequest) (any, error) {
|
||
return r.onStoriesIncrementStoryViews(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesGetStoriesViewsRequest](d, tlprofile.SemanticMethodStoriesGetStoriesViews, func(ctx context.Context, layerRequest *tg.StoriesGetStoriesViewsRequest) (any, error) {
|
||
return r.onStoriesGetStoriesViews(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesGetStoryViewsListRequest](d, tlprofile.SemanticMethodStoriesGetStoryViewsList, func(ctx context.Context, layerRequest *tg.StoriesGetStoryViewsListRequest) (any, error) {
|
||
return r.onStoriesGetStoryViewsList(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesGetStoryReactionsListRequest](d, tlprofile.SemanticMethodStoriesGetStoryReactionsList, func(ctx context.Context, layerRequest *tg.StoriesGetStoryReactionsListRequest) (any, error) {
|
||
return r.onStoriesGetStoryReactionsList(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesTogglePeerStoriesHiddenRequest](d, tlprofile.SemanticMethodStoriesTogglePeerStoriesHidden, func(ctx context.Context, layerRequest *tg.StoriesTogglePeerStoriesHiddenRequest) (any, error) {
|
||
return r.onStoriesTogglePeerStoriesHidden(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesCanSendStoryRequest](d, tlprofile.SemanticMethodStoriesCanSendStory, func(ctx context.Context, layerRequest *tg.StoriesCanSendStoryRequest) (any, error) {
|
||
return r.onStoriesCanSendStory(ctx, layerRequest.
|
||
Peer)
|
||
})
|
||
registerRPC[*tg.StoriesGetChatsToSendRequest](d, tlprofile.SemanticMethodStoriesGetChatsToSend, func(ctx context.Context, layerRequest *tg.StoriesGetChatsToSendRequest) (any, error) {
|
||
return r.onStoriesGetChatsToSend(ctx)
|
||
})
|
||
registerRPC[*tg.StoriesSendReactionRequest](d, tlprofile.SemanticMethodStoriesSendReaction, func(ctx context.Context, layerRequest *tg.StoriesSendReactionRequest) (any, error) {
|
||
return r.onStoriesSendReaction(ctx, layerRequest)
|
||
})
|
||
registerRPC[*tg.StoriesStartLiveRequest](d, tlprofile.SemanticMethodStoriesStartLive, func(ctx context.Context, layerRequest *tg.StoriesStartLiveRequest) (any, error) {
|
||
return r.onStoriesStartLive(ctx, layerRequest)
|
||
})
|
||
}
|
||
|
||
func (r *Router) onStoriesGetAllStories(ctx context.Context, req *tg.StoriesGetAllStoriesRequest) (tg.StoriesAllStoriesClass, error) {
|
||
if err := validateStoriesGetAllStoriesRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
hidden := storyAllStoriesHidden(req)
|
||
requestState, hasRequestState := storyAllStoriesRequestState(req)
|
||
next := storyAllStoriesNext(req)
|
||
now := int(r.clock.Now().Unix())
|
||
var cursor domain.StoryListCursor
|
||
if next {
|
||
if digest, ok := storyAllStoriesDigestFromCompleteState(requestState, hidden); ok {
|
||
list := domain.StoryList{Hidden: hidden, Count: digest.Count, State: requestState}
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return tgStoriesAllStories(userID, list), nil
|
||
}
|
||
return r.tgStoriesAllStories(ctx, userID, list), nil
|
||
}
|
||
cursor, err = storyAllStoriesCursorFromState(requestState, hidden)
|
||
if err != nil {
|
||
return nil, offsetInvalidErr()
|
||
}
|
||
}
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
list := domain.StoryList{Hidden: hidden, State: storyAllStoriesDigestState(hidden, domain.StoryListDigest{})}
|
||
if hasRequestState && !next && requestState == list.State {
|
||
return tgStoriesAllStoriesNotModified(list.State), nil
|
||
}
|
||
return tgStoriesAllStories(userID, list), nil
|
||
}
|
||
var (
|
||
completeState string
|
||
haveDigest bool
|
||
)
|
||
if hasRequestState && !next && storyAllStoriesCompleteState(requestState) {
|
||
digest, err := r.deps.Stories.GetAllStoriesDigest(ctx, userID, hidden, now)
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
completeState = storyAllStoriesDigestState(hidden, digest)
|
||
haveDigest = true
|
||
if requestState == completeState {
|
||
return tgStoriesAllStoriesNotModified(completeState), nil
|
||
}
|
||
}
|
||
list, err := r.deps.Stories.GetAllStoriesPage(ctx, userID, hidden, now, cursor, domain.MaxStoryListLimit)
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
if list.HasMore {
|
||
list.State = storyAllStoriesCursorStateFromList(hidden, list)
|
||
} else {
|
||
if !haveDigest {
|
||
digest, err := r.deps.Stories.GetAllStoriesDigest(ctx, userID, hidden, now)
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
completeState = storyAllStoriesDigestState(hidden, digest)
|
||
}
|
||
list.State = completeState
|
||
}
|
||
list.Hidden = hidden
|
||
return r.tgStoriesAllStories(ctx, userID, list), nil
|
||
}
|
||
|
||
func validateStoriesGetAllStoriesRequest(req *tg.StoriesGetAllStoriesRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
state, hasState := storyAllStoriesRequestState(req)
|
||
if len(state) > maxStoryAllStoriesStateLength {
|
||
return offsetInvalidErr()
|
||
}
|
||
next := storyAllStoriesNext(req)
|
||
hidden := storyAllStoriesHidden(req)
|
||
if next {
|
||
if !hasState || state == "" {
|
||
return offsetInvalidErr()
|
||
}
|
||
if storyAllStoriesCompleteState(state) {
|
||
if !storyAllStoriesCompleteStateForHidden(state, hidden) {
|
||
return offsetInvalidErr()
|
||
}
|
||
return nil
|
||
}
|
||
if !storyAllStoriesCursorStateToken(state) {
|
||
return offsetInvalidErr()
|
||
}
|
||
if _, err := storyAllStoriesCursorFromState(state, hidden); err != nil {
|
||
return offsetInvalidErr()
|
||
}
|
||
return nil
|
||
}
|
||
if !hasState {
|
||
return nil
|
||
}
|
||
if state == "" || !storyAllStoriesCompleteStateForHidden(state, hidden) {
|
||
return offsetInvalidErr()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func storyAllStoriesRequestState(req *tg.StoriesGetAllStoriesRequest) (string, bool) {
|
||
state, hasState := req.GetState()
|
||
if !hasState && req.State != "" {
|
||
state, hasState = req.State, true
|
||
}
|
||
return state, hasState
|
||
}
|
||
|
||
func storyAllStoriesNext(req *tg.StoriesGetAllStoriesRequest) bool {
|
||
return req != nil && (req.Next || req.GetNext())
|
||
}
|
||
|
||
func storyAllStoriesHidden(req *tg.StoriesGetAllStoriesRequest) bool {
|
||
return req != nil && (req.Hidden || req.GetHidden())
|
||
}
|
||
|
||
func storyAllStoriesCompleteState(state string) bool {
|
||
return strings.HasPrefix(state, "ts1:")
|
||
}
|
||
|
||
func storyAllStoriesDigestFromCompleteState(state string, hidden bool) (domain.StoryListDigest, bool) {
|
||
if !storyAllStoriesCompleteStateForHidden(state, hidden) {
|
||
return domain.StoryListDigest{}, false
|
||
}
|
||
parts := strings.Split(state, ":")
|
||
count, err := strconv.Atoi(parts[2])
|
||
if err != nil || count < 0 {
|
||
return domain.StoryListDigest{}, false
|
||
}
|
||
hash, err := strconv.ParseUint(parts[3], 16, 64)
|
||
if err != nil {
|
||
return domain.StoryListDigest{}, false
|
||
}
|
||
return domain.StoryListDigest{Count: count, Hash: hash}, true
|
||
}
|
||
|
||
func storyAllStoriesCompleteStateForHidden(state string, hidden bool) bool {
|
||
parts := strings.Split(state, ":")
|
||
if len(parts) != 4 || parts[0] != "ts1" {
|
||
return false
|
||
}
|
||
switch parts[1] {
|
||
case "0":
|
||
if hidden {
|
||
return false
|
||
}
|
||
case "1":
|
||
if !hidden {
|
||
return false
|
||
}
|
||
default:
|
||
return false
|
||
}
|
||
count, err := strconv.Atoi(parts[2])
|
||
if err != nil || count < 0 {
|
||
return false
|
||
}
|
||
if len(parts[3]) != 16 {
|
||
return false
|
||
}
|
||
_, err = strconv.ParseUint(parts[3], 16, 64)
|
||
return err == nil
|
||
}
|
||
|
||
func storyAllStoriesDigestState(hidden bool, digest domain.StoryListDigest) string {
|
||
hiddenBit := "0"
|
||
if hidden {
|
||
hiddenBit = "1"
|
||
}
|
||
return fmt.Sprintf("ts1:%s:%d:%016x", hiddenBit, digest.Count, digest.Hash)
|
||
}
|
||
|
||
func storyAllStoriesCursorStateFromList(hidden bool, list domain.StoryList) string {
|
||
if cursor, ok := storyAllStoriesCursorFromList(list); ok {
|
||
return storyAllStoriesCursorState(hidden, cursor)
|
||
}
|
||
return storyAllStoriesDigestState(hidden, domain.DigestStoryPeerList(list.Peers))
|
||
}
|
||
|
||
func storyAllStoriesCursorFromList(list domain.StoryList) (domain.StoryListCursor, bool) {
|
||
if len(list.Peers) == 0 {
|
||
return domain.StoryListCursor{}, false
|
||
}
|
||
peer := list.Peers[len(list.Peers)-1]
|
||
maxDate := 0
|
||
for _, story := range peer.Stories {
|
||
if story.Date > maxDate {
|
||
maxDate = story.Date
|
||
}
|
||
}
|
||
if maxDate <= 0 {
|
||
return domain.StoryListCursor{}, false
|
||
}
|
||
return domain.StoryListCursor{Set: true, Date: maxDate, Peer: peer.Peer}, true
|
||
}
|
||
|
||
func storyAllStoriesCursorState(hidden bool, cursor domain.StoryListCursor) string {
|
||
hiddenBit := "0"
|
||
if hidden {
|
||
hiddenBit = "1"
|
||
}
|
||
return fmt.Sprintf("tsc1:%s:%d:%s:%d", hiddenBit, cursor.Date, cursor.Peer.Type, cursor.Peer.ID)
|
||
}
|
||
|
||
func storyAllStoriesCursorStateToken(state string) bool {
|
||
return strings.HasPrefix(state, "tsc1:")
|
||
}
|
||
|
||
func storyAllStoriesCursorFromState(state string, hidden bool) (domain.StoryListCursor, error) {
|
||
parts := strings.Split(state, ":")
|
||
if len(parts) != 5 || parts[0] != "tsc1" {
|
||
return domain.StoryListCursor{}, domain.ErrStoryOffsetInvalid
|
||
}
|
||
switch parts[1] {
|
||
case "0":
|
||
if hidden {
|
||
return domain.StoryListCursor{}, domain.ErrStoryOffsetInvalid
|
||
}
|
||
case "1":
|
||
if !hidden {
|
||
return domain.StoryListCursor{}, domain.ErrStoryOffsetInvalid
|
||
}
|
||
default:
|
||
return domain.StoryListCursor{}, domain.ErrStoryOffsetInvalid
|
||
}
|
||
date, err := strconv.Atoi(parts[2])
|
||
if err != nil || date <= 0 {
|
||
return domain.StoryListCursor{}, domain.ErrStoryOffsetInvalid
|
||
}
|
||
peer := domain.Peer{Type: domain.PeerType(parts[3])}
|
||
switch peer.Type {
|
||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||
default:
|
||
return domain.StoryListCursor{}, domain.ErrStoryOffsetInvalid
|
||
}
|
||
peerID, err := strconv.ParseInt(parts[4], 10, 64)
|
||
if err != nil || peerID <= 0 {
|
||
return domain.StoryListCursor{}, domain.ErrStoryOffsetInvalid
|
||
}
|
||
peer.ID = peerID
|
||
return domain.StoryListCursor{Set: true, Date: date, Peer: peer}, nil
|
||
}
|
||
|
||
func (r *Router) onStoriesGetPeerStories(ctx context.Context, peer tg.InputPeerClass) (*tg.StoriesPeerStories, error) {
|
||
if err := validateStoriesDirectInputPeer(peer); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
domainPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return r.tgStoriesPeerStories(ctx, userID, domain.PeerStories{Peer: domainPeer}), nil
|
||
}
|
||
stories, err := r.deps.Stories.GetPeerStories(ctx, userID, domainPeer, int(r.clock.Now().Unix()))
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
return r.tgStoriesPeerStories(ctx, userID, stories), nil
|
||
}
|
||
|
||
func (r *Router) onStoriesGetStoriesByID(ctx context.Context, req *tg.StoriesGetStoriesByIDRequest) (*tg.StoriesStories, error) {
|
||
if err := validateStoriesGetStoriesByIDRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return &tg.StoriesStories{}, nil
|
||
}
|
||
list, err := r.deps.Stories.GetStoriesByID(ctx, userID, peer, uniqueStoryIDs(req.ID), int(r.clock.Now().Unix()))
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
return r.tgStoriesStories(ctx, userID, list), nil
|
||
}
|
||
|
||
func validateStoriesGetStoriesByIDRequest(req *tg.StoriesGetStoriesByIDRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if len(req.ID) == 0 {
|
||
return storyIDEmptyErr()
|
||
}
|
||
if err := validateStoryIDSlice(req.ID); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Router) onStoriesGetChatsToSend(ctx context.Context) (tg.MessagesChatsClass, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if r.deps.Channels == nil || userID == 0 {
|
||
return r.applyStoryMaxIDsToChats(ctx, userID, &tg.MessagesChats{Chats: []tg.ChatClass{}}), nil
|
||
}
|
||
channels, err := r.deps.Channels.ListStoryPostableChannels(ctx, userID)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
ids := make([]int64, 0, len(channels))
|
||
for _, channel := range channels {
|
||
if channel.ID != 0 {
|
||
ids = append(ids, channel.ID)
|
||
}
|
||
}
|
||
views, err := r.deps.Channels.GetChannels(ctx, userID, ids)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
chats := make([]tg.ChatClass, 0, len(views))
|
||
for _, view := range views {
|
||
if view.Forbidden {
|
||
continue
|
||
}
|
||
chats = append(chats, tgChannelChatForView(userID, view))
|
||
}
|
||
return r.applyStoryMaxIDsToChats(ctx, userID, &tg.MessagesChats{Chats: chats}), nil
|
||
}
|
||
|
||
func (r *Router) onStoriesGetStoriesArchive(ctx context.Context, req *tg.StoriesGetStoriesArchiveRequest) (*tg.StoriesStories, error) {
|
||
if err := validateStoriesGetStoriesArchiveRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return tdesktop.StoriesArchive(), nil
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
list, err := r.deps.Stories.GetStoriesArchive(ctx, userID, peer, req.OffsetID, req.Limit, int(r.clock.Now().Unix()))
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
return r.tgStoriesStories(ctx, userID, list), nil
|
||
}
|
||
|
||
func validateStoriesGetStoriesArchiveRequest(req *tg.StoriesGetStoriesArchiveRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if err := validateStoryPageBounds(req.OffsetID, req.Limit); err != nil {
|
||
return err
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func (r *Router) onStoriesGetPinnedStories(ctx context.Context, req *tg.StoriesGetPinnedStoriesRequest) (*tg.StoriesStories, error) {
|
||
if err := validateStoriesGetPinnedStoriesRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return tdesktop.PinnedStories(), nil
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
list, err := r.cachedPinnedStories(ctx, userID, peer, req.OffsetID, req.Limit)
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
return r.tgStoriesStories(ctx, userID, list), nil
|
||
}
|
||
|
||
func (r *Router) cachedPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit int) (domain.StoryList, error) {
|
||
if r.storyPinnedListCache == nil {
|
||
return r.deps.Stories.GetPinnedStories(ctx, viewerUserID, peer, offsetID, limit, int(r.clock.Now().Unix()))
|
||
}
|
||
key := storyPinnedStoriesKey(viewerUserID, peer, offsetID, limit)
|
||
return r.storyPinnedListCache.getOrLoad(ctx, key, func() (domain.StoryList, error) {
|
||
return r.deps.Stories.GetPinnedStories(ctx, viewerUserID, peer, key.offsetID, key.limit, int(r.clock.Now().Unix()))
|
||
})
|
||
}
|
||
|
||
func validateStoriesGetPinnedStoriesRequest(req *tg.StoriesGetPinnedStoriesRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if err := validateStoryPageBounds(req.OffsetID, req.Limit); err != nil {
|
||
return err
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func (r *Router) onStoriesExportStoryLink(ctx context.Context, req *tg.StoriesExportStoryLinkRequest) (*tg.ExportedStoryLink, error) {
|
||
if err := validateStoriesExportStoryLinkRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if r.deps.Stories != nil && userID != 0 {
|
||
list, err := r.deps.Stories.GetStoriesByID(ctx, userID, peer, []int{req.ID}, int(r.clock.Now().Unix()))
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
if len(list.Stories) == 0 {
|
||
return nil, storyIDInvalidErr()
|
||
}
|
||
}
|
||
return &tg.ExportedStoryLink{Link: r.storyExportLink(peer, req.ID)}, nil
|
||
}
|
||
|
||
func validateStoriesExportStoryLinkRequest(req *tg.StoriesExportStoryLinkRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if req.ID <= 0 || req.ID > domain.MaxStoryID {
|
||
return storyIDInvalidErr()
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func (r *Router) onStoriesReport(ctx context.Context, req *tg.StoriesReportRequest) (tg.ReportResultClass, error) {
|
||
if err := validateStoriesReportRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result, err := reportResultForOption(string(req.Option))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if _, final := result.(*tg.ReportResultReported); !final {
|
||
// Option discovery is non-mutating, but still follows the peer/access
|
||
// validation above so malformed targets cannot bypass normal errors.
|
||
return result, nil
|
||
}
|
||
reason, ok := moderationReasonForReportOption(string(req.Option))
|
||
if !ok {
|
||
return nil, tgerr.New(400, "OPTION_INVALID")
|
||
}
|
||
if r.deps.Moderation == nil {
|
||
return nil, internalErr()
|
||
}
|
||
if _, _, err := r.deps.Moderation.ReportStories(ctx, domain.ModerationStoryReportRequest{
|
||
ReporterUserID: userID, Target: peer, StoryIDs: uniqueStoryIDs(req.ID),
|
||
Reason: reason, Option: string(req.Option), Comment: req.Message,
|
||
CreatedAt: r.clock.Now(),
|
||
}); err != nil {
|
||
if errors.Is(err, domain.ErrModerationEvidenceNotFound) {
|
||
return nil, storyIDInvalidErr()
|
||
}
|
||
return nil, moderationReportError(err)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func validateStoriesReportRequest(req *tg.StoriesReportRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if len(req.ID) == 0 {
|
||
return storyIDEmptyErr()
|
||
}
|
||
if len(req.ID) > domain.MaxStoryIDs || len(req.Option) > maxReportOptionLength || utf8.RuneCountInString(req.Message) > maxReportCommentLength {
|
||
return limitInvalidErr()
|
||
}
|
||
if err := validateStoryIDSlice(req.ID); err != nil {
|
||
return err
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func (r *Router) onStoriesActivateStealthMode(ctx context.Context, req *tg.StoriesActivateStealthModeRequest) (tg.UpdatesClass, error) {
|
||
if req == nil {
|
||
return nil, inputRequestInvalidErr()
|
||
}
|
||
past, future := storyStealthPastRequested(req), storyStealthFutureRequested(req)
|
||
if !past && !future {
|
||
return nil, inputRequestInvalidErr()
|
||
}
|
||
if _, _, err := r.currentUserID(ctx); err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
return tgStoryStealthModeUpdates(int(r.clock.Now().Unix()), past, future), nil
|
||
}
|
||
|
||
func storyStealthPastRequested(req *tg.StoriesActivateStealthModeRequest) bool {
|
||
return req != nil && (req.Past || req.GetPast())
|
||
}
|
||
|
||
func storyStealthFutureRequested(req *tg.StoriesActivateStealthModeRequest) bool {
|
||
return req != nil && (req.Future || req.GetFuture())
|
||
}
|
||
|
||
func (r *Router) onStoriesSearchPosts(ctx context.Context, req *tg.StoriesSearchPostsRequest) (*tg.StoriesFoundStories, error) {
|
||
if err := validateStoriesSearchPostsRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if peer, ok := req.GetPeer(); ok {
|
||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return &tg.StoriesFoundStories{
|
||
Stories: []tg.FoundStory{},
|
||
Chats: []tg.ChatClass{},
|
||
Users: []tg.UserClass{},
|
||
}, nil
|
||
}
|
||
|
||
func validateStoriesSearchPostsRequest(req *tg.StoriesSearchPostsRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if req.Limit < 0 || req.Limit > domain.MaxStoryListLimit {
|
||
return limitInvalidErr()
|
||
}
|
||
if len(req.Offset) > maxStorySearchPostsOffsetLength {
|
||
return offsetInvalidErr()
|
||
}
|
||
hashtag, hasHashtag := storySearchPostsHashtag(req)
|
||
area, hasArea := storySearchPostsArea(req)
|
||
if hasHashtag == hasArea {
|
||
return searchQueryEmptyErr()
|
||
}
|
||
if hasHashtag {
|
||
hashtag, err := normalizeStorySearchHashtag(hashtag)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if hashtag == "" {
|
||
return searchQueryEmptyErr()
|
||
}
|
||
if utf8.RuneCountInString(hashtag) > maxChannelSearchPostsQuery {
|
||
return limitInvalidErr()
|
||
}
|
||
return validateStoriesSearchPostsPeer(req)
|
||
}
|
||
if err := validateStorySearchArea(area); err != nil {
|
||
return err
|
||
}
|
||
return validateStoriesSearchPostsPeer(req)
|
||
}
|
||
|
||
func validateStoriesSearchPostsPeer(req *tg.StoriesSearchPostsRequest) error {
|
||
if peer, ok := req.GetPeer(); ok {
|
||
return validateStoriesDirectInputPeer(peer)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func storySearchPostsHashtag(req *tg.StoriesSearchPostsRequest) (string, bool) {
|
||
hashtag, hasHashtag := req.GetHashtag()
|
||
if !hasHashtag && req.Hashtag != "" {
|
||
hashtag, hasHashtag = req.Hashtag, true
|
||
}
|
||
return hashtag, hasHashtag
|
||
}
|
||
|
||
func storySearchPostsArea(req *tg.StoriesSearchPostsRequest) (tg.MediaAreaClass, bool) {
|
||
area, hasArea := req.GetArea()
|
||
if !hasArea && req.Area != nil {
|
||
area, hasArea = req.Area, true
|
||
}
|
||
return area, hasArea
|
||
}
|
||
|
||
func normalizeStorySearchHashtag(hashtag string) (string, error) {
|
||
hashtag = strings.TrimSpace(hashtag)
|
||
if hashtag == "" {
|
||
return "", nil
|
||
}
|
||
if strings.HasPrefix(hashtag, "#") || strings.HasPrefix(hashtag, "$") {
|
||
hashtag = strings.TrimSpace(hashtag[1:])
|
||
}
|
||
if hashtag == "" {
|
||
return "", nil
|
||
}
|
||
if strings.ContainsAny(hashtag, "#$") {
|
||
return "", limitInvalidErr()
|
||
}
|
||
return hashtag, nil
|
||
}
|
||
|
||
func validateStorySearchArea(area tg.MediaAreaClass) error {
|
||
if storyMediaAreaClassNil(area) {
|
||
return mediaInvalidErr()
|
||
}
|
||
switch typed := area.(type) {
|
||
case *tg.MediaAreaGeoPoint:
|
||
if _, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates); err != nil {
|
||
return err
|
||
}
|
||
if _, err := domainStoryGeoPointFromTL(typed.Geo); err != nil {
|
||
return err
|
||
}
|
||
if _, err := domainStoryGeoPointAddressFromTL(typed); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
case *tg.MediaAreaVenue:
|
||
if _, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates); err != nil {
|
||
return err
|
||
}
|
||
_, err := domainStoryVenueFromTL(typed)
|
||
return err
|
||
default:
|
||
return mediaInvalidErr()
|
||
}
|
||
}
|
||
|
||
func (r *Router) onStoriesGetAlbums(ctx context.Context, req *tg.StoriesGetAlbumsRequest) (tg.StoriesAlbumsClass, error) {
|
||
if err := validateStoriesGetAlbumsRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
||
return nil, err
|
||
}
|
||
return tdesktop.StoryAlbums(), nil
|
||
}
|
||
|
||
func validateStoriesGetAlbumsRequest(req *tg.StoriesGetAlbumsRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func (r *Router) onStoriesGetAlbumStories(ctx context.Context, req *tg.StoriesGetAlbumStoriesRequest) (*tg.StoriesStories, error) {
|
||
if err := validateStoriesGetAlbumStoriesRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
||
return nil, err
|
||
}
|
||
return r.tgStoriesStories(ctx, userID, domain.StoryList{}), nil
|
||
}
|
||
|
||
func validateStoriesGetAlbumStoriesRequest(req *tg.StoriesGetAlbumStoriesRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if err := validateStoryAlbumID(req.AlbumID); err != nil {
|
||
return err
|
||
}
|
||
if req.Offset < 0 || req.Offset > domain.MaxStoryAlbumOffset {
|
||
return offsetInvalidErr()
|
||
}
|
||
if req.Limit < 0 || req.Limit > domain.MaxStoryListLimit {
|
||
return limitInvalidErr()
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func (r *Router) onStoriesToggleAllStoriesHidden(ctx context.Context, hidden bool) (bool, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return false, internalErr()
|
||
}
|
||
r.invalidateStoryProjectionCacheForViewer(userID)
|
||
return true, nil
|
||
}
|
||
|
||
func (r *Router) onStoriesStartLive(ctx context.Context, req *tg.StoriesStartLiveRequest) (tg.UpdatesClass, error) {
|
||
if err := validateStoriesStartLiveRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if _, err := r.storyVisibilityFromInputPrivacyRules(ctx, userID, req.PrivacyRules); err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
|
||
return nil, err
|
||
}
|
||
return nil, methodInvalidErr()
|
||
}
|
||
|
||
func validateStoriesStartLiveRequest(req *tg.StoriesStartLiveRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if req.RandomID == 0 {
|
||
return randomIDEmptyErr()
|
||
}
|
||
if err := validateStoryCaptionEntities(req.Caption, req.Entities); err != nil {
|
||
return err
|
||
}
|
||
if stars, ok := req.GetSendPaidMessagesStars(); ok {
|
||
if stars < 0 || stars > maxChannelPaidMessageStars {
|
||
return starsAmountInvalidErr()
|
||
}
|
||
}
|
||
if err := validateStoryInputPrivacyRulesShape(req.PrivacyRules); err != nil {
|
||
return err
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func validateStoryInputPrivacyRulesShape(rules []tg.InputPrivacyRuleClass) error {
|
||
for _, rule := range rules {
|
||
if inputPrivacyRuleClassNil(rule) {
|
||
return privacyValueInvalidErr()
|
||
}
|
||
switch typed := rule.(type) {
|
||
case *tg.InputPrivacyValueAllowUsers:
|
||
for _, user := range typed.Users {
|
||
if inputUserClassNil(user) {
|
||
return userIDInvalidErr()
|
||
}
|
||
}
|
||
case *tg.InputPrivacyValueDisallowUsers:
|
||
for _, user := range typed.Users {
|
||
if inputUserClassNil(user) {
|
||
return userIDInvalidErr()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Router) onStoriesCreateAlbum(ctx context.Context, req *tg.StoriesCreateAlbumRequest) (*tg.StoryAlbum, error) {
|
||
if err := validateStoriesCreateAlbumRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
if _, _, err := r.storyAlbumWriteScope(ctx, req.Peer); err != nil {
|
||
return nil, err
|
||
}
|
||
return nil, methodInvalidErr()
|
||
}
|
||
|
||
func (r *Router) onStoriesUpdateAlbum(ctx context.Context, req *tg.StoriesUpdateAlbumRequest) (*tg.StoryAlbum, error) {
|
||
if err := validateStoriesUpdateAlbumRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
if _, _, err := r.storyAlbumWriteScope(ctx, req.Peer); err != nil {
|
||
return nil, err
|
||
}
|
||
return nil, methodInvalidErr()
|
||
}
|
||
|
||
func validateStoriesCreateAlbumRequest(req *tg.StoriesCreateAlbumRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if err := validateStoryAlbumTitle(req.Title); err != nil {
|
||
return err
|
||
}
|
||
if err := validateStoryAlbumStoryIDSlice(req.Stories); err != nil {
|
||
return err
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func validateStoriesUpdateAlbumRequest(req *tg.StoriesUpdateAlbumRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if err := validateStoryAlbumID(req.AlbumID); err != nil {
|
||
return err
|
||
}
|
||
if !storyUpdateAlbumHasMutation(req) {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if title, ok := req.GetTitle(); ok {
|
||
if err := validateStoryAlbumTitle(title); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if ids, ok := req.GetDeleteStories(); ok {
|
||
if err := validateStoryAlbumUpdateStoryIDSlice(ids); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if ids, ok := req.GetAddStories(); ok {
|
||
if err := validateStoryAlbumUpdateStoryIDSlice(ids); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if ids, ok := req.GetOrder(); ok {
|
||
if err := validateStoryAlbumUpdateStoryIDSlice(ids); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func storyUpdateAlbumHasMutation(req *tg.StoriesUpdateAlbumRequest) bool {
|
||
if req == nil {
|
||
return false
|
||
}
|
||
if _, ok := req.GetTitle(); ok {
|
||
return true
|
||
}
|
||
if _, ok := req.GetDeleteStories(); ok {
|
||
return true
|
||
}
|
||
if _, ok := req.GetAddStories(); ok {
|
||
return true
|
||
}
|
||
if _, ok := req.GetOrder(); ok {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (r *Router) onStoriesReorderAlbums(ctx context.Context, req *tg.StoriesReorderAlbumsRequest) (bool, error) {
|
||
if err := validateStoriesReorderAlbumsRequest(req); err != nil {
|
||
return false, err
|
||
}
|
||
if _, _, err := r.storyAlbumWriteScope(ctx, req.Peer); err != nil {
|
||
return false, err
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
func validateStoriesReorderAlbumsRequest(req *tg.StoriesReorderAlbumsRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if err := validateStoryAlbumIDSlice(req.Order); err != nil {
|
||
return err
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func (r *Router) onStoriesDeleteAlbum(ctx context.Context, req *tg.StoriesDeleteAlbumRequest) (bool, error) {
|
||
if err := validateStoriesDeleteAlbumRequest(req); err != nil {
|
||
return false, err
|
||
}
|
||
if _, _, err := r.storyAlbumWriteScope(ctx, req.Peer); err != nil {
|
||
return false, err
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
func validateStoriesDeleteAlbumRequest(req *tg.StoriesDeleteAlbumRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if err := validateStoryAlbumID(req.AlbumID); err != nil {
|
||
return err
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func (r *Router) onStoriesGetAllReadPeerStories(ctx context.Context) (tg.UpdatesClass, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
now := int(r.clock.Now().Unix())
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return tgEmptyUpdates(now), nil
|
||
}
|
||
states, err := r.deps.Stories.ListReadStates(ctx, userID)
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
states = normalizeStoryReadStates(userID, states)
|
||
return tgReadStoryUpdates(states, now), nil
|
||
}
|
||
|
||
func normalizeStoryReadStates(viewerUserID int64, states []domain.StoryReadState) []domain.StoryReadState {
|
||
byPeer := make(map[domain.Peer]domain.StoryReadState, len(states))
|
||
for _, state := range states {
|
||
if state.ViewerID != viewerUserID || state.MaxReadID <= 0 || state.MaxReadID > domain.MaxStoryID {
|
||
continue
|
||
}
|
||
if state.Peer.ID == 0 || state.Peer.Type == "" {
|
||
continue
|
||
}
|
||
existing, ok := byPeer[state.Peer]
|
||
if !ok || state.MaxReadID > existing.MaxReadID || (state.MaxReadID == existing.MaxReadID && state.Date > existing.Date) {
|
||
byPeer[state.Peer] = state
|
||
}
|
||
}
|
||
out := make([]domain.StoryReadState, 0, len(byPeer))
|
||
for _, state := range byPeer {
|
||
out = append(out, state)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
if out[i].Peer.Type != out[j].Peer.Type {
|
||
return out[i].Peer.Type < out[j].Peer.Type
|
||
}
|
||
return out[i].Peer.ID < out[j].Peer.ID
|
||
})
|
||
return out
|
||
}
|
||
|
||
func (r *Router) onStoriesGetPeerMaxIDs(ctx context.Context, id []tg.InputPeerClass) ([]tg.RecentStory, error) {
|
||
if len(id) > domain.MaxStoryIDs {
|
||
return nil, storyIDInvalidErr()
|
||
}
|
||
for _, input := range id {
|
||
if err := validateStoriesDirectInputPeer(input); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
peers := make([]domain.Peer, 0, len(id))
|
||
positions := make([]int, 0, len(id))
|
||
result := make([]tg.RecentStory, len(id))
|
||
for i, input := range id {
|
||
if _, community, err := r.maybeCommunityFromInputPeer(ctx, userID, input); err != nil {
|
||
return nil, err
|
||
} else if community {
|
||
// Communities are projected as channel peers in dialog lists, but do
|
||
// not own stories. Keep the batch positional by returning an empty
|
||
// recentStory at this index and resolve every ordinary peer normally.
|
||
continue
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
peers = append(peers, peer)
|
||
positions = append(positions, i)
|
||
}
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return result, nil
|
||
}
|
||
recent, err := r.deps.Stories.GetPeerMaxIDs(ctx, userID, peers, int(r.clock.Now().Unix()))
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
aligned := tgRecentStories(alignStoryRecentByPeer(peers, recent))
|
||
for i, position := range positions {
|
||
result[position] = aligned[i]
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func alignStoryRecentByPeer(peers []domain.Peer, recent []domain.RecentStory) []domain.RecentStory {
|
||
byPeer := make(map[domain.Peer]domain.RecentStory, len(recent))
|
||
for _, item := range recent {
|
||
if item.Peer.ID == 0 || item.Peer.Type == "" {
|
||
continue
|
||
}
|
||
existing, ok := byPeer[item.Peer]
|
||
if !ok || item.MaxID > existing.MaxID {
|
||
byPeer[item.Peer] = item
|
||
continue
|
||
}
|
||
if item.Live && !existing.Live {
|
||
existing.Live = true
|
||
byPeer[item.Peer] = existing
|
||
}
|
||
}
|
||
out := make([]domain.RecentStory, len(peers))
|
||
for i, peer := range peers {
|
||
out[i] = domain.RecentStory{Peer: peer}
|
||
if item, ok := byPeer[peer]; ok {
|
||
item.Peer = peer
|
||
out[i] = item
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (r *Router) onStoriesCanSendStory(ctx context.Context, peer tg.InputPeerClass) (*tg.StoriesCanSendStoryCount, error) {
|
||
if err := validateStoriesDirectInputPeer(peer); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
domainPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if r.deps.Stories == nil {
|
||
if !canSendStoryFallbackPeerAllowed(userID, domainPeer) {
|
||
return nil, peerIDInvalidErr()
|
||
}
|
||
return &tg.StoriesCanSendStoryCount{CountRemains: domain.DefaultStoryCanSendRemaining}, nil
|
||
}
|
||
count, err := r.deps.Stories.CanSendStory(ctx, userID, domainPeer)
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
return &tg.StoriesCanSendStoryCount{CountRemains: count}, nil
|
||
}
|
||
|
||
func canSendStoryFallbackPeerAllowed(userID int64, peer domain.Peer) bool {
|
||
return userID != 0 && peer.Type == domain.PeerTypeUser && peer.ID == userID
|
||
}
|
||
|
||
func validateStoriesDirectInputPeer(peer tg.InputPeerClass) error {
|
||
if inputPeerClassNil(peer) {
|
||
return peerIDInvalidErr()
|
||
}
|
||
switch typed := peer.(type) {
|
||
case *tg.InputPeerEmpty:
|
||
return peerIDInvalidErr()
|
||
case *tg.InputPeerChat:
|
||
if typed.ChatID <= 0 {
|
||
return peerIDInvalidErr()
|
||
}
|
||
case *tg.InputPeerUser:
|
||
if typed.UserID <= 0 {
|
||
return peerIDInvalidErr()
|
||
}
|
||
case *tg.InputPeerChannel:
|
||
if typed.ChannelID <= 0 {
|
||
return peerIDInvalidErr()
|
||
}
|
||
case *tg.InputPeerUserFromMessage:
|
||
if typed.UserID <= 0 || typed.MsgID <= 0 {
|
||
return peerIDInvalidErr()
|
||
}
|
||
case *tg.InputPeerChannelFromMessage:
|
||
if typed.ChannelID <= 0 || typed.MsgID <= 0 {
|
||
return peerIDInvalidErr()
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Router) onStoriesSendStory(ctx context.Context, req *tg.StoriesSendStoryRequest) (tg.UpdatesClass, error) {
|
||
period, err := validateStoriesSendStoryRequest(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return nil, peerIDInvalidErr()
|
||
}
|
||
now := int(r.clock.Now().Unix())
|
||
forward, err := r.domainStoryForwardFromSend(ctx, userID, req, now)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
media, err := r.resolveStoryInputMedia(ctx, userID, req.Media)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
visibility, err := r.storyVisibilityFromInputPrivacyRules(ctx, userID, req.PrivacyRules)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
mediaAreas, err := r.domainStoryMediaAreasFromSend(ctx, userID, peer, req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
res, err := r.deps.Stories.CreateStory(ctx, userID, domain.StoryCreateRequest{
|
||
Owner: peer,
|
||
RandomID: req.RandomID,
|
||
Date: now,
|
||
Period: period,
|
||
Pinned: req.Pinned,
|
||
Public: visibility.Public,
|
||
CloseFriends: visibility.CloseFriends,
|
||
Contacts: visibility.Contacts,
|
||
SelectedContacts: visibility.SelectedContacts,
|
||
NoForwards: req.Noforwards,
|
||
PrivacyRules: visibility.Rules,
|
||
AllowUserIDs: visibility.AllowUserIDs,
|
||
DisallowUserIDs: visibility.DisallowUserIDs,
|
||
Caption: req.Caption,
|
||
Entities: domainMessageEntitiesForViewer(userID, req.Entities),
|
||
Media: media,
|
||
MediaAreas: mediaAreas,
|
||
Forward: forward,
|
||
})
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
r.invalidateStoryProjectionCacheForPeer(peer)
|
||
if !res.Duplicate {
|
||
if err := r.recordStoryChange(ctx, userID, res.Story); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := r.fanoutChannelStoryChange(ctx, userID, res.Story); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return r.tgStoryChangeUpdates(ctx, userID, peer, res.Story, req.RandomID, true, now), nil
|
||
}
|
||
|
||
func validateStoriesSendStoryRequest(req *tg.StoriesSendStoryRequest) (int, error) {
|
||
if req == nil {
|
||
return 0, inputRequestInvalidErr()
|
||
}
|
||
if req.RandomID == 0 {
|
||
return 0, randomIDEmptyErr()
|
||
}
|
||
if err := validateStoryCaptionEntities(req.Caption, req.Entities); err != nil {
|
||
return 0, err
|
||
}
|
||
if err := validateStoryInputMediaClass(req.Media); err != nil {
|
||
return 0, err
|
||
}
|
||
if err := validateStorySendUnsupportedOptions(req); err != nil {
|
||
return 0, err
|
||
}
|
||
return storySendPeriodFromRequest(req)
|
||
}
|
||
|
||
func storySendPeriodFromRequest(req *tg.StoriesSendStoryRequest) (int, error) {
|
||
period, ok := req.GetPeriod()
|
||
if !ok {
|
||
return domain.DefaultStoryPeriod, nil
|
||
}
|
||
switch period {
|
||
case 6 * 3600, 12 * 3600, domain.DefaultStoryPeriod, 2 * domain.DefaultStoryPeriod:
|
||
return period, nil
|
||
default:
|
||
return 0, storyPeriodInvalidErr()
|
||
}
|
||
}
|
||
|
||
func (r *Router) domainStoryForwardFromSend(ctx context.Context, userID int64, req *tg.StoriesSendStoryRequest, now int) (*domain.StoryForward, error) {
|
||
sourceInput, hasSourcePeer := req.GetFwdFromID()
|
||
sourceStoryID, hasSourceStory := req.GetFwdFromStory()
|
||
if !hasSourcePeer && !hasSourceStory {
|
||
if req.FwdModified {
|
||
return nil, storyIDInvalidErr()
|
||
}
|
||
return nil, nil
|
||
}
|
||
if !hasSourcePeer || !hasSourceStory {
|
||
return nil, storyIDInvalidErr()
|
||
}
|
||
if err := validateStorySendForwardPayload(sourceInput, sourceStoryID); err != nil {
|
||
return nil, err
|
||
}
|
||
sourcePeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, sourceInput)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if r.deps.Stories == nil {
|
||
return nil, storyIDInvalidErr()
|
||
}
|
||
list, err := r.deps.Stories.GetStoriesByID(ctx, userID, sourcePeer, []int{sourceStoryID}, now)
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
if len(list.Stories) != 1 || list.Stories[0].ID != sourceStoryID || list.Stories[0].Owner != sourcePeer {
|
||
return nil, storyIDInvalidErr()
|
||
}
|
||
source := list.Stories[0]
|
||
if source.NoForwards {
|
||
return nil, chatForwardsRestrictedErr()
|
||
}
|
||
forward := &domain.StoryForward{
|
||
Source: source.Owner,
|
||
From: source.Owner,
|
||
StoryID: source.ID,
|
||
Modified: req.FwdModified,
|
||
}
|
||
r.applyStoryForwardAuthorPrivacy(ctx, userID, forward)
|
||
return forward, nil
|
||
}
|
||
|
||
func (r *Router) applyStoryForwardAuthorPrivacy(ctx context.Context, forwarderUserID int64, forward *domain.StoryForward) {
|
||
if forward == nil || forward.From.Type != domain.PeerTypeUser || forward.From.ID == 0 || forward.FromName != "" {
|
||
return
|
||
}
|
||
if forward.From.ID == forwarderUserID || r.deps.Privacy == nil {
|
||
return
|
||
}
|
||
allowed, err := r.deps.Privacy.CanSee(ctx, forward.From.ID, forwarderUserID, domain.PrivacyKeyForwards)
|
||
if err != nil || allowed {
|
||
return
|
||
}
|
||
name := r.forwardAuthorDisplayName(ctx, forwarderUserID, forward.From.ID)
|
||
forward.From = domain.Peer{}
|
||
forward.FromName = name
|
||
}
|
||
|
||
func (r *Router) onStoriesEditStory(ctx context.Context, req *tg.StoriesEditStoryRequest) (tg.UpdatesClass, error) {
|
||
if err := validateStoriesEditStoryRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var media *domain.MessageMedia
|
||
updateMedia := false
|
||
if input, ok := req.GetMedia(); ok {
|
||
updateMedia = true
|
||
media, err = r.resolveStoryInputMedia(ctx, userID, input)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
caption, updateCaption := req.GetCaption()
|
||
entities := req.Entities
|
||
if _, ok := req.GetEntities(); !ok {
|
||
entities = nil
|
||
}
|
||
rules, updatePrivacy := req.GetPrivacyRules()
|
||
visibility, err := r.storyVisibilityFromInputPrivacyRules(ctx, userID, rules)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
mediaAreas, updateMediaAreas, err := r.domainStoryMediaAreasFromEdit(ctx, userID, peer, req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return nil, peerIDInvalidErr()
|
||
}
|
||
musicOnlyNoop := storyEditHasOnlyEmptyMusic(req, updateMedia, updateCaption, updatePrivacy, updateMediaAreas)
|
||
res, err := r.deps.Stories.EditStory(ctx, userID, domain.StoryEditRequest{
|
||
Owner: peer,
|
||
ID: req.ID,
|
||
Media: media,
|
||
UpdateMedia: updateMedia,
|
||
Caption: caption,
|
||
Entities: domainMessageEntitiesForViewer(userID, entities),
|
||
UpdateCaption: updateCaption,
|
||
Public: visibility.Public,
|
||
CloseFriends: visibility.CloseFriends,
|
||
Contacts: visibility.Contacts,
|
||
SelectedContacts: visibility.SelectedContacts,
|
||
PrivacyRules: visibility.Rules,
|
||
AllowUserIDs: visibility.AllowUserIDs,
|
||
DisallowUserIDs: visibility.DisallowUserIDs,
|
||
UpdatePrivacy: updatePrivacy,
|
||
MediaAreas: mediaAreas,
|
||
UpdateMediaAreas: updateMediaAreas,
|
||
})
|
||
if err != nil {
|
||
if musicOnlyNoop && errors.Is(err, domain.ErrStoryNotModified) {
|
||
return r.tgUnchangedStoryUpdates(ctx, userID, peer, req.ID, int(r.clock.Now().Unix()))
|
||
}
|
||
return nil, storyErr(err)
|
||
}
|
||
r.invalidateStoryProjectionCacheForPeer(peer)
|
||
if err := r.recordStoryChange(ctx, userID, res.Story); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := r.fanoutChannelStoryChange(ctx, userID, res.Story); err != nil {
|
||
return nil, err
|
||
}
|
||
if updatePrivacy {
|
||
if err := r.fanoutStoryPrivacyChange(ctx, userID, res.Previous, res.Story); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return r.tgStoryChangeUpdates(ctx, userID, peer, res.Story, 0, false, int(r.clock.Now().Unix())), nil
|
||
}
|
||
|
||
func validateStoriesEditStoryRequest(req *tg.StoriesEditStoryRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if req.ID <= 0 || req.ID > domain.MaxStoryID {
|
||
return storyIDInvalidErr()
|
||
}
|
||
if err := validateStoryCaptionEntities(req.Caption, req.Entities); err != nil {
|
||
return err
|
||
}
|
||
if media, ok := req.GetMedia(); ok {
|
||
if err := validateStoryInputMediaClass(media); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return validateStoryEditUnsupportedOptions(req)
|
||
}
|
||
|
||
func validateStoryCaptionEntities(caption string, entities []tg.MessageEntityClass) error {
|
||
if utf8.RuneCountInString(caption) > maxSendMessageTextLength {
|
||
return mediaCaptionTooLongErr()
|
||
}
|
||
if len(entities) > maxMessageEntityCount {
|
||
return entitiesTooLongErr()
|
||
}
|
||
if len(entities) == 0 {
|
||
return nil
|
||
}
|
||
limit := utf16CodeUnitLen(caption)
|
||
for _, entity := range entities {
|
||
if messageEntityClassNil(entity) || !storyCaptionEntitySupported(entity) {
|
||
return entityBoundsInvalidErr()
|
||
}
|
||
offset, length := entity.GetOffset(), entity.GetLength()
|
||
if offset < 0 || length <= 0 || offset > limit || length > limit-offset {
|
||
return entityBoundsInvalidErr()
|
||
}
|
||
switch typed := entity.(type) {
|
||
case *tg.MessageEntityCustomEmoji:
|
||
if typed.DocumentID <= 0 {
|
||
return entityBoundsInvalidErr()
|
||
}
|
||
case *tg.InputMessageEntityMentionName:
|
||
if inputUserClassNil(typed.UserID) {
|
||
return userIDInvalidErr()
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func utf16CodeUnitLen(s string) int {
|
||
n := 0
|
||
for _, r := range s {
|
||
if r <= 0xFFFF {
|
||
n++
|
||
} else {
|
||
n += 2
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
func messageEntityClassNil(entity tg.MessageEntityClass) bool {
|
||
switch typed := entity.(type) {
|
||
case nil:
|
||
return true
|
||
case *tg.MessageEntityUnknown:
|
||
return typed == nil
|
||
case *tg.MessageEntityMention:
|
||
return typed == nil
|
||
case *tg.MessageEntityHashtag:
|
||
return typed == nil
|
||
case *tg.MessageEntityBotCommand:
|
||
return typed == nil
|
||
case *tg.MessageEntityURL:
|
||
return typed == nil
|
||
case *tg.MessageEntityEmail:
|
||
return typed == nil
|
||
case *tg.MessageEntityBold:
|
||
return typed == nil
|
||
case *tg.MessageEntityItalic:
|
||
return typed == nil
|
||
case *tg.MessageEntityCode:
|
||
return typed == nil
|
||
case *tg.MessageEntityPre:
|
||
return typed == nil
|
||
case *tg.MessageEntityTextURL:
|
||
return typed == nil
|
||
case *tg.MessageEntityMentionName:
|
||
return typed == nil
|
||
case *tg.InputMessageEntityMentionName:
|
||
return typed == nil
|
||
case *tg.MessageEntityPhone:
|
||
return typed == nil
|
||
case *tg.MessageEntityCashtag:
|
||
return typed == nil
|
||
case *tg.MessageEntityUnderline:
|
||
return typed == nil
|
||
case *tg.MessageEntityStrike:
|
||
return typed == nil
|
||
case *tg.MessageEntityBankCard:
|
||
return typed == nil
|
||
case *tg.MessageEntitySpoiler:
|
||
return typed == nil
|
||
case *tg.MessageEntityCustomEmoji:
|
||
return typed == nil
|
||
case *tg.MessageEntityBlockquote:
|
||
return typed == nil
|
||
case *tg.MessageEntityFormattedDate:
|
||
return typed == nil
|
||
case *tg.MessageEntityDiffInsert:
|
||
return typed == nil
|
||
case *tg.MessageEntityDiffReplace:
|
||
return typed == nil
|
||
case *tg.MessageEntityDiffDelete:
|
||
return typed == nil
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func storyCaptionEntitySupported(entity tg.MessageEntityClass) bool {
|
||
switch entity.(type) {
|
||
case *tg.MessageEntityMention,
|
||
*tg.MessageEntityHashtag,
|
||
*tg.MessageEntityBotCommand,
|
||
*tg.MessageEntityURL,
|
||
*tg.MessageEntityEmail,
|
||
*tg.MessageEntityBold,
|
||
*tg.MessageEntityItalic,
|
||
*tg.MessageEntityCode,
|
||
*tg.MessageEntityPre,
|
||
*tg.MessageEntityTextURL,
|
||
*tg.MessageEntityMentionName,
|
||
*tg.InputMessageEntityMentionName,
|
||
*tg.MessageEntityPhone,
|
||
*tg.MessageEntityCashtag,
|
||
*tg.MessageEntityUnderline,
|
||
*tg.MessageEntityStrike,
|
||
*tg.MessageEntityBankCard,
|
||
*tg.MessageEntitySpoiler,
|
||
*tg.MessageEntityCustomEmoji,
|
||
*tg.MessageEntityBlockquote,
|
||
*tg.MessageEntityFormattedDate:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func validateStorySendUnsupportedOptions(req *tg.StoriesSendStoryRequest) error {
|
||
if albums, ok := req.GetAlbums(); ok && len(albums) > 0 {
|
||
return mediaInvalidErr()
|
||
}
|
||
if music, ok := req.GetMusic(); ok && !storyInputDocumentIsEmpty(music) {
|
||
return documentInvalidErr()
|
||
}
|
||
return validateStorySendForwardFlags(req)
|
||
}
|
||
|
||
func validateStorySendForwardFlags(req *tg.StoriesSendStoryRequest) error {
|
||
sourceInput, hasSourcePeer := req.GetFwdFromID()
|
||
sourceStoryID, hasSourceStory := req.GetFwdFromStory()
|
||
if !hasSourcePeer && !hasSourceStory {
|
||
if req.FwdModified {
|
||
return storyIDInvalidErr()
|
||
}
|
||
return nil
|
||
}
|
||
if !hasSourcePeer || !hasSourceStory {
|
||
return storyIDInvalidErr()
|
||
}
|
||
return validateStorySendForwardPayload(sourceInput, sourceStoryID)
|
||
}
|
||
|
||
func validateStorySendForwardPayload(sourceInput tg.InputPeerClass, sourceStoryID int) error {
|
||
if sourceStoryID <= 0 || sourceStoryID > domain.MaxStoryID {
|
||
return storyIDInvalidErr()
|
||
}
|
||
if err := validateStoriesDirectInputPeer(sourceInput); err != nil {
|
||
return storyIDInvalidErr()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateStoryEditUnsupportedOptions(req *tg.StoriesEditStoryRequest) error {
|
||
if music, ok := req.GetMusic(); ok && !storyInputDocumentIsEmpty(music) {
|
||
return documentInvalidErr()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func storyInputDocumentIsEmpty(input tg.InputDocumentClass) bool {
|
||
if input == nil {
|
||
return false
|
||
}
|
||
empty, ok := input.(*tg.InputDocumentEmpty)
|
||
return ok && empty != nil
|
||
}
|
||
|
||
func storyEditHasOnlyEmptyMusic(req *tg.StoriesEditStoryRequest, updateMedia, updateCaption, updatePrivacy, updateMediaAreas bool) bool {
|
||
if updateMedia || updateCaption || updatePrivacy || updateMediaAreas {
|
||
return false
|
||
}
|
||
music, ok := req.GetMusic()
|
||
return ok && storyInputDocumentIsEmpty(music)
|
||
}
|
||
|
||
func (r *Router) domainStoryMediaAreasFromSend(ctx context.Context, userID int64, peer domain.Peer, req *tg.StoriesSendStoryRequest) ([]domain.StoryMediaArea, error) {
|
||
areas, ok := req.GetMediaAreas()
|
||
if !ok {
|
||
return nil, nil
|
||
}
|
||
return r.domainStoryMediaAreasFromTL(ctx, userID, peer, areas)
|
||
}
|
||
|
||
func (r *Router) domainStoryMediaAreasFromEdit(ctx context.Context, userID int64, peer domain.Peer, req *tg.StoriesEditStoryRequest) ([]domain.StoryMediaArea, bool, error) {
|
||
areas, ok := req.GetMediaAreas()
|
||
if !ok {
|
||
return nil, false, nil
|
||
}
|
||
out, err := r.domainStoryMediaAreasFromTL(ctx, userID, peer, areas)
|
||
return out, true, err
|
||
}
|
||
|
||
func (r *Router) domainStoryMediaAreasFromTL(ctx context.Context, userID int64, peer domain.Peer, areas []tg.MediaAreaClass) ([]domain.StoryMediaArea, error) {
|
||
if len(areas) == 0 {
|
||
return nil, nil
|
||
}
|
||
if len(areas) > domain.MaxStoryMediaAreas {
|
||
return nil, limitInvalidErr()
|
||
}
|
||
out := make([]domain.StoryMediaArea, 0, len(areas))
|
||
for _, area := range areas {
|
||
if storyMediaAreaClassNil(area) {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
switch typed := area.(type) {
|
||
case *tg.MediaAreaSuggestedReaction:
|
||
coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
reaction, err := domainStoryReactionValueFromTL(typed.Reaction)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, domain.StoryMediaArea{
|
||
Kind: domain.StoryMediaAreaSuggestedReaction,
|
||
Coordinates: coords,
|
||
Dark: typed.Dark,
|
||
Flipped: typed.Flipped,
|
||
Reaction: &reaction,
|
||
})
|
||
case *tg.MediaAreaURL:
|
||
coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !validStoryMediaAreaURL(typed.URL) {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
out = append(out, domain.StoryMediaArea{
|
||
Kind: domain.StoryMediaAreaURL,
|
||
Coordinates: coords,
|
||
URL: typed.URL,
|
||
})
|
||
case *tg.MediaAreaGeoPoint:
|
||
coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
geo, err := domainStoryGeoPointFromTL(typed.Geo)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
address, err := domainStoryGeoPointAddressFromTL(typed)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, domain.StoryMediaArea{
|
||
Kind: domain.StoryMediaAreaGeoPoint,
|
||
Coordinates: coords,
|
||
Geo: geo,
|
||
GeoAddress: address,
|
||
})
|
||
case *tg.MediaAreaVenue:
|
||
coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
venue, err := domainStoryVenueFromTL(typed)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, domain.StoryMediaArea{
|
||
Kind: domain.StoryMediaAreaVenue,
|
||
Coordinates: coords,
|
||
Geo: &venue.Geo,
|
||
Venue: venue,
|
||
})
|
||
case *tg.InputMediaAreaVenue:
|
||
coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
area, err := r.domainStoryVenueMediaAreaFromInput(ctx, userID, peer, coords, typed)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, area)
|
||
case *tg.MediaAreaWeather:
|
||
coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !validStoryWeatherEmoji(typed.Emoji) || !validStoryWeatherTemperature(typed.TemperatureC) {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
out = append(out, domain.StoryMediaArea{
|
||
Kind: domain.StoryMediaAreaWeather,
|
||
Coordinates: coords,
|
||
WeatherEmoji: typed.Emoji,
|
||
TemperatureC: typed.TemperatureC,
|
||
Color: typed.Color,
|
||
})
|
||
case *tg.MediaAreaChannelPost:
|
||
coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !validStoryChannelPost(typed.ChannelID, typed.MsgID) {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
out = append(out, domain.StoryMediaArea{
|
||
Kind: domain.StoryMediaAreaChannelPost,
|
||
Coordinates: coords,
|
||
ChannelID: typed.ChannelID,
|
||
MsgID: typed.MsgID,
|
||
})
|
||
case *tg.MediaAreaStarGift:
|
||
coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !validStoryStarGiftSlug(typed.Slug) {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
out = append(out, domain.StoryMediaArea{
|
||
Kind: domain.StoryMediaAreaStarGift,
|
||
Coordinates: coords,
|
||
StarGiftSlug: typed.Slug,
|
||
})
|
||
case *tg.InputMediaAreaChannelPost:
|
||
coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
area, err := r.domainStoryChannelPostMediaAreaFromInput(ctx, userID, coords, typed)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, area)
|
||
default:
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func storyMediaAreaClassNil(area tg.MediaAreaClass) bool {
|
||
switch typed := area.(type) {
|
||
case nil:
|
||
return true
|
||
case *tg.MediaAreaSuggestedReaction:
|
||
return typed == nil
|
||
case *tg.MediaAreaURL:
|
||
return typed == nil
|
||
case *tg.MediaAreaGeoPoint:
|
||
return typed == nil
|
||
case *tg.MediaAreaVenue:
|
||
return typed == nil
|
||
case *tg.InputMediaAreaVenue:
|
||
return typed == nil
|
||
case *tg.MediaAreaWeather:
|
||
return typed == nil
|
||
case *tg.MediaAreaChannelPost:
|
||
return typed == nil
|
||
case *tg.MediaAreaStarGift:
|
||
return typed == nil
|
||
case *tg.InputMediaAreaChannelPost:
|
||
return typed == nil
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func domainStoryMediaAreaCoordinatesFromTL(in tg.MediaAreaCoordinates) (domain.StoryMediaAreaCoordinates, error) {
|
||
radius, hasRadius := in.GetRadius()
|
||
out := domain.StoryMediaAreaCoordinates{
|
||
X: in.X,
|
||
Y: in.Y,
|
||
W: in.W,
|
||
H: in.H,
|
||
Rotation: in.Rotation,
|
||
Radius: radius,
|
||
HasRadius: hasRadius,
|
||
}
|
||
if !storyAreaPercent(out.X, true) ||
|
||
!storyAreaPercent(out.Y, true) ||
|
||
!storyAreaPercent(out.W, false) ||
|
||
!storyAreaPercent(out.H, false) ||
|
||
!storyAreaRotation(out.Rotation) ||
|
||
(hasRadius && !storyAreaPercent(out.Radius, true)) {
|
||
return domain.StoryMediaAreaCoordinates{}, mediaInvalidErr()
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func storyAreaPercent(v float64, allowZero bool) bool {
|
||
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > 100 {
|
||
return false
|
||
}
|
||
return allowZero || v > 0
|
||
}
|
||
|
||
func storyAreaRotation(v float64) bool {
|
||
return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= 360
|
||
}
|
||
|
||
func validStoryMediaAreaURL(raw string) bool {
|
||
return raw != "" &&
|
||
len(raw) <= domain.MaxStoryMediaAreaURLLength &&
|
||
strings.TrimSpace(raw) == raw
|
||
}
|
||
|
||
func validStoryWeatherEmoji(raw string) bool {
|
||
return raw != "" &&
|
||
strings.TrimSpace(raw) == raw &&
|
||
utf8.RuneCountInString(raw) <= domain.MaxStoryWeatherEmojiLength
|
||
}
|
||
|
||
func validStoryWeatherTemperature(v float64) bool {
|
||
return !math.IsNaN(v) &&
|
||
!math.IsInf(v, 0) &&
|
||
v >= minStoryWeatherTemperatureC &&
|
||
v <= maxStoryWeatherTemperatureC
|
||
}
|
||
|
||
func validStoryChannelPost(channelID int64, msgID int) bool {
|
||
return channelID > 0 && msgID > 0 && msgID <= domain.MaxMessageBoxID
|
||
}
|
||
|
||
func validStoryStarGiftSlug(raw string) bool {
|
||
if raw == "" ||
|
||
len(raw) > domain.MaxStoryStarGiftSlugLength ||
|
||
strings.TrimSpace(raw) != raw {
|
||
return false
|
||
}
|
||
for i := 0; i < len(raw); i++ {
|
||
c := raw[i]
|
||
if (c >= 'a' && c <= 'z') ||
|
||
(c >= 'A' && c <= 'Z') ||
|
||
(c >= '0' && c <= '9') ||
|
||
c == '.' ||
|
||
c == '_' ||
|
||
c == '-' {
|
||
continue
|
||
}
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (r *Router) domainStoryChannelPostMediaAreaFromInput(ctx context.Context, userID int64, coords domain.StoryMediaAreaCoordinates, area *tg.InputMediaAreaChannelPost) (domain.StoryMediaArea, error) {
|
||
if area == nil || area.Channel == nil || area.MsgID <= 0 || area.MsgID > domain.MaxMessageBoxID {
|
||
return domain.StoryMediaArea{}, mediaInvalidErr()
|
||
}
|
||
if r.deps.Channels == nil {
|
||
return domain.StoryMediaArea{}, channelInvalidErr(domain.ErrChannelInvalid)
|
||
}
|
||
channelID, err := r.channelIDFromInput(ctx, userID, area.Channel)
|
||
if err != nil {
|
||
return domain.StoryMediaArea{}, err
|
||
}
|
||
if !validStoryChannelPost(channelID, area.MsgID) {
|
||
return domain.StoryMediaArea{}, mediaInvalidErr()
|
||
}
|
||
history, err := r.deps.Channels.GetMessages(ctx, userID, channelID, []int{area.MsgID})
|
||
if err != nil {
|
||
if errors.Is(err, domain.ErrMessageIDInvalid) {
|
||
return domain.StoryMediaArea{}, mediaInvalidErr()
|
||
}
|
||
return domain.StoryMediaArea{}, channelInvalidErr(err)
|
||
}
|
||
if len(history.Messages) != 1 || history.Messages[0].ID != area.MsgID {
|
||
return domain.StoryMediaArea{}, mediaInvalidErr()
|
||
}
|
||
return domain.StoryMediaArea{
|
||
Kind: domain.StoryMediaAreaChannelPost,
|
||
Coordinates: coords,
|
||
ChannelID: channelID,
|
||
MsgID: area.MsgID,
|
||
}, nil
|
||
}
|
||
|
||
func domainStoryVenueFromTL(area *tg.MediaAreaVenue) (*domain.MessageVenue, error) {
|
||
if area == nil {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
geo, err := domainStoryGeoPointFromTL(area.Geo)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return domainStoryVenueFromValues(*geo, area.Title, area.Address, area.Provider, area.VenueID, area.VenueType)
|
||
}
|
||
|
||
func (r *Router) domainStoryVenueMediaAreaFromInput(ctx context.Context, userID int64, peer domain.Peer, coords domain.StoryMediaAreaCoordinates, area *tg.InputMediaAreaVenue) (domain.StoryMediaArea, error) {
|
||
if area == nil ||
|
||
area.QueryID == 0 ||
|
||
area.ResultID == "" ||
|
||
len(area.ResultID) > domain.MaxBotInlineResultIDLen {
|
||
return domain.StoryMediaArea{}, mediaInvalidErr()
|
||
}
|
||
results, result, ok := r.inlines.resultForSendContext(ctx, r.clock.Now(), userID, area.QueryID, area.ResultID)
|
||
if !ok || !r.inlineResultsAllowPeer(ctx, userID, results, peer) ||
|
||
result.Media == nil ||
|
||
result.Media.Kind != domain.MessageMediaKindVenue ||
|
||
result.Media.Venue == nil {
|
||
return domain.StoryMediaArea{}, mediaInvalidErr()
|
||
}
|
||
venue, err := domainStoryVenueFromValues(result.Media.Venue.Geo, result.Media.Venue.Title, result.Media.Venue.Address, result.Media.Venue.Provider, result.Media.Venue.VenueID, result.Media.Venue.VenueType)
|
||
if err != nil {
|
||
return domain.StoryMediaArea{}, err
|
||
}
|
||
return domain.StoryMediaArea{
|
||
Kind: domain.StoryMediaAreaVenue,
|
||
Coordinates: coords,
|
||
Geo: &venue.Geo,
|
||
Venue: venue,
|
||
}, nil
|
||
}
|
||
|
||
func domainStoryVenueFromValues(geo domain.MessageGeoPoint, title, address, provider, venueID, venueType string) (*domain.MessageVenue, error) {
|
||
if !validStoryDomainGeoPoint(geo) ||
|
||
strings.TrimSpace(title) == "" ||
|
||
utf8.RuneCountInString(title) > maxVenueTitleLength ||
|
||
utf8.RuneCountInString(address) > maxVenueAddressLength ||
|
||
utf8.RuneCountInString(provider) > maxVenueProviderLength ||
|
||
utf8.RuneCountInString(venueID) > maxVenueIDLength ||
|
||
utf8.RuneCountInString(venueType) > maxVenueIDLength {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
return &domain.MessageVenue{
|
||
Geo: geo,
|
||
Title: title,
|
||
Address: address,
|
||
Provider: provider,
|
||
VenueID: venueID,
|
||
VenueType: venueType,
|
||
}, nil
|
||
}
|
||
|
||
func validStoryDomainGeoPoint(geo domain.MessageGeoPoint) bool {
|
||
return !math.IsNaN(geo.Lat) &&
|
||
!math.IsInf(geo.Lat, 0) &&
|
||
!math.IsNaN(geo.Long) &&
|
||
!math.IsInf(geo.Long, 0) &&
|
||
geo.Lat >= -90 &&
|
||
geo.Lat <= 90 &&
|
||
geo.Long >= -180 &&
|
||
geo.Long <= 180
|
||
}
|
||
|
||
func domainStoryGeoPointFromTL(geo tg.GeoPointClass) (*domain.MessageGeoPoint, error) {
|
||
point, ok := geo.(*tg.GeoPoint)
|
||
if !ok || point == nil ||
|
||
math.IsNaN(point.Lat) || math.IsInf(point.Lat, 0) ||
|
||
math.IsNaN(point.Long) || math.IsInf(point.Long, 0) ||
|
||
point.Lat < -90 || point.Lat > 90 ||
|
||
point.Long < -180 || point.Long > 180 {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
accuracy, _ := point.GetAccuracyRadius()
|
||
if accuracy < 0 || accuracy > maxGeoAccuracyRadiusMeters {
|
||
accuracy = 0
|
||
}
|
||
return &domain.MessageGeoPoint{
|
||
Lat: point.Lat,
|
||
Long: point.Long,
|
||
AccessHash: point.AccessHash,
|
||
AccuracyRadius: accuracy,
|
||
}, nil
|
||
}
|
||
|
||
func domainStoryGeoPointAddressFromTL(area *tg.MediaAreaGeoPoint) (*domain.StoryGeoPointAddress, error) {
|
||
if area == nil {
|
||
return nil, nil
|
||
}
|
||
address, ok := area.GetAddress()
|
||
if !ok {
|
||
return nil, nil
|
||
}
|
||
if !validStoryGeoCountry(address.CountryISO2) ||
|
||
!validStoryGeoAddressPart(address.State) ||
|
||
!validStoryGeoAddressPart(address.City) ||
|
||
!validStoryGeoAddressPart(address.Street) {
|
||
return nil, mediaInvalidErr()
|
||
}
|
||
return &domain.StoryGeoPointAddress{
|
||
CountryISO2: address.CountryISO2,
|
||
State: address.State,
|
||
City: address.City,
|
||
Street: address.Street,
|
||
}, nil
|
||
}
|
||
|
||
func validStoryGeoCountry(raw string) bool {
|
||
return utf8.RuneCountInString(raw) == 2 && strings.TrimSpace(raw) == raw
|
||
}
|
||
|
||
func validStoryGeoAddressPart(raw string) bool {
|
||
return utf8.RuneCountInString(raw) <= domain.MaxStoryGeoAddressPartLength &&
|
||
strings.TrimSpace(raw) == raw
|
||
}
|
||
|
||
func (r *Router) tgUnchangedStoryUpdates(ctx context.Context, viewerUserID int64, peer domain.Peer, storyID, date int) (tg.UpdatesClass, error) {
|
||
list, err := r.deps.Stories.GetStoriesByID(ctx, viewerUserID, peer, []int{storyID}, date)
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
if len(list.Stories) == 0 {
|
||
return nil, storyIDInvalidErr()
|
||
}
|
||
return r.tgStoryChangeUpdates(ctx, viewerUserID, peer, list.Stories[0], 0, false, date), nil
|
||
}
|
||
|
||
type storyPrivacyFanoutFacts struct {
|
||
isContact bool
|
||
closeFriend bool
|
||
storyBlocked bool
|
||
}
|
||
|
||
func (r *Router) fanoutStoryPrivacyChange(ctx context.Context, ownerID int64, before, after domain.Story) error {
|
||
if ownerID == 0 || r.deps.Stories == nil || r.deps.Updates == nil {
|
||
return nil
|
||
}
|
||
if before.Owner != after.Owner || before.ID != after.ID || after.Owner.Type != domain.PeerTypeUser || after.Owner.ID != ownerID {
|
||
return nil
|
||
}
|
||
now := int(r.clock.Now().Unix())
|
||
beforeActive := before.Active(now)
|
||
afterActive := after.Active(now)
|
||
if !beforeActive && !afterActive {
|
||
return nil
|
||
}
|
||
candidates := make(map[int64]storyPrivacyFanoutFacts)
|
||
addCandidate := func(userID int64, facts storyPrivacyFanoutFacts) {
|
||
if userID == 0 || userID == ownerID {
|
||
return
|
||
}
|
||
existing, ok := candidates[userID]
|
||
if !ok && len(candidates) >= domain.MaxStoryPrivacyFanoutTargets {
|
||
return
|
||
}
|
||
facts.isContact = facts.isContact || existing.isContact
|
||
facts.closeFriend = facts.closeFriend || existing.closeFriend
|
||
facts.storyBlocked = facts.storyBlocked || existing.storyBlocked
|
||
candidates[userID] = facts
|
||
}
|
||
addCandidateIDs := func(ids []int64) {
|
||
for _, userID := range ids {
|
||
addCandidate(userID, storyPrivacyFanoutFacts{})
|
||
}
|
||
}
|
||
addCandidateIDs(before.AllowUserIDs)
|
||
addCandidateIDs(after.AllowUserIDs)
|
||
addCandidateIDs(before.DisallowUserIDs)
|
||
addCandidateIDs(after.DisallowUserIDs)
|
||
if r.deps.Contacts != nil {
|
||
list, _, err := r.deps.Contacts.GetContacts(ctx, ownerID, 0)
|
||
if err != nil {
|
||
return internalErr()
|
||
}
|
||
for _, contact := range list.Contacts {
|
||
contactUserID := contact.User.ID
|
||
addCandidate(contactUserID, storyPrivacyFanoutFacts{
|
||
isContact: true,
|
||
closeFriend: contact.CloseFriend || contact.User.CloseFriend,
|
||
})
|
||
}
|
||
}
|
||
viewerIDs, err := r.deps.Stories.ListStoryViewerIDs(ctx, ownerID, after.Owner, after.ID, domain.MaxStoryPrivacyFanoutTargets)
|
||
if err != nil {
|
||
return storyErr(err)
|
||
}
|
||
addCandidateIDs(viewerIDs)
|
||
candidateIDs := make([]int64, 0, len(candidates))
|
||
for userID := range candidates {
|
||
candidateIDs = append(candidateIDs, userID)
|
||
}
|
||
blockedFacts, err := r.storyBlockedFactsForUsers(ctx, ownerID, candidateIDs)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for userID, blocked := range blockedFacts {
|
||
facts := candidates[userID]
|
||
facts.storyBlocked = blocked
|
||
candidates[userID] = facts
|
||
}
|
||
visible := func(story domain.Story, active bool, userID int64, facts storyPrivacyFanoutFacts) bool {
|
||
return active && story.VisibleToWithStoryFacts(userID, facts.isContact, facts.closeFriend, facts.storyBlocked)
|
||
}
|
||
for userID, facts := range candidates {
|
||
beforeVisible := visible(before, beforeActive, userID, facts)
|
||
afterVisible := visible(after, afterActive, userID, facts)
|
||
switch {
|
||
case afterVisible:
|
||
if err := r.recordStoryFanout(ctx, userID, storyFanoutSnapshot(after)); err != nil {
|
||
return err
|
||
}
|
||
case beforeVisible:
|
||
deleted := storyFanoutSnapshot(after)
|
||
deleted.Deleted = true
|
||
if err := r.recordStoryFanout(ctx, userID, deleted); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Router) fanoutDeletedUserStory(ctx context.Context, ownerID int64, before, deleted domain.Story, now int) error {
|
||
if ownerID == 0 || r.deps.Stories == nil || r.deps.Updates == nil {
|
||
return nil
|
||
}
|
||
if before.ID == 0 {
|
||
before = deleted
|
||
before.Deleted = false
|
||
}
|
||
if deleted.ID == 0 {
|
||
deleted = before
|
||
deleted.Deleted = true
|
||
}
|
||
if before.Owner.Type != domain.PeerTypeUser || before.Owner.ID != ownerID || before.ID <= 0 {
|
||
return nil
|
||
}
|
||
if deleted.Owner != before.Owner || deleted.ID != before.ID {
|
||
return nil
|
||
}
|
||
if !before.Active(now) && !before.Pinned {
|
||
return nil
|
||
}
|
||
candidates := make(map[int64]storyPrivacyFanoutFacts)
|
||
addCandidate := func(userID int64, facts storyPrivacyFanoutFacts) {
|
||
if userID == 0 || userID == ownerID {
|
||
return
|
||
}
|
||
existing, ok := candidates[userID]
|
||
if !ok && len(candidates) >= domain.MaxStoryPrivacyFanoutTargets {
|
||
return
|
||
}
|
||
facts.isContact = facts.isContact || existing.isContact
|
||
facts.closeFriend = facts.closeFriend || existing.closeFriend
|
||
facts.storyBlocked = facts.storyBlocked || existing.storyBlocked
|
||
candidates[userID] = facts
|
||
}
|
||
addCandidateIDs := func(ids []int64) {
|
||
for _, userID := range ids {
|
||
addCandidate(userID, storyPrivacyFanoutFacts{})
|
||
}
|
||
}
|
||
addCandidateIDs(before.AllowUserIDs)
|
||
addCandidateIDs(before.DisallowUserIDs)
|
||
if r.deps.Contacts != nil {
|
||
list, _, err := r.deps.Contacts.GetContacts(ctx, ownerID, 0)
|
||
if err != nil {
|
||
return internalErr()
|
||
}
|
||
for _, contact := range list.Contacts {
|
||
contactUserID := contact.User.ID
|
||
addCandidate(contactUserID, storyPrivacyFanoutFacts{
|
||
isContact: true,
|
||
closeFriend: contact.CloseFriend || contact.User.CloseFriend,
|
||
})
|
||
}
|
||
}
|
||
viewerIDs, err := r.deps.Stories.ListStoryViewerIDs(ctx, ownerID, before.Owner, before.ID, domain.MaxStoryPrivacyFanoutTargets)
|
||
if err != nil {
|
||
return storyErr(err)
|
||
}
|
||
addCandidateIDs(viewerIDs)
|
||
candidateIDs := make([]int64, 0, len(candidates))
|
||
for userID := range candidates {
|
||
candidateIDs = append(candidateIDs, userID)
|
||
}
|
||
blockedFacts, err := r.storyBlockedFactsForUsers(ctx, ownerID, candidateIDs)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for userID, blocked := range blockedFacts {
|
||
facts := candidates[userID]
|
||
facts.storyBlocked = blocked
|
||
candidates[userID] = facts
|
||
}
|
||
deleted = storyFanoutSnapshot(deleted)
|
||
deleted.Deleted = true
|
||
for userID, facts := range candidates {
|
||
if before.VisibleToWithStoryFacts(userID, facts.isContact, facts.closeFriend, facts.storyBlocked) {
|
||
if err := r.recordStoryFanout(ctx, userID, deleted); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Router) fanoutUnpinnedExpiredUserStory(ctx context.Context, ownerID int64, before, after domain.Story, now int) error {
|
||
if before.ID == 0 || after.ID == 0 {
|
||
return nil
|
||
}
|
||
if before.Owner != after.Owner || before.ID != after.ID {
|
||
return nil
|
||
}
|
||
if !before.Pinned || before.Active(now) || after.Active(now) || after.Pinned || after.Deleted {
|
||
return nil
|
||
}
|
||
return r.fanoutDeletedUserStory(ctx, ownerID, before, after, now)
|
||
}
|
||
|
||
func (r *Router) fanoutChannelStoryChange(ctx context.Context, originUserID int64, story domain.Story) error {
|
||
if originUserID == 0 ||
|
||
story.Owner.Type != domain.PeerTypeChannel ||
|
||
story.Owner.ID == 0 ||
|
||
r.deps.Channels == nil ||
|
||
r.deps.Updates == nil {
|
||
return nil
|
||
}
|
||
memberIDs, err := r.deps.Channels.ActiveMemberIDs(ctx, originUserID, story.Owner.ID, domain.MaxChannelRealtimeFanout)
|
||
if err != nil {
|
||
return internalErr()
|
||
}
|
||
snapshot := storyFanoutSnapshot(story)
|
||
for _, userID := range memberIDs {
|
||
if userID == 0 || userID == originUserID {
|
||
continue
|
||
}
|
||
if err := r.recordStoryFanout(ctx, userID, snapshot); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Router) onStoriesDeleteStories(ctx context.Context, req *tg.StoriesDeleteStoriesRequest) ([]int, error) {
|
||
ids, err := validateStoriesDeleteStoriesRequest(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
now := int(r.clock.Now().Unix())
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return append([]int(nil), ids...), nil
|
||
}
|
||
res, err := r.deps.Stories.DeleteStories(ctx, userID, peer, ids, now)
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
r.invalidateStoryProjectionCacheForPeer(peer)
|
||
previousByID := make(map[int]domain.Story, len(res.Previous))
|
||
for _, story := range res.Previous {
|
||
previousByID[story.ID] = story
|
||
}
|
||
for _, story := range res.Stories {
|
||
if err := r.recordStoryChange(ctx, userID, story); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := r.fanoutDeletedUserStory(ctx, userID, previousByID[story.ID], story, now); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := r.fanoutChannelStoryChange(ctx, userID, story); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return res.IDs, nil
|
||
}
|
||
|
||
func validateStoriesDeleteStoriesRequest(req *tg.StoriesDeleteStoriesRequest) ([]int, error) {
|
||
if req == nil {
|
||
return nil, inputRequestInvalidErr()
|
||
}
|
||
if len(req.ID) == 0 {
|
||
return nil, storyIDEmptyErr()
|
||
}
|
||
if err := validateStoryIDSlice(req.ID); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := validateStoriesDirectInputPeer(req.Peer); err != nil {
|
||
return nil, err
|
||
}
|
||
return uniqueStoryIDs(req.ID), nil
|
||
}
|
||
|
||
func (r *Router) onStoriesTogglePinned(ctx context.Context, req *tg.StoriesTogglePinnedRequest) ([]int, error) {
|
||
ids, err := validateStoriesTogglePinnedRequest(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
now := int(r.clock.Now().Unix())
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return append([]int(nil), ids...), nil
|
||
}
|
||
res, err := r.deps.Stories.TogglePinned(ctx, userID, peer, ids, req.Pinned, now)
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
r.invalidateStoryProjectionCacheForPeer(peer)
|
||
previousByID := make(map[int]domain.Story, len(res.Previous))
|
||
for _, story := range res.Previous {
|
||
previousByID[story.ID] = story
|
||
}
|
||
for _, story := range res.Stories {
|
||
if err := r.recordStoryChange(ctx, userID, story); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := r.fanoutUnpinnedExpiredUserStory(ctx, userID, previousByID[story.ID], story, now); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := r.fanoutChannelStoryChange(ctx, userID, story); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return res.IDs, nil
|
||
}
|
||
|
||
func validateStoriesTogglePinnedRequest(req *tg.StoriesTogglePinnedRequest) ([]int, error) {
|
||
if req == nil {
|
||
return nil, inputRequestInvalidErr()
|
||
}
|
||
if len(req.ID) > 0 {
|
||
if err := validateStoryIDSlice(req.ID); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if err := validateStoriesDirectInputPeer(req.Peer); err != nil {
|
||
return nil, err
|
||
}
|
||
if len(req.ID) == 0 {
|
||
return []int{}, nil
|
||
}
|
||
return uniqueStoryIDs(req.ID), nil
|
||
}
|
||
|
||
func (r *Router) onStoriesTogglePinnedToTop(ctx context.Context, req *tg.StoriesTogglePinnedToTopRequest) (bool, error) {
|
||
ids, err := validateStoriesTogglePinnedToTopRequest(req)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return false, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return true, nil
|
||
}
|
||
if err := r.deps.Stories.TogglePinnedToTop(ctx, userID, peer, ids); err != nil {
|
||
return false, storyErr(err)
|
||
}
|
||
r.invalidateStoryProjectionCacheForPeer(peer)
|
||
return true, nil
|
||
}
|
||
|
||
func validateStoriesTogglePinnedToTopRequest(req *tg.StoriesTogglePinnedToTopRequest) ([]int, error) {
|
||
if req == nil {
|
||
return nil, inputRequestInvalidErr()
|
||
}
|
||
if len(req.ID) > 0 {
|
||
if err := validateStoryIDSlice(req.ID); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
ids := uniqueStoryIDs(req.ID)
|
||
if len(ids) > domain.MaxStoryPinnedToTop {
|
||
return nil, storyIDInvalidErr()
|
||
}
|
||
if err := validateStoriesDirectInputPeer(req.Peer); err != nil {
|
||
return nil, err
|
||
}
|
||
return ids, nil
|
||
}
|
||
|
||
func (r *Router) onStoriesReadStories(ctx context.Context, req *tg.StoriesReadStoriesRequest) ([]int, error) {
|
||
if err := validateStoriesReadStoriesRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return []int{}, nil
|
||
}
|
||
now := int(r.clock.Now().Unix())
|
||
read, err := r.deps.Stories.ReadStories(ctx, userID, peer, req.MaxID, now)
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
if read.Advanced && r.deps.Updates != nil {
|
||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||
sessionID, _ := SessionIDFrom(ctx)
|
||
if _, _, err := r.deps.Updates.RecordReadStories(ctx, authKeyID, userID, read, rawAuthKeyIDForOrigin(ctx), sessionID); err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
}
|
||
if read.MaxReadID <= 0 {
|
||
return []int{}, nil
|
||
}
|
||
return []int{read.MaxReadID}, nil
|
||
}
|
||
|
||
func validateStoriesReadStoriesRequest(req *tg.StoriesReadStoriesRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if req.MaxID <= 0 || req.MaxID > domain.MaxStoryID {
|
||
return storyIDInvalidErr()
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func (r *Router) onStoriesIncrementStoryViews(ctx context.Context, req *tg.StoriesIncrementStoryViewsRequest) (bool, error) {
|
||
if err := validateStoriesIncrementStoryViewsRequest(req); err != nil {
|
||
return false, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return false, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return true, nil
|
||
}
|
||
if _, err := r.deps.Stories.IncrementViews(ctx, userID, peer, uniqueStoryIDs(req.ID), int(r.clock.Now().Unix())); err != nil {
|
||
return false, storyErr(err)
|
||
}
|
||
// 浏览计数变化主要影响作者自己查看自己故事时的 ViewsCount。view 增量高频,
|
||
// 只失效作者自视角这一条缓存,避免按 peer 全量失效把热门故事的缓存打穿。
|
||
if peer.Type == domain.PeerTypeUser {
|
||
r.invalidateStoryProjectionCache(peer.ID, peer)
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
func validateStoriesIncrementStoryViewsRequest(req *tg.StoriesIncrementStoryViewsRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if len(req.ID) == 0 {
|
||
return storyIDEmptyErr()
|
||
}
|
||
if err := validateStoryIDSlice(req.ID); err != nil {
|
||
return err
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func (r *Router) onStoriesGetStoriesViews(ctx context.Context, req *tg.StoriesGetStoriesViewsRequest) (*tg.StoriesStoryViews, error) {
|
||
if err := validateStoriesGetStoriesViewsRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return tgEmptyStoriesStoryViews(len(req.ID)), nil
|
||
}
|
||
list, err := r.deps.Stories.GetStoriesByID(ctx, userID, peer, req.ID, int(r.clock.Now().Unix()))
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
r.addStoryMessageForwardCounts(ctx, userID, list.Stories)
|
||
return r.tgStoriesStoryViewsForIDs(ctx, userID, peer, req.ID, list.Stories), nil
|
||
}
|
||
|
||
func validateStoriesGetStoriesViewsRequest(req *tg.StoriesGetStoriesViewsRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if len(req.ID) == 0 {
|
||
return storyIDEmptyErr()
|
||
}
|
||
if err := validateStoryIDSlice(req.ID); err != nil {
|
||
return err
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func (r *Router) onStoriesGetStoryViewsList(ctx context.Context, req *tg.StoriesGetStoryViewsListRequest) (*tg.StoriesStoryViewsList, error) {
|
||
if err := validateStoriesGetStoryViewsListRequest(req); err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return r.tgStoryViewsList(ctx, userID, domain.StoryViewList{}), nil
|
||
}
|
||
q, _ := storyViewsListQuery(req)
|
||
list, err := r.deps.Stories.GetStoryViewsList(ctx, userID, domain.StoryViewListRequest{
|
||
Owner: peer,
|
||
StoryID: req.ID,
|
||
Offset: req.Offset,
|
||
Limit: req.Limit,
|
||
Query: q,
|
||
JustContacts: req.GetJustContacts(),
|
||
ReactionsFirst: req.GetReactionsFirst(),
|
||
ForwardsFirst: req.GetForwardsFirst(),
|
||
})
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
if q == "" && !req.GetJustContacts() {
|
||
list = r.withStoryMessageForwardViews(ctx, userID, list, domain.StoryMessageForwardListRequest{
|
||
Owner: peer,
|
||
StoryID: req.ID,
|
||
Offset: req.Offset,
|
||
Limit: req.Limit,
|
||
ReactionsFirst: req.GetReactionsFirst(),
|
||
ForwardsFirst: req.GetForwardsFirst(),
|
||
})
|
||
}
|
||
return r.tgStoryViewsList(ctx, userID, list), nil
|
||
}
|
||
|
||
func validateStoriesGetStoryViewsListRequest(req *tg.StoriesGetStoryViewsListRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if req.ID <= 0 || req.ID > domain.MaxStoryID {
|
||
return storyIDInvalidErr()
|
||
}
|
||
if err := validateStoryInteractionListLimit(req.Limit); err != nil {
|
||
return err
|
||
}
|
||
if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil {
|
||
return storyErr(err)
|
||
}
|
||
if q, ok := storyViewsListQuery(req); ok && utf8.RuneCountInString(q) > domain.MaxStoryViewQueryLength {
|
||
return limitInvalidErr()
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func storyViewsListQuery(req *tg.StoriesGetStoryViewsListRequest) (string, bool) {
|
||
if req == nil {
|
||
return "", false
|
||
}
|
||
q, ok := req.GetQ()
|
||
if !ok && req.Q != "" {
|
||
q, ok = req.Q, true
|
||
}
|
||
return q, ok
|
||
}
|
||
|
||
func (r *Router) onStoriesGetStoryReactionsList(ctx context.Context, req *tg.StoriesGetStoryReactionsListRequest) (*tg.StoriesStoryReactionsList, error) {
|
||
reaction, offset, err := validateStoriesGetStoryReactionsListRequest(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if peer.Type != domain.PeerTypeChannel {
|
||
return nil, peerIDInvalidErr()
|
||
}
|
||
if err := r.requireChannelStoryInteractionsAdmin(ctx, userID, peer.ID); err != nil {
|
||
return nil, err
|
||
}
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return r.tgStoryReactionsList(ctx, userID, domain.StoryReactionList{}), nil
|
||
}
|
||
list, err := r.deps.Stories.GetStoryReactionsList(ctx, userID, domain.StoryReactionListRequest{
|
||
Owner: peer,
|
||
StoryID: req.ID,
|
||
Reaction: reaction,
|
||
Offset: offset,
|
||
Limit: req.Limit,
|
||
ForwardsFirst: req.GetForwardsFirst(),
|
||
CanViewOwnerInteractions: true,
|
||
})
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
if reaction == nil {
|
||
list = r.withStoryMessageForwardReactions(ctx, userID, list, domain.StoryMessageForwardListRequest{
|
||
Owner: peer,
|
||
StoryID: req.ID,
|
||
Offset: offset,
|
||
Limit: req.Limit,
|
||
ForwardsFirst: req.GetForwardsFirst(),
|
||
})
|
||
}
|
||
return r.tgStoryReactionsList(ctx, userID, list), nil
|
||
}
|
||
|
||
func validateStoriesGetStoryReactionsListRequest(req *tg.StoriesGetStoryReactionsListRequest) (*domain.MessageReaction, string, error) {
|
||
if req == nil {
|
||
return nil, "", inputRequestInvalidErr()
|
||
}
|
||
if req.ID <= 0 || req.ID > domain.MaxStoryID {
|
||
return nil, "", storyIDInvalidErr()
|
||
}
|
||
if err := validateStoryInteractionListLimit(req.Limit); err != nil {
|
||
return nil, "", err
|
||
}
|
||
var reaction *domain.MessageReaction
|
||
if inputReaction, ok := req.GetReaction(); ok {
|
||
parsed, err := domainStoryReactionFromTL(inputReaction)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
reaction = parsed
|
||
}
|
||
offset, _ := req.GetOffset()
|
||
if err := domain.ValidateStoryReactionInteractionOffset(offset, req.GetForwardsFirst()); err != nil {
|
||
return nil, "", storyErr(err)
|
||
}
|
||
if err := validateStoriesDirectInputPeer(req.Peer); err != nil {
|
||
return nil, "", err
|
||
}
|
||
return reaction, offset, nil
|
||
}
|
||
|
||
func (r *Router) requireChannelStoryInteractionsAdmin(ctx context.Context, userID, channelID int64) error {
|
||
if userID == 0 || channelID == 0 || r.deps.Channels == nil {
|
||
return channelInvalidErr(domain.ErrChannelInvalid)
|
||
}
|
||
member, err := r.deps.Channels.GetParticipant(ctx, userID, channelID, userID)
|
||
if err != nil {
|
||
return channelInvalidErr(err)
|
||
}
|
||
if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin {
|
||
return nil
|
||
}
|
||
return channelInvalidErr(domain.ErrChannelAdminRequired)
|
||
}
|
||
|
||
func (r *Router) onStoriesTogglePeerStoriesHidden(ctx context.Context, req *tg.StoriesTogglePeerStoriesHiddenRequest) (bool, error) {
|
||
if err := validateStoriesTogglePeerStoriesHiddenRequest(req); err != nil {
|
||
return false, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return false, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if r.deps.Stories != nil && userID != 0 {
|
||
if err := r.deps.Stories.TogglePeerStoriesHidden(ctx, userID, peer, req.Hidden); err != nil {
|
||
return false, storyErr(err)
|
||
}
|
||
}
|
||
r.invalidateStoryProjectionCache(userID, peer)
|
||
return true, nil
|
||
}
|
||
|
||
func validateStoriesTogglePeerStoriesHiddenRequest(req *tg.StoriesTogglePeerStoriesHiddenRequest) error {
|
||
if req == nil {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
return validateStoriesDirectInputPeer(req.Peer)
|
||
}
|
||
|
||
func (r *Router) onStoriesSendReaction(ctx context.Context, req *tg.StoriesSendReactionRequest) (tg.UpdatesClass, error) {
|
||
reaction, err := validateStoriesSendReactionRequest(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
now := int(r.clock.Now().Unix())
|
||
if r.deps.Stories == nil || userID == 0 {
|
||
return r.tgStoryReactionUpdates(ctx, userID, peer, req.StoryID, req.Reaction, now), nil
|
||
}
|
||
res, err := r.deps.Stories.SendReaction(ctx, userID, peer, req.StoryID, reaction, now)
|
||
if err != nil {
|
||
return nil, storyErr(err)
|
||
}
|
||
if res.Changed {
|
||
// 反应改变了该故事的 reaction 计数(所有 viewer 可见)与本 viewer 的 SentReaction,
|
||
// 必须失效该 peer 的故事投影缓存,否则 getPinnedStories 会命中陈旧页(陈旧 SentReaction/计数)。
|
||
r.invalidateStoryProjectionCacheForPeer(peer)
|
||
}
|
||
if reaction != nil && res.Changed {
|
||
if err := r.recordMessageReactionUse(ctx, userID, []domain.MessageReaction{*reaction}, req.GetAddToRecent(), now); err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
}
|
||
if res.Changed && r.deps.Updates != nil {
|
||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||
sessionID, _ := SessionIDFrom(ctx)
|
||
if _, _, err := r.deps.Updates.RecordSentStoryReaction(ctx, authKeyID, userID, res, rawAuthKeyIDForOrigin(ctx), sessionID); err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
if ownerUserID, ok := ownerStoryReactionNotificationUserID(res, userID); ok && res.Reaction != nil {
|
||
event, _, err := r.deps.Updates.RecordNewStoryReaction(ctx, [8]byte{}, ownerUserID, res, [8]byte{}, 0)
|
||
if err != nil {
|
||
return nil, internalErr()
|
||
}
|
||
updates, buildErr := r.BuildOutboxUpdates(ctx, []OutboxUpdateRequest{{
|
||
TargetUserID: ownerUserID,
|
||
Event: event,
|
||
}})
|
||
if buildErr != nil {
|
||
r.log.Error("build story reaction outbox update",
|
||
zap.Int64("viewer_user_id", ownerUserID),
|
||
zap.Error(buildErr))
|
||
} else if len(updates) == 1 && updates[0] != nil {
|
||
r.pushUserUpdatesIfNoReliableDispatch(ctx, ownerUserID, updates[0])
|
||
}
|
||
}
|
||
}
|
||
return r.tgStoryReactionUpdates(ctx, userID, peer, req.StoryID, req.Reaction, now), nil
|
||
}
|
||
|
||
func validateStoriesSendReactionRequest(req *tg.StoriesSendReactionRequest) (*domain.MessageReaction, error) {
|
||
if req == nil {
|
||
return nil, inputRequestInvalidErr()
|
||
}
|
||
if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID {
|
||
return nil, storyIDInvalidErr()
|
||
}
|
||
reaction, err := domainStoryReactionFromTL(req.Reaction)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := validateStoriesDirectInputPeer(req.Peer); err != nil {
|
||
return nil, err
|
||
}
|
||
return reaction, nil
|
||
}
|
||
|
||
func ownerStoryReactionNotificationUserID(res domain.StoryReactionResult, viewerUserID int64) (int64, bool) {
|
||
owner := res.Story.Owner
|
||
if owner.ID == 0 {
|
||
owner = res.Peer
|
||
}
|
||
if owner.Type != domain.PeerTypeUser || owner.ID == 0 || owner.ID == viewerUserID {
|
||
return 0, false
|
||
}
|
||
return owner.ID, true
|
||
}
|
||
|
||
type storyVisibility struct {
|
||
Public bool
|
||
CloseFriends bool
|
||
Contacts bool
|
||
SelectedContacts bool
|
||
Rules []domain.PrivacyRule
|
||
AllowUserIDs []int64
|
||
DisallowUserIDs []int64
|
||
}
|
||
|
||
func (r *Router) storyVisibilityFromInputPrivacyRules(ctx context.Context, userID int64, rules []tg.InputPrivacyRuleClass) (storyVisibility, error) {
|
||
domainRules, err := r.domainPrivacyRulesFromInput(ctx, userID, rules)
|
||
if err != nil {
|
||
return storyVisibility{}, err
|
||
}
|
||
return storyVisibilityFromDomainPrivacyRules(domainRules), nil
|
||
}
|
||
|
||
func storyVisibilityFromDomainPrivacyRules(rules []domain.PrivacyRule) storyVisibility {
|
||
out := storyVisibility{Rules: cloneDomainPrivacyRules(rules)}
|
||
for i, rule := range rules {
|
||
if i == 0 {
|
||
switch rule.Kind {
|
||
case domain.PrivacyRuleAllowAll:
|
||
out.Public = true
|
||
case domain.PrivacyRuleAllowContacts:
|
||
out.Contacts = true
|
||
case domain.PrivacyRuleAllowUsers:
|
||
out.SelectedContacts = true
|
||
case domain.PrivacyRuleAllowCloseFriends:
|
||
out.CloseFriends = true
|
||
}
|
||
}
|
||
switch rule.Kind {
|
||
case domain.PrivacyRuleAllowUsers:
|
||
out.AllowUserIDs = appendUniquePositiveInt64(out.AllowUserIDs, rule.UserIDs...)
|
||
case domain.PrivacyRuleDisallowUsers:
|
||
out.DisallowUserIDs = appendUniquePositiveInt64(out.DisallowUserIDs, rule.UserIDs...)
|
||
case domain.PrivacyRuleDisallowAll:
|
||
out.Public = false
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func cloneDomainPrivacyRules(in []domain.PrivacyRule) []domain.PrivacyRule {
|
||
if len(in) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]domain.PrivacyRule, len(in))
|
||
for i, rule := range in {
|
||
out[i] = rule
|
||
out[i].UserIDs = append([]int64(nil), rule.UserIDs...)
|
||
out[i].ChatIDs = append([]int64(nil), rule.ChatIDs...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func appendUniquePositiveInt64(base []int64, ids ...int64) []int64 {
|
||
seen := make(map[int64]struct{}, len(base)+len(ids))
|
||
out := make([]int64, 0, len(base)+len(ids))
|
||
for _, id := range base {
|
||
if id <= 0 {
|
||
continue
|
||
}
|
||
if _, ok := seen[id]; ok {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
out = append(out, id)
|
||
}
|
||
for _, id := range ids {
|
||
if id <= 0 {
|
||
continue
|
||
}
|
||
if _, ok := seen[id]; ok {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
out = append(out, id)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (r *Router) resolveStoryInputMedia(ctx context.Context, userID int64, input tg.InputMediaClass) (*domain.MessageMedia, error) {
|
||
if err := validateStoryInputMediaClass(input); err != nil {
|
||
return nil, err
|
||
}
|
||
media, err := r.resolveInputMedia(ctx, userID, input)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if media == nil {
|
||
return nil, mediaEmptyErr()
|
||
}
|
||
switch media.Kind {
|
||
case domain.MessageMediaKindPhoto, domain.MessageMediaKindDocument:
|
||
return media, nil
|
||
default:
|
||
return nil, mediaTypeInvalidErr()
|
||
}
|
||
}
|
||
|
||
func validateStoryInputMediaClass(input tg.InputMediaClass) error {
|
||
switch typed := input.(type) {
|
||
case nil:
|
||
return mediaEmptyErr()
|
||
case *tg.InputMediaEmpty:
|
||
return mediaEmptyErr()
|
||
case *tg.InputMediaUploadedPhoto:
|
||
if typed == nil {
|
||
return mediaEmptyErr()
|
||
}
|
||
if typed.File == nil {
|
||
return mediaInvalidErr()
|
||
}
|
||
case *tg.InputMediaUploadedDocument:
|
||
if typed == nil {
|
||
return mediaEmptyErr()
|
||
}
|
||
if typed.File == nil {
|
||
return mediaInvalidErr()
|
||
}
|
||
case *tg.InputMediaPhoto:
|
||
if typed == nil {
|
||
return mediaEmptyErr()
|
||
}
|
||
if _, ok := inputPhotoID(typed.ID); !ok {
|
||
return photoInvalidErr()
|
||
}
|
||
case *tg.InputMediaDocument:
|
||
if typed == nil {
|
||
return mediaEmptyErr()
|
||
}
|
||
if _, ok := inputDocumentCandidateIDs(typed.ID); !ok {
|
||
return mediaInvalidErr()
|
||
}
|
||
default:
|
||
return mediaTypeInvalidErr()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateStoryIDSlice(ids []int) error {
|
||
if len(ids) > domain.MaxStoryIDs {
|
||
return storyIDInvalidErr()
|
||
}
|
||
for _, id := range ids {
|
||
if id <= 0 || id > domain.MaxStoryID {
|
||
return storyIDInvalidErr()
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateStoryPageBounds(offsetID, limit int) error {
|
||
if offsetID < -1 || offsetID > domain.MaxStoryID {
|
||
return storyIDInvalidErr()
|
||
}
|
||
if limit < 0 || limit > domain.MaxStoryListLimit {
|
||
return limitInvalidErr()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateStoryInteractionListLimit(limit int) error {
|
||
if limit < 0 || limit > domain.MaxStoryListLimit {
|
||
return limitInvalidErr()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateStoryAlbumID(id int) error {
|
||
if id <= 0 || id > domain.MaxStoryID {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateStoryAlbumIDSlice(ids []int) error {
|
||
if len(ids) > domain.MaxStoryIDs {
|
||
return limitInvalidErr()
|
||
}
|
||
seen := make(map[int]struct{}, len(ids))
|
||
for _, id := range ids {
|
||
if err := validateStoryAlbumID(id); err != nil {
|
||
return err
|
||
}
|
||
if _, ok := seen[id]; ok {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
seen[id] = struct{}{}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateStoryAlbumStoryIDSlice(ids []int) error {
|
||
if err := validateStoryIDSlice(ids); err != nil {
|
||
return err
|
||
}
|
||
seen := make(map[int]struct{}, len(ids))
|
||
for _, id := range ids {
|
||
if _, ok := seen[id]; ok {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
seen[id] = struct{}{}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateStoryAlbumUpdateStoryIDSlice(ids []int) error {
|
||
if len(ids) == 0 {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
return validateStoryAlbumStoryIDSlice(ids)
|
||
}
|
||
|
||
func validateStoryAlbumTitle(title string) error {
|
||
if strings.TrimSpace(title) == "" {
|
||
return inputRequestInvalidErr()
|
||
}
|
||
if utf8.RuneCountInString(title) > maxStoryAlbumTitleLength {
|
||
return limitInvalidErr()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Router) storyAlbumWriteScope(ctx context.Context, input tg.InputPeerClass) (int64, domain.Peer, error) {
|
||
userID, _, err := r.currentUserID(ctx)
|
||
if err != nil {
|
||
return 0, domain.Peer{}, internalErr()
|
||
}
|
||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
|
||
if err != nil {
|
||
return 0, domain.Peer{}, err
|
||
}
|
||
switch peer.Type {
|
||
case domain.PeerTypeUser:
|
||
if userID != 0 && peer.ID == userID {
|
||
return userID, peer, nil
|
||
}
|
||
return 0, domain.Peer{}, peerIDInvalidErr()
|
||
case domain.PeerTypeChannel:
|
||
if err := r.requireChannelStoryAlbumAdmin(ctx, userID, peer.ID); err != nil {
|
||
return 0, domain.Peer{}, err
|
||
}
|
||
return userID, peer, nil
|
||
default:
|
||
return 0, domain.Peer{}, peerIDInvalidErr()
|
||
}
|
||
}
|
||
|
||
func (r *Router) requireChannelStoryAlbumAdmin(ctx context.Context, userID, channelID int64) error {
|
||
if userID == 0 || channelID == 0 || r.deps.Channels == nil {
|
||
return channelInvalidErr(domain.ErrChannelInvalid)
|
||
}
|
||
member, err := r.deps.Channels.GetParticipant(ctx, userID, channelID, userID)
|
||
if err != nil {
|
||
return channelInvalidErr(err)
|
||
}
|
||
if member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.EditStories) {
|
||
return nil
|
||
}
|
||
return channelInvalidErr(domain.ErrChannelAdminRequired)
|
||
}
|
||
|
||
func uniqueStoryIDs(ids []int) []int {
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
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 (r *Router) storyExportLink(peer domain.Peer, storyID int) string {
|
||
return r.publicLink(fmt.Sprintf("story/%s/%d/%d", peer.Type, peer.ID, storyID))
|
||
}
|
||
|
||
func (r *Router) recordStoryChange(ctx context.Context, userID int64, story domain.Story) error {
|
||
if r.deps.Updates == nil {
|
||
return nil
|
||
}
|
||
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||
sessionID, _ := SessionIDFrom(ctx)
|
||
if _, _, err := r.deps.Updates.RecordStory(ctx, authKeyID, userID, story, rawAuthKeyIDForOrigin(ctx), sessionID); err != nil {
|
||
return internalErr()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func tgStoryChangeUpdates(peer domain.Peer, story domain.Story, randomID int64, includeStoryID bool, date int) tg.UpdatesClass {
|
||
updates := make([]tg.UpdateClass, 0, 2)
|
||
if includeStoryID {
|
||
updates = append(updates, &tg.UpdateStoryID{ID: story.ID, RandomID: randomID})
|
||
}
|
||
updates = append(updates, &tg.UpdateStory{Peer: tgPeer(peer), Story: tgStoryItem(story)})
|
||
return &tg.Updates{
|
||
Updates: updates,
|
||
Users: []tg.UserClass{},
|
||
Chats: []tg.ChatClass{},
|
||
Date: date,
|
||
Seq: 0,
|
||
}
|
||
}
|
||
|
||
func tgStoryStealthModeUpdates(date int, past, future bool) tg.UpdatesClass {
|
||
mode := tg.StoriesStealthMode{}
|
||
if future {
|
||
mode.SetActiveUntilDate(date + storyStealthFuturePeriodSeconds)
|
||
}
|
||
if past || future {
|
||
mode.SetCooldownUntilDate(date + storyStealthCooldownSeconds)
|
||
}
|
||
return &tg.Updates{
|
||
Updates: []tg.UpdateClass{&tg.UpdateStoriesStealthMode{StealthMode: mode}},
|
||
Users: []tg.UserClass{},
|
||
Chats: []tg.ChatClass{},
|
||
Date: date,
|
||
Seq: 0,
|
||
}
|
||
}
|
||
|
||
func domainStoryReactionFromTL(reaction tg.ReactionClass) (*domain.MessageReaction, error) {
|
||
if reactionClassNil(reaction) {
|
||
return nil, reactionInvalidErr()
|
||
}
|
||
if empty, ok := reaction.(*tg.ReactionEmpty); ok && empty != nil {
|
||
return nil, nil
|
||
}
|
||
out, err := domainStoryReactionValueFromTL(reaction)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &out, nil
|
||
}
|
||
|
||
func tgStoryReactionUpdates(peer domain.Peer, storyID int, reaction tg.ReactionClass, date int) tg.UpdatesClass {
|
||
if reaction == nil {
|
||
reaction = &tg.ReactionEmpty{}
|
||
}
|
||
return &tg.Updates{
|
||
Updates: []tg.UpdateClass{&tg.UpdateSentStoryReaction{Peer: tgPeer(peer), StoryID: storyID, Reaction: reaction}},
|
||
Date: date,
|
||
Seq: 0,
|
||
}
|
||
}
|
||
|
||
func tgEmptyStoriesStoryViews(count int) *tg.StoriesStoryViews {
|
||
if count < 0 {
|
||
count = 0
|
||
}
|
||
views := make([]tg.StoryViews, count)
|
||
return &tg.StoriesStoryViews{Views: views, Users: []tg.UserClass{}}
|
||
}
|
||
|
||
func (r *Router) tgStoriesStoryViewsForIDs(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, stories []domain.Story) *tg.StoriesStoryViews {
|
||
byID := make(map[int]domain.Story, len(stories))
|
||
recentViewerIDs := make([]int64, 0)
|
||
for _, story := range stories {
|
||
if story.Owner != peer || story.ID <= 0 || story.ID > domain.MaxStoryID {
|
||
continue
|
||
}
|
||
byID[story.ID] = story
|
||
recentViewerIDs = append(recentViewerIDs, storyViewsForCounterResponse(viewerUserID, story).RecentViewers...)
|
||
}
|
||
views := make([]tg.StoryViews, 0, len(ids))
|
||
for _, id := range ids {
|
||
story, ok := byID[id]
|
||
if !ok {
|
||
views = append(views, tg.StoryViews{})
|
||
continue
|
||
}
|
||
views = append(views, tgStoryViews(storyViewsForCounterResponse(viewerUserID, story)))
|
||
}
|
||
return &tg.StoriesStoryViews{
|
||
Views: views,
|
||
Users: tgUsersForViewer(viewerUserID, r.domainUsersForIDs(ctx, viewerUserID, uniquePeerIDs(recentViewerIDs))),
|
||
}
|
||
}
|
||
|
||
func storyViewsForCounterResponse(viewerUserID int64, story domain.Story) domain.StoryViews {
|
||
views := story.Views
|
||
if !story.Owner.IsSelfUser(viewerUserID) {
|
||
views.HasViewers = false
|
||
views.RecentViewers = nil
|
||
}
|
||
return views
|
||
}
|
||
|
||
func (r *Router) addStoryMessageForwardCounts(ctx context.Context, viewerUserID int64, stories []domain.Story) {
|
||
if r.deps.Channels == nil || viewerUserID == 0 {
|
||
return
|
||
}
|
||
for i := range stories {
|
||
count, err := r.storyMessageForwardCount(ctx, viewerUserID, stories[i].Owner, stories[i].ID)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
stories[i].Views.ForwardsCount += count
|
||
}
|
||
}
|
||
|
||
func (r *Router) withStoryMessageForwardViews(ctx context.Context, viewerUserID int64, list domain.StoryViewList, req domain.StoryMessageForwardListRequest) domain.StoryViewList {
|
||
forwards := r.storyMessageForwardPage(ctx, viewerUserID, req)
|
||
if forwards.Count == 0 && len(forwards.Forwards) == 0 {
|
||
return list
|
||
}
|
||
hasMore := storyInteractionSourcesHaveMore(len(list.Views), len(forwards.Forwards), req.Limit, list.NextOffset != "" || forwards.NextOffset != "")
|
||
list.Count += forwards.Count
|
||
list.ForwardsCount += forwards.Count
|
||
list.Views = mergeStoryInteractionViews(list.Views, forwards.Forwards, req.Limit, req.ReactionsFirst, req.ForwardsFirst)
|
||
list.NextOffset = nextStoryInteractionOffset(list.Views, req.Limit, req.ReactionsFirst, req.ForwardsFirst, hasMore)
|
||
return list
|
||
}
|
||
|
||
func (r *Router) withStoryMessageForwardReactions(ctx context.Context, viewerUserID int64, list domain.StoryReactionList, req domain.StoryMessageForwardListRequest) domain.StoryReactionList {
|
||
forwards := r.storyMessageForwardPage(ctx, viewerUserID, req)
|
||
if forwards.Count == 0 && len(forwards.Forwards) == 0 {
|
||
return list
|
||
}
|
||
hasMore := storyInteractionSourcesHaveMore(len(list.Reactions), len(forwards.Forwards), req.Limit, list.NextOffset != "" || forwards.NextOffset != "")
|
||
list.Count += forwards.Count
|
||
list.Reactions = mergeStoryInteractionViews(list.Reactions, forwards.Forwards, req.Limit, false, req.ForwardsFirst)
|
||
list.NextOffset = nextStoryInteractionOffset(list.Reactions, req.Limit, false, req.ForwardsFirst, hasMore)
|
||
return list
|
||
}
|
||
|
||
func (r *Router) storyMessageForwardPage(ctx context.Context, viewerUserID int64, req domain.StoryMessageForwardListRequest) domain.StoryMessageForwardList {
|
||
if r.deps.Channels == nil || viewerUserID == 0 {
|
||
return domain.StoryMessageForwardList{}
|
||
}
|
||
req.ViewerUserID = viewerUserID
|
||
if req.Limit <= 0 || req.Limit > domain.MaxStoryInteractionListLimit {
|
||
req.Limit = domain.MaxStoryInteractionListLimit
|
||
}
|
||
list, err := r.deps.Channels.ListStoryMessageForwards(ctx, viewerUserID, req)
|
||
if err != nil {
|
||
return domain.StoryMessageForwardList{}
|
||
}
|
||
return list
|
||
}
|
||
|
||
func (r *Router) storyMessageForwardCount(ctx context.Context, viewerUserID int64, owner domain.Peer, storyID int) (int, error) {
|
||
list, err := r.deps.Channels.ListStoryMessageForwards(ctx, viewerUserID, domain.StoryMessageForwardListRequest{
|
||
ViewerUserID: viewerUserID,
|
||
Owner: owner,
|
||
StoryID: storyID,
|
||
Limit: 1,
|
||
})
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return list.Count, nil
|
||
}
|
||
|
||
func storyInteractionSourcesHaveMore(leftLen, rightLen, limit int, sourceHasMore bool) bool {
|
||
if limit <= 0 || limit > domain.MaxStoryInteractionListLimit {
|
||
limit = domain.MaxStoryInteractionListLimit
|
||
}
|
||
return sourceHasMore || leftLen+rightLen > limit
|
||
}
|
||
|
||
func mergeStoryInteractionViews(left, right []domain.StoryView, limit int, reactionsFirst, forwardsFirst bool) []domain.StoryView {
|
||
if limit <= 0 || limit > domain.MaxStoryInteractionListLimit {
|
||
limit = domain.MaxStoryInteractionListLimit
|
||
}
|
||
merged := make([]domain.StoryView, 0, len(left)+len(right))
|
||
merged = append(merged, left...)
|
||
merged = append(merged, right...)
|
||
sort.Slice(merged, func(i, j int) bool {
|
||
return storyInteractionLess(merged[i], merged[j], reactionsFirst, forwardsFirst)
|
||
})
|
||
if len(merged) > limit {
|
||
return merged[:limit]
|
||
}
|
||
return merged
|
||
}
|
||
|
||
func nextStoryInteractionOffset(views []domain.StoryView, limit int, reactionsFirst, forwardsFirst bool, hasMore bool) string {
|
||
if limit <= 0 || limit > domain.MaxStoryInteractionListLimit {
|
||
limit = domain.MaxStoryInteractionListLimit
|
||
}
|
||
if !hasMore || len(views) == 0 || len(views) < limit {
|
||
return ""
|
||
}
|
||
return formatStoryInteractionOffset(views[len(views)-1], reactionsFirst, forwardsFirst)
|
||
}
|
||
|
||
func storyInteractionLess(a, b domain.StoryView, reactionsFirst, forwardsFirst bool) bool {
|
||
ga := storyInteractionGroup(a, reactionsFirst, forwardsFirst)
|
||
gb := storyInteractionGroup(b, reactionsFirst, forwardsFirst)
|
||
if ga != gb {
|
||
return ga < gb
|
||
}
|
||
if a.Date != b.Date {
|
||
return a.Date > b.Date
|
||
}
|
||
ak, aid := storyInteractionCursorKey(a)
|
||
bk, bid := storyInteractionCursorKey(b)
|
||
if ak != bk {
|
||
return ak > bk
|
||
}
|
||
return aid > bid
|
||
}
|
||
|
||
func storyInteractionGroup(view domain.StoryView, reactionsFirst, forwardsFirst bool) int {
|
||
if forwardsFirst {
|
||
if view.Repost != nil || view.PublicForward != nil {
|
||
return 0
|
||
}
|
||
return 1
|
||
}
|
||
if reactionsFirst && view.Reaction == nil && view.Repost == nil && view.PublicForward == nil {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func storyInteractionCursorKey(view domain.StoryView) (int64, int) {
|
||
if view.PublicForward != nil {
|
||
return -view.PublicForward.Message.ChannelID, view.PublicForward.Message.ID
|
||
}
|
||
if view.Repost != nil {
|
||
if view.Repost.Owner.Type == domain.PeerTypeChannel {
|
||
return -view.Repost.Owner.ID, 0
|
||
}
|
||
return view.Repost.Owner.ID, 0
|
||
}
|
||
return view.ViewerID, 0
|
||
}
|
||
|
||
func formatStoryInteractionOffset(view domain.StoryView, reactionsFirst, forwardsFirst bool) string {
|
||
group := storyInteractionGroup(view, reactionsFirst, forwardsFirst)
|
||
key, messageID := storyInteractionCursorKey(view)
|
||
out := strconv.Itoa(group) + ":" + strconv.Itoa(view.Date) + ":" + strconv.FormatInt(key, 10)
|
||
if messageID > 0 {
|
||
out += ":" + strconv.Itoa(messageID)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func storyErr(err error) error {
|
||
switch {
|
||
case err == nil:
|
||
return nil
|
||
case errors.Is(err, domain.ErrStoryIDInvalid):
|
||
return storyIDInvalidErr()
|
||
case errors.Is(err, domain.ErrStoryPeerInvalid):
|
||
return peerIDInvalidErr()
|
||
case errors.Is(err, domain.ErrStoryNotFound):
|
||
return storyIDInvalidErr()
|
||
case errors.Is(err, domain.ErrStoryNotModified):
|
||
return storyNotModifiedErr()
|
||
case errors.Is(err, domain.ErrStoryOffsetInvalid):
|
||
return offsetInvalidErr()
|
||
case errors.Is(err, domain.ErrStoryPeriodInvalid):
|
||
return storyPeriodInvalidErr()
|
||
case errors.Is(err, domain.ErrChannelInvalid),
|
||
errors.Is(err, domain.ErrChannelPrivate),
|
||
errors.Is(err, domain.ErrChannelUserBanned),
|
||
errors.Is(err, domain.ErrChannelWriteForbidden),
|
||
errors.Is(err, domain.ErrChannelAdminRequired):
|
||
return channelInvalidErr(err)
|
||
default:
|
||
return internalErr()
|
||
}
|
||
}
|