owpengram-server/internal/rpc/stories.go
2026-09-01 12:06:31 +03:00

3422 lines
105 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
}
}